From c68929d1fb5d7559b0cc2ba0aa06978f60012ad2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 13:55:23 +0200 Subject: [PATCH] fix(task-repeat): allow selecting day-of-month recurrence (#8896) * fix(task-repeat): allow selecting day-of-month mode * fix(task-repeat): clarify monthly recurrence pattern * test(task-repeat): cover day-of-month persistence * docs(task-repeat): explain custom monthly patterns --- docs/wiki/2.06-Manage-Repeating-Tasks.md | 1 + ...log-edit-task-repeat-cfg.component.spec.ts | 84 +++++++++++++++++-- .../dialog-edit-task-repeat-cfg.component.ts | 7 ++ .../task-repeat-cfg-form.const.spec.ts | 17 ++++ .../task-repeat-cfg-form.const.ts | 1 + src/app/t.const.ts | 2 + src/assets/i18n/en.json | 3 +- 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/docs/wiki/2.06-Manage-Repeating-Tasks.md b/docs/wiki/2.06-Manage-Repeating-Tasks.md index 8418859e6c..b361f58c82 100755 --- a/docs/wiki/2.06-Manage-Repeating-Tasks.md +++ b/docs/wiki/2.06-Manage-Repeating-Tasks.md @@ -27,6 +27,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro - Schedule type (only for intervals > 1): Fixed schedule (every X from start date) or After completion (X after I finish). - Start date — when the pattern begins (required). - For weekly cycle: check the weekdays (Monday–Sunday) on which the task should recur. + - For monthly cycle: choose a **Monthly pattern**. **Day of month** uses the day shown in **Start date**; first, second, third, fourth, and last patterns also require a **Weekday**. 5. Optionally set **Tags to add** (chip list). 6. Optionally set **Scheduled start time** (e.g. 15:00; leave blank for all-day). If you set a time, **Remind at** appears — choose when to be reminded (e.g. when it starts, 5 minutes before). 7. Optionally set **Default Estimate** (duration). **Order** (number) controls creation order when several recurring tasks are created at the same time; see the field description in the dialog. diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts index 18c7182665..540a74eb71 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts @@ -7,7 +7,10 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { DateAdapter, MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldHarness } from '@angular/material/form-field/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TranslateModule } from '@ngx-translate/core'; import { provideMockStore } from '@ngrx/store/testing'; import { Observable, of, Subject } from 'rxjs'; @@ -74,6 +77,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { getRepeatCfgReturnValue?: | Observable | Subject, + renderTemplate = false, ): Promise> => { mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); @@ -117,7 +121,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { formatTime: () => '12:00 PM', }); - await TestBed.configureTestingModule({ + const testModule = TestBed.configureTestingModule({ imports: [ DialogEditTaskRepeatCfgComponent, MatDialogModule, @@ -140,16 +144,20 @@ describe('DialogEditTaskRepeatCfgComponent', () => { { provide: DateService, useValue: mockDateService }, { provide: DateAdapter, useClass: CustomDateAdapter }, ], - }) - .overrideComponent(DialogEditTaskRepeatCfgComponent, { + }); + + if (!renderTemplate) { + testModule.overrideComponent(DialogEditTaskRepeatCfgComponent, { set: { // Use a minimal template to avoid @ngx-formly/material select rendering, // which triggers a compareWith validation error with Angular Material 21+. // These tests verify component signals/logic, not template rendering. template: '
', }, - }) - .compileComponents(); + }); + } + + await testModule.compileComponents(); return TestBed.createComponent(DialogEditTaskRepeatCfgComponent); }; @@ -158,6 +166,72 @@ describe('DialogEditTaskRepeatCfgComponent', () => { TestBed.resetTestingModule(); }); + it('keeps Day of month selected after switching from an Nth weekday (#8886)', async () => { + const monthlyNthWeekdayCfg: TaskRepeatCfg = { + ...DEFAULT_TASK_REPEAT_CFG, + id: 'repeat-cfg-monthly-nth-weekday', + title: 'Monthly task', + quickSetting: 'CUSTOM', + repeatCycle: 'MONTHLY', + startDate: '2026-06-09', + monthlyWeekOfMonth: 2, + monthlyWeekday: 1, + }; + const fixture = await setupTestBed( + { repeatCfg: monthlyNthWeekdayCfg }, + undefined, + true, + ); + fixture.detectChanges(); + await fixture.whenStable(); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const selects = await loader.getAllHarnesses(MatSelectHarness); + const formFieldsBeforeSwitch = await loader.getAllHarnesses(MatFormFieldHarness); + const labelsBeforeSwitch = await Promise.all( + formFieldsBeforeSwitch.map((formField) => formField.getLabel()), + ); + expect(labelsBeforeSwitch).toContain(T.F.TASK_REPEAT.F.WEEKDAY); + let monthlyPatternSelect: MatSelectHarness | undefined; + let dayOfMonthOptionText = ''; + + for (const select of selects) { + await select.open(); + const [dayOfMonthOption] = await select.getOptions({ + text: /MONTHLY_MODE_DAY_OF_MONTH/, + }); + if (dayOfMonthOption) { + monthlyPatternSelect = select; + dayOfMonthOptionText = await dayOfMonthOption.getText(); + await dayOfMonthOption.click(); + break; + } + await select.close(); + } + + expect(monthlyPatternSelect).toBeDefined(); + expect(await monthlyPatternSelect!.getValueText()).toBe(dayOfMonthOptionText); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.repeatCfg().monthlyWeekOfMonth).toBeNull(); + const formFieldsAfterSwitch = await loader.getAllHarnesses(MatFormFieldHarness); + const labelsAfterSwitch = await Promise.all( + formFieldsAfterSwitch.map((formField) => formField.getLabel()), + ); + expect(labelsAfterSwitch).not.toContain(T.F.TASK_REPEAT.F.WEEKDAY); + + fixture.componentInstance.save(); + + const changes = + mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent().args[1]; + expect( + Object.prototype.hasOwnProperty.call(changes, 'monthlyWeekOfMonth'), + ).toBeTrue(); + expect(changes.monthlyWeekOfMonth).toBeUndefined(); + }); + describe('isLoading signal', () => { it('should be false when repeatCfg is provided directly (sync path)', async () => { const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index c122b24f67..518a72b85a 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -57,6 +57,7 @@ import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clo import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds'; import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { DateService } from '../../../core/date/date.service'; +import { MAT_SELECT_CONFIG } from '@angular/material/select'; // Fields whose change requires offering "Update all task instances?" — covers // what propagates to existing tasks (vs. schedule fields, which only affect @@ -88,6 +89,12 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [ templateUrl: './dialog-edit-task-repeat-cfg.component.html', styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: MAT_SELECT_CONFIG, + useValue: { canSelectNullableOptions: true }, + }, + ], imports: [ MatDialogTitle, TranslatePipe, diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index ca068b7bb7..5882098917 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -2,6 +2,7 @@ import { TASK_REPEAT_CFG_ADVANCED_FORM_CFG, TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG, } from './task-repeat-cfg-form.const'; +import { T } from '../../../t.const'; describe('TaskRepeatCfgFormConfig', () => { it('should not contain startDate in essential form fields', () => { @@ -23,6 +24,22 @@ describe('TaskRepeatCfgFormConfig', () => { expect(remindAtField).toBeUndefined(); }); + it('explains that Day of month uses the start date (#8886)', () => { + const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( + (field) => field.fieldGroupClassName === 'repeat-config-container', + ); + const monthlyAnchor = repeatContainer?.fieldGroup?.find( + (field) => field.fieldGroupClassName === 'monthly-anchor', + ); + const monthlyPattern = monthlyAnchor?.fieldGroup?.find( + (field) => field.key === 'monthlyWeekOfMonth', + ); + + expect(monthlyPattern?.templateOptions?.description).toBe( + T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION, + ); + }); + describe('weekdays group visibility (issue #8025)', () => { const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( (field) => field.fieldGroupClassName === 'repeat-config-container', diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index bce6326523..7adf22d56b 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -95,6 +95,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [ defaultValue: null, templateOptions: { label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH, + description: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION, options: [ { value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH }, { value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST }, diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 08e933e3b6..cdbe6cdb56 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -2054,6 +2054,8 @@ const T = { INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION', MONDAY: 'F.TASK_REPEAT.F.MONDAY', MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH', + MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION: + 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION', NOTES: 'F.TASK_REPEAT.F.NOTES', ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST', ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 239aa0c847..1d98bf3795 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1997,6 +1997,7 @@ "INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task", "MONDAY": "Monday", "MONTHLY_MODE_DAY_OF_MONTH": "Day of month", + "MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION": "\"Day of month\" uses the day shown in Start date.", "NOTES": "Default notes", "ORD_FIRST": "first", "ORD_FIRST_NTH": "first", @@ -2038,7 +2039,7 @@ "WAIT_FOR_COMPLETION": "Only create next task after completion", "WAIT_FOR_COMPLETION_DESCRIPTION": "Prevents multiple pending recurring tasks from piling up. The next instance will only be created once the current task is completed", "WEDNESDAY": "Wednesday", - "WEEK_OF_MONTH": "Week of month", + "WEEK_OF_MONTH": "Monthly pattern", "WEEKDAY": "Weekday", "WEEKDAY_REQUIRED": "Select at least one weekday" },