From 564ca172548be591b097f752182ae998418c158d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 6 Mar 2026 12:41:25 +0100 Subject: [PATCH] feat(work-view): show recurring tasks in project view Add a collapsible "Recurring Tasks" section at the bottom of the project work-view showing repeatable task configs for the active project. Clicking a row opens the edit dialog, hovering shows next due date. The section is hidden on tag views, when no repeat configs exist for the project, and when task view filters/grouping are active. Closes #4933 --- .../core/persistence/storage-keys.const.ts | 1 + .../repeat-cfg-preview.component.html | 8 +++ .../repeat-cfg-preview.component.scss | 44 +++++++++++++ .../repeat-cfg-preview.component.ts | 63 +++++++++++++++++++ .../store/task-repeat-cfg.selectors.ts | 8 +++ .../work-view/work-view.component.html | 25 ++++++++ .../work-view/work-view.component.scss | 4 ++ .../features/work-view/work-view.component.ts | 38 +++++++++++ src/app/t.const.ts | 1 + src/assets/i18n/en.json | 1 + 10 files changed, 193 insertions(+) create mode 100644 src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.html create mode 100644 src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.scss create mode 100644 src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.ts diff --git a/src/app/core/persistence/storage-keys.const.ts b/src/app/core/persistence/storage-keys.const.ts index 6cc4b1f6c6..48601ba6cc 100644 --- a/src/app/core/persistence/storage-keys.const.ts +++ b/src/app/core/persistence/storage-keys.const.ts @@ -49,6 +49,7 @@ export enum LS { DONE_TASKS_HIDDEN = 'DONE_TASKS_HIDDEN', LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN', OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN', + REPEAT_CFGS_HIDDEN = 'REPEAT_CFGS_HIDDEN', // Magic side nav NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED', diff --git a/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.html b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.html new file mode 100644 index 0000000000..f2f93727f8 --- /dev/null +++ b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.html @@ -0,0 +1,8 @@ +
+ +
{{ repeatCfg().title }}
+
{{ getRepeatInfoText() }}
+
diff --git a/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.scss b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.scss new file mode 100644 index 0000000000..f9253ca23c --- /dev/null +++ b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.scss @@ -0,0 +1,44 @@ +@use '../../../../styles/_globals.scss' as *; + +:host { + display: block; + margin-bottom: var(--s-half); +} + +:host > div { + display: flex; + align-items: center; + padding-top: var(--s-quarter); + padding-bottom: var(--s-quarter); + border: 1px solid var(--extra-border-color); + border-radius: var(--task-border-radius); + opacity: 0.9; + cursor: pointer; + min-height: var(--planner-item-height); + + &:hover { + border-color: var(--c-primary); + } + + mat-icon { + margin-left: var(--s); + margin-right: var(--s); + opacity: 0.8; + } +} + +.title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.info { + white-space: nowrap; + margin-left: var(--s); + margin-right: var(--s); + opacity: 0.7; + font-size: 12px; +} diff --git a/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.ts b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.ts new file mode 100644 index 0000000000..8b1f4d1a9d --- /dev/null +++ b/src/app/features/task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core'; +import { TaskRepeatCfg } from '../task-repeat-cfg.model'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogEditTaskRepeatCfgComponent } from '../dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component'; +import { getTaskRepeatInfoText } from '../../tasks/task-detail-panel/get-task-repeat-info-text.util'; +import { T } from '../../../t.const'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service'; +import { getNextRepeatOccurrence } from '../store/get-next-repeat-occurrence.util'; +import { formatMonthDay } from '../../../util/format-month-day.util'; + +@Component({ + selector: 'repeat-cfg-preview', + templateUrl: './repeat-cfg-preview.component.html', + styleUrl: './repeat-cfg-preview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, TranslatePipe, MatTooltip], +}) +export class RepeatCfgPreviewComponent { + private _matDialog = inject(MatDialog); + private _translateService = inject(TranslateService); + private _dateTimeFormatService = inject(DateTimeFormatService); + + repeatCfg = input.required(); + + T = T; + + nextDueTooltip = computed(() => { + const cfg = this.repeatCfg(); + const nextDate = getNextRepeatOccurrence(cfg, new Date()); + if (!nextDate) { + return ''; + } + const locale = this._dateTimeFormatService.currentLocale(); + const formatted = formatMonthDay(nextDate, locale); + const nextLabel = this._translateService.instant(T.SCHEDULE.NEXT); + return `${nextLabel} ${formatted}`; + }); + + editTaskRepeatCfg(): void { + this._matDialog.open(DialogEditTaskRepeatCfgComponent, { + restoreFocus: false, + data: { repeatCfg: this.repeatCfg() }, + }); + } + + getRepeatInfoText(): string { + const [key, params] = getTaskRepeatInfoText( + this.repeatCfg(), + this._dateTimeFormatService.currentLocale(), + this._dateTimeFormatService, + ); + return this._translateService.instant(key, params); + } +} diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.ts index 1de19ff2d6..b7f9c1579a 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.selectors.ts @@ -154,6 +154,14 @@ export const selectAllUnprocessedTaskRepeatCfgs = createSelector( ); }, ); +export const selectTaskRepeatCfgsByProjectId = createSelector( + selectAllTaskRepeatCfgs, + (taskRepeatCfgs: TaskRepeatCfg[], props: { projectId: string }): TaskRepeatCfg[] => { + return taskRepeatCfgs + .filter((cfg) => cfg.projectId === props.projectId) + .sort((a, b) => (a.title || '').localeCompare(b.title || '')); + }, +); export const selectTaskRepeatCfgByIdAllowUndefined = createSelector( selectTaskRepeatCfgFeatureState, (state: TaskRepeatCfgState, props: { id: string }): TaskRepeatCfg | undefined => diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html index cbed78c13c..cb26b8ade8 100644 --- a/src/app/features/work-view/work-view.component.html +++ b/src/app/features/work-view/work-view.component.html @@ -310,6 +310,31 @@ } + @if (isShowRepeatCfgsPanel()) { +
+ +
+ @for (repeatCfg of repeatCfgsForProject(); track repeatCfg.id) { + + } +
+
+
+ } + @if (!isPlanningMode() && isOnTodayList()) { } diff --git a/src/app/features/work-view/work-view.component.scss b/src/app/features/work-view/work-view.component.scss index 7ffc7b5a42..9569feecb2 100644 --- a/src/app/features/work-view/work-view.component.scss +++ b/src/app/features/work-view/work-view.component.scss @@ -327,3 +327,7 @@ finish-day-btn, padding-bottom: 8px; padding-top: 8px; } + +.repeat-cfg-list { + padding: var(--s-half); +} diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index 132f88197a..67bc56e729 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -23,6 +23,7 @@ import { from, fromEvent, Observable, + of, ReplaySubject, Subscription, timer, @@ -63,6 +64,9 @@ import { TODAY_TAG } from '../tag/tag.const'; import { LS } from '../../core/persistence/storage-keys.const'; import { FinishDayBtnComponent } from './finish-day-btn/finish-day-btn.component'; import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe'; +import { selectTaskRepeatCfgsByProjectId } from '../task-repeat-cfg/store/task-repeat-cfg.selectors'; +import { TaskRepeatCfg } from '../task-repeat-cfg/task-repeat-cfg.model'; +import { RepeatCfgPreviewComponent } from '../task-repeat-cfg/repeat-cfg-preview/repeat-cfg-preview.component'; @Component({ selector: 'work-view', @@ -94,6 +98,7 @@ import { ScheduledDateGroupPipe } from '../../ui/pipes/scheduled-date-group.pipe CommonModule, FinishDayBtnComponent, ScheduledDateGroupPipe, + RepeatCfgPreviewComponent, ], }) export class WorkViewComponent implements OnInit, OnDestroy { @@ -140,6 +145,30 @@ export class WorkViewComponent implements OnInit, OnDestroy { isDoneHidden = signal(!!localStorage.getItem(LS.DONE_TASKS_HIDDEN)); isLaterTodayHidden = signal(!!localStorage.getItem(LS.LATER_TODAY_TASKS_HIDDEN)); isOverdueHidden = signal(!!localStorage.getItem(LS.OVERDUE_TASKS_HIDDEN)); + isRepeatCfgsHidden = signal(!!localStorage.getItem(LS.REPEAT_CFGS_HIDDEN)); + + isActiveContextProject = toSignal(this.workContextService.isActiveWorkContextProject$, { + initialValue: false, + }); + repeatCfgsForProject = toSignal( + this.workContextService.activeWorkContextTypeAndId$.pipe( + switchMap(({ activeType, activeId }) => + activeType === 'PROJECT' + ? this._store.select(selectTaskRepeatCfgsByProjectId, { + projectId: activeId, + }) + : of([] as TaskRepeatCfg[]), + ), + ), + { initialValue: [] as TaskRepeatCfg[] }, + ); + + isShowRepeatCfgsPanel = computed( + () => + this.isActiveContextProject() && + !this.customizerService.isCustomized() && + this.repeatCfgsForProject().length > 0, + ); isShowOverduePanel = computed( () => this.isOnTodayList() && this.overdueTasks().length > 0, @@ -231,6 +260,15 @@ export class WorkViewComponent implements OnInit, OnDestroy { } }); + effect(() => { + const isHidden = this.isRepeatCfgsHidden(); + if (isHidden) { + localStorage.setItem(LS.REPEAT_CFGS_HIDDEN, 'true'); + } else { + localStorage.removeItem(LS.REPEAT_CFGS_HIDDEN); + } + }); + afterNextRender(() => this._initScrollTracking()); } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 2bd5ddc605..ded708ceb7 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2603,6 +2603,7 @@ const T = { TIME_ESTIMATED: 'WW.TIME_ESTIMATED', TODAY_REMAINING: 'WW.TODAY_REMAINING', WITHOUT_BREAK: 'WW.WITHOUT_BREAK', + RECURRING_TASKS: 'WW.RECURRING_TASKS', WORKING_TODAY: 'WW.WORKING_TODAY', WORKING_TODAY_ARCHIVED: 'WW.WORKING_TODAY_ARCHIVED', }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index c2ec8b1b07..b51c53923e 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2548,6 +2548,7 @@ "TIME_ESTIMATED": "Time estimated:", "TODAY_REMAINING": "Today remaining:", "WITHOUT_BREAK": "Without break:", + "RECURRING_TASKS": "Recurring Tasks", "WORKING_TODAY": "Working today:", "WORKING_TODAY_ARCHIVED": "Time worked today on archived tasks" }