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
This commit is contained in:
Johannes Millan 2026-03-06 12:41:25 +01:00
parent 9ef05b4fab
commit 564ca17254
10 changed files with 193 additions and 0 deletions

View file

@ -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',

View file

@ -0,0 +1,8 @@
<div
(click)="editTaskRepeatCfg()"
[matTooltip]="nextDueTooltip()"
>
<mat-icon svgIcon="repeat"></mat-icon>
<div class="title">{{ repeatCfg().title }}</div>
<div class="info">{{ getRepeatInfoText() }}</div>
</div>

View file

@ -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;
}

View file

@ -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<TaskRepeatCfg>();
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);
}
}

View file

@ -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 =>

View file

@ -310,6 +310,31 @@
</div>
}
@if (isShowRepeatCfgsPanel()) {
<div
@expand
class="collapsible-section"
>
<collapsible
[title]="
(T.WW.RECURRING_TASKS | translate) +
' (' +
repeatCfgsForProject().length +
')'
"
[isExpanded]="!isRepeatCfgsHidden()"
(isExpandedChange)="isRepeatCfgsHidden.set(!$event)"
[isIconBefore]="true"
>
<div class="repeat-cfg-list">
@for (repeatCfg of repeatCfgsForProject(); track repeatCfg.id) {
<repeat-cfg-preview [repeatCfg]="repeatCfg"></repeat-cfg-preview>
}
</div>
</collapsible>
</div>
}
@if (!isPlanningMode() && isOnTodayList()) {
<finish-day-btn [hasDoneTasks]="hasDoneTasks()"></finish-day-btn>
}

View file

@ -327,3 +327,7 @@ finish-day-btn,
padding-bottom: 8px;
padding-top: 8px;
}
.repeat-cfg-list {
padding: var(--s-half);
}

View file

@ -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());
}

View file

@ -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',
},

View file

@ -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"
}