mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
ba992d06e5
commit
72e3241b79
3 changed files with 129 additions and 55 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue