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 987d2a740e..13cacb2947 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 @@ -170,6 +170,7 @@ export class DialogEditTaskRepeatCfgComponent { : undefined; return { ...DEFAULT_TASK_REPEAT_CFG, + // Mirrored by getFirstOccurrenceAnchor's equality check (#7344). startDate: this._data.task.dueDay ?? getDbDateStr(this._data.task.dueWithTime || undefined), diff --git a/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.spec.ts index 2f8d60ed4f..fbf2e8412f 100644 --- a/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.spec.ts @@ -1,46 +1,116 @@ import { getFirstOccurrenceAnchor } from './get-first-occurrence-anchor.util'; +import { getDbDateStr } from '../../../util/get-db-date-str'; describe('getFirstOccurrenceAnchor', () => { - it('returns the task dueDay when it matches the config startDate (dialog default)', () => { - const result = getFirstOccurrenceAnchor( - { dueDay: '2026-04-01', dueWithTime: undefined }, - { startDate: '2026-04-01' }, - ); - expect(result.getFullYear()).toBe(2026); - expect(result.getMonth()).toBe(3); - expect(result.getDate()).toBe(1); + const addDays = (days: number): string => { + const d = new Date(); + d.setDate(d.getDate() + days); + return getDbDateStr(d); + }; + + describe('default preservation (#7344)', () => { + it('returns the task dueDay when it matches the config startDate (dialog default)', () => { + const result = getFirstOccurrenceAnchor( + { dueDay: '2026-04-01', dueWithTime: undefined }, + { startDate: '2026-04-01' }, + ); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(3); + expect(result.getDate()).toBe(1); + }); + + it('derives dueDay from dueWithTime when dueDay is missing and matches startDate', () => { + const dueWithTime = new Date(2026, 3, 1, 10, 30).getTime(); + const result = getFirstOccurrenceAnchor( + { dueDay: undefined, dueWithTime }, + { startDate: '2026-04-01' }, + ); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(3); + expect(result.getDate()).toBe(1); + }); }); - it('derives dueDay from dueWithTime when dueDay is missing and matches startDate', () => { - const dueWithTime = new Date(2026, 3, 1, 10, 30).getTime(); - const result = getFirstOccurrenceAnchor( - { dueDay: undefined, dueWithTime }, - { startDate: '2026-04-01' }, - ); - expect(result.getFullYear()).toBe(2026); - expect(result.getMonth()).toBe(3); - expect(result.getDate()).toBe(1); + describe('fallback to today', () => { + it('falls back to today when task has no planned date', () => { + const before = Date.now(); + const result = getFirstOccurrenceAnchor( + { dueDay: undefined, dueWithTime: undefined }, + { startDate: '2026-04-01' }, + ); + const after = Date.now(); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); + + it('falls back to today when task dueDay differs from startDate (user override)', () => { + const before = Date.now(); + const result = getFirstOccurrenceAnchor( + { dueDay: '2025-01-20', dueWithTime: undefined }, + { startDate: '2025-01-15' }, + ); + const after = Date.now(); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); }); - it('falls back to today when task has no planned date', () => { - const before = Date.now(); - const result = getFirstOccurrenceAnchor( - { dueDay: undefined, dueWithTime: undefined }, - { startDate: '2026-04-01' }, - ); - const after = Date.now(); - expect(result.getTime()).toBeGreaterThanOrEqual(before); - expect(result.getTime()).toBeLessThanOrEqual(after); - }); + describe('timed-task past-day clamp (#7354)', () => { + it('clamps to today when cfg is timed and planned day is in the past (derived from dueWithTime)', () => { + const pastDay = addDays(-20); + const before = Date.now(); + const result = getFirstOccurrenceAnchor( + { + dueDay: undefined, + dueWithTime: new Date(`${pastDay}T10:00:00`).getTime(), + }, + { startDate: pastDay, startTime: '10:00' }, + ); + const after = Date.now(); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); - it('falls back to today when task dueDay differs from startDate (user override)', () => { - const before = Date.now(); - const result = getFirstOccurrenceAnchor( - { dueDay: '2025-01-20', dueWithTime: undefined }, - { startDate: '2025-01-15' }, - ); - const after = Date.now(); - expect(result.getTime()).toBeGreaterThanOrEqual(before); - expect(result.getTime()).toBeLessThanOrEqual(after); + it('clamps to today when cfg is timed and planned day is in the past (dueDay only, startTime added in dialog)', () => { + const pastDay = addDays(-20); + const before = Date.now(); + const result = getFirstOccurrenceAnchor( + { dueDay: pastDay, dueWithTime: undefined }, + { startDate: pastDay, startTime: '10:00' }, + ); + const after = Date.now(); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); + + it('preserves planned day for timed cfg when planned day is today', () => { + const today = getDbDateStr(); + const result = getFirstOccurrenceAnchor( + { + dueDay: today, + dueWithTime: undefined, + }, + { startDate: today, startTime: '10:00' }, + ); + expect(getDbDateStr(result)).toBe(today); + }); + + it('preserves planned day for timed cfg when planned day is in the future', () => { + const future = addDays(30); + const result = getFirstOccurrenceAnchor( + { dueDay: future, dueWithTime: undefined }, + { startDate: future, startTime: '10:00' }, + ); + expect(getDbDateStr(result)).toBe(future); + }); + + it('preserves planned day for non-timed cfg even when planned day is in the past (#7344 regression guard)', () => { + const pastDay = addDays(-20); + const result = getFirstOccurrenceAnchor( + { dueDay: pastDay, dueWithTime: undefined }, + { startDate: pastDay }, + ); + expect(getDbDateStr(result)).toBe(pastDay); + }); }); }); diff --git a/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.ts b/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.ts index fffd815790..39d69db1c8 100644 --- a/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-first-occurrence-anchor.util.ts @@ -4,32 +4,35 @@ import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; import { getDbDateStr } from '../../../util/get-db-date-str'; /** - * Returns the "today" date that `getFirstRepeatOccurrence` should anchor on - * when an existing task is being converted into a recurring task. + * Anchor for `getFirstRepeatOccurrence` when converting an existing task to + * recurring. Returns the task's planned day if the user accepted the dialog + * default (equality with `cfg.startDate`, #7344), else today. * - * Contract: returns `dateStrToUtcDate(task.dueDay)` ONLY when the task's - * planned day equals `cfg.startDate` — i.e. the dialog's default is in - * effect. The dialog defaults `startDate` to `task.dueDay ?? getDbDateStr( - * task.dueWithTime)`, so equality is the signal that the user accepted the - * default and intends the existing planned date to become the first - * occurrence (#7344). If the user changed `startDate`, the anchor falls back - * to today. + * For timed cfgs with a past planned day we clamp to today: otherwise + * downstream `remindAt` would be in the past and the reminder module + * (`reminder.module.ts`) would fire a "missed reminder" popup immediately + * on save (#7354). Non-timed past days are safe to preserve. * - * NOTE: only used by the two "conversion" effects - * (`addRepeatCfgToTaskUpdateTask$`, `updateTaskAfterMakingItRepeatable$`). - * `rescheduleTaskOnRepeatCfgUpdate$` deliberately anchors on `new Date()` - * instead, because editing an already-existing recurring cfg should compute - * the next occurrence from today, not retro-anchor on the live task's - * planned date. + * IMPORTANT: the equality check mirrors the dialog's default in + * `dialog-edit-task-repeat-cfg.component.ts` (`task.dueDay ?? + * getDbDateStr(task.dueWithTime)`). If the dialog default changes, update + * this check too. + * + * Used only by the two conversion effects; `rescheduleTaskOnRepeatCfgUpdate$` + * intentionally uses `new Date()`. */ export const getFirstOccurrenceAnchor = ( task: Pick, - cfg: Pick, + cfg: Pick, ): Date => { const taskPlannedDayStr = task.dueDay ?? (task.dueWithTime ? getDbDateStr(task.dueWithTime) : undefined); - if (taskPlannedDayStr && taskPlannedDayStr === cfg.startDate) { - return dateStrToUtcDate(taskPlannedDayStr); + if (!taskPlannedDayStr || taskPlannedDayStr !== cfg.startDate) { + return new Date(); } - return new Date(); + // `yyyy-mm-dd` strings sort lexicographically like calendar dates. + if (cfg.startTime && taskPlannedDayStr < getDbDateStr()) { + return new Date(); + } + return dateStrToUtcDate(taskPlannedDayStr); };