fix(task-repeat-cfg): avoid past remindAt when converting timed past-dated task to recurring (#7354)

Follow-up to #7344. When a timed task with a past dueWithTime was converted
to recurring, the preserved first-occurrence date produced a past remindAt
and the reminder module flagged it as overdue, firing a "missed reminder"
popup immediately on save.

Clamp the anchor to today when cfg.startTime is set and the planned day is
past. Non-timed past-day preservation (the #7344 fix) is untouched.
This commit is contained in:
Johannes Millan 2026-04-24 19:17:32 +02:00
parent ba992d06e5
commit 72e3241b79
3 changed files with 129 additions and 55 deletions

View file

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

View file

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

View file

@ -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<Task, 'dueDay' | 'dueWithTime'>,
cfg: Pick<TaskRepeatCfg, 'startDate'>,
cfg: Pick<TaskRepeatCfg, 'startDate' | 'startTime'>,
): 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);
};