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
This commit is contained in:
Johannes Millan 2026-07-10 13:55:23 +02:00 committed by GitHub
parent 762b3c5d8d
commit c68929d1fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 109 additions and 6 deletions

View file

@ -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). - 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). - Start date — when the pattern begins (required).
- For weekly cycle: check the weekdays (MondaySunday) on which the task should recur. - For weekly cycle: check the weekdays (MondaySunday) 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). 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). 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. 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.

View file

@ -7,7 +7,10 @@ import {
MatDialogRef, MatDialogRef,
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { DateAdapter, MatNativeDateModule } from '@angular/material/core'; 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 { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { Observable, of, Subject } from 'rxjs'; import { Observable, of, Subject } from 'rxjs';
@ -74,6 +77,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
getRepeatCfgReturnValue?: getRepeatCfgReturnValue?:
| Observable<TaskRepeatCfg | undefined> | Observable<TaskRepeatCfg | undefined>
| Subject<TaskRepeatCfg>, | Subject<TaskRepeatCfg>,
renderTemplate = false,
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => { ): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
@ -117,7 +121,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
formatTime: () => '12:00 PM', formatTime: () => '12:00 PM',
}); });
await TestBed.configureTestingModule({ const testModule = TestBed.configureTestingModule({
imports: [ imports: [
DialogEditTaskRepeatCfgComponent, DialogEditTaskRepeatCfgComponent,
MatDialogModule, MatDialogModule,
@ -140,16 +144,20 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
{ provide: DateService, useValue: mockDateService }, { provide: DateService, useValue: mockDateService },
{ provide: DateAdapter, useClass: CustomDateAdapter }, { provide: DateAdapter, useClass: CustomDateAdapter },
], ],
}) });
.overrideComponent(DialogEditTaskRepeatCfgComponent, {
if (!renderTemplate) {
testModule.overrideComponent(DialogEditTaskRepeatCfgComponent, {
set: { set: {
// Use a minimal template to avoid @ngx-formly/material select rendering, // Use a minimal template to avoid @ngx-formly/material select rendering,
// which triggers a compareWith validation error with Angular Material 21+. // which triggers a compareWith validation error with Angular Material 21+.
// These tests verify component signals/logic, not template rendering. // These tests verify component signals/logic, not template rendering.
template: '<div></div>', template: '<div></div>',
}, },
}) });
.compileComponents(); }
await testModule.compileComponents();
return TestBed.createComponent(DialogEditTaskRepeatCfgComponent); return TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
}; };
@ -158,6 +166,72 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
TestBed.resetTestingModule(); 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', () => { describe('isLoading signal', () => {
it('should be false when repeatCfg is provided directly (sync path)', async () => { it('should be false when repeatCfg is provided directly (sync path)', async () => {
const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg }); const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg });

View file

@ -57,6 +57,7 @@ import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clo
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds'; import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { DateService } from '../../../core/date/date.service'; 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 // Fields whose change requires offering "Update all task instances?" — covers
// what propagates to existing tasks (vs. schedule fields, which only affect // 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', templateUrl: './dialog-edit-task-repeat-cfg.component.html',
styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'], styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: MAT_SELECT_CONFIG,
useValue: { canSelectNullableOptions: true },
},
],
imports: [ imports: [
MatDialogTitle, MatDialogTitle,
TranslatePipe, TranslatePipe,

View file

@ -2,6 +2,7 @@ import {
TASK_REPEAT_CFG_ADVANCED_FORM_CFG, TASK_REPEAT_CFG_ADVANCED_FORM_CFG,
TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG, TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG,
} from './task-repeat-cfg-form.const'; } from './task-repeat-cfg-form.const';
import { T } from '../../../t.const';
describe('TaskRepeatCfgFormConfig', () => { describe('TaskRepeatCfgFormConfig', () => {
it('should not contain startDate in essential form fields', () => { it('should not contain startDate in essential form fields', () => {
@ -23,6 +24,22 @@ describe('TaskRepeatCfgFormConfig', () => {
expect(remindAtField).toBeUndefined(); 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)', () => { describe('weekdays group visibility (issue #8025)', () => {
const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find( const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
(field) => field.fieldGroupClassName === 'repeat-config-container', (field) => field.fieldGroupClassName === 'repeat-config-container',

View file

@ -95,6 +95,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
defaultValue: null, defaultValue: null,
templateOptions: { templateOptions: {
label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH, label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH,
description: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION,
options: [ options: [
{ value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH }, { value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH },
{ value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST }, { value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST },

View file

@ -2054,6 +2054,8 @@ const T = {
INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION', INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION',
MONDAY: 'F.TASK_REPEAT.F.MONDAY', 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: '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', NOTES: 'F.TASK_REPEAT.F.NOTES',
ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST', ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST',
ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH', ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH',

View file

@ -1997,6 +1997,7 @@
"INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task", "INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task",
"MONDAY": "Monday", "MONDAY": "Monday",
"MONTHLY_MODE_DAY_OF_MONTH": "Day of month", "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", "NOTES": "Default notes",
"ORD_FIRST": "first", "ORD_FIRST": "first",
"ORD_FIRST_NTH": "first", "ORD_FIRST_NTH": "first",
@ -2038,7 +2039,7 @@
"WAIT_FOR_COMPLETION": "Only create next task after completion", "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", "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", "WEDNESDAY": "Wednesday",
"WEEK_OF_MONTH": "Week of month", "WEEK_OF_MONTH": "Monthly pattern",
"WEEKDAY": "Weekday", "WEEKDAY": "Weekday",
"WEEKDAY_REQUIRED": "Select at least one weekday" "WEEKDAY_REQUIRED": "Select at least one weekday"
}, },