mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(task-repeat-cfg): don't strand today's instance on edit (#7951)
Editing a schedule-affecting field of a recurring task relocated the live instance via getNextRepeatOccurrence(), which is exclusive of today by contract. A still-valid daily/weekly instance was therefore pushed to tomorrow and lastTaskCreationDay advanced past today, leaving today empty and never recreated (the reported "task not created on the new day"). Add an opt-in `inclusive` mode to getNextRepeatOccurrence() that scans from today, with a floor so MONTHLY/YEARLY never resolve to an already passed anchor, and use it in rescheduleTaskOnRepeatCfgUpdate$. The default (exclusive) behaviour is unchanged for preview / scheduled-list / heatmap. For timed tasks (startTime + remindAt) the reschedule advances to the next future occurrence when today's start time has already passed, so relocating an instance never fires an immediate "missed reminder" (#7354). Tests: unit repro + edge cases (repeatFromCompletionDate, repeatEvery>1 off-cycle, overdue relocation, MONTHLY/YEARLY today+floor, past-time timed reschedule) and a fail-before/pass-after e2e.
This commit is contained in:
parent
9fe602893c
commit
11cdeca683
5 changed files with 489 additions and 20 deletions
109
e2e/tests/recurring/repeat-edit-strands-today-bug-7951.spec.ts
Normal file
109
e2e/tests/recurring/repeat-edit-strands-today-bug-7951.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
|
||||
/**
|
||||
* Bug: https://github.com/super-productivity/super-productivity/issues/7951
|
||||
*
|
||||
* Editing a schedule-affecting field of a recurring task whose CURRENT day is
|
||||
* still a valid occurrence moved the live instance to the NEXT occurrence
|
||||
* (tomorrow) and advanced lastTaskCreationDay past today. The day-change repeat
|
||||
* processor then permanently skips today (today's effectiveLastDay already
|
||||
* points at tomorrow), so today's task simply vanishes — matching the issue's
|
||||
* screenshot where the current day is empty while future days are populated.
|
||||
*
|
||||
* Repro (today fixed to Tue 2026-06-16):
|
||||
* 1. Create task → make recurring (Daily, startDate defaults to today) → save.
|
||||
* The live instance lands on today.
|
||||
* 2. Reopen the recur dialog → switch the quick setting Daily → Mon-Fri → save.
|
||||
* Tuesday is still a valid Mon-Fri occurrence, so the live instance MUST STAY on
|
||||
* today (16/6) — the bug moved it to Wednesday (17/6).
|
||||
*
|
||||
* - Pass (post-fix): the live <planner-task> is on 16/6, not 17/6.
|
||||
* - Fail (pre-fix): the live <planner-task> is on 17/6 and 16/6 is empty.
|
||||
*
|
||||
* Dates kept close to today so they fit the planner's default desktop window.
|
||||
*/
|
||||
|
||||
const FIXED_TODAY = new Date('2026-06-16T09:00:00'); // Tuesday
|
||||
|
||||
const openRecurDialog = async (page: Page): Promise<Locator> => {
|
||||
const recurItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) });
|
||||
await expect(recurItem).toBeVisible({ timeout: 5000 });
|
||||
await recurItem.click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const saveDialog = async (page: Page): Promise<void> => {
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
const saveBtn = dialog.getByRole('button', { name: /Save/i });
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
|
||||
await saveBtn.click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
};
|
||||
|
||||
// Switch the "Recurring Config" quick-setting select. The option labels come
|
||||
// from en.json (Q_MONDAY_TO_FRIDAY = "Every Monday through Friday").
|
||||
const setQuickSetting = async (page: Page, optionLabel: RegExp): Promise<void> => {
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await dialog.locator('mat-select').first().click();
|
||||
const option = page.locator('mat-option').filter({ hasText: optionLabel });
|
||||
await expect(option).toBeVisible({ timeout: 5000 });
|
||||
await option.click();
|
||||
};
|
||||
|
||||
test.describe('Recurring Task - Edit must not strand today (#7951)', () => {
|
||||
test('switching Daily -> Mon-Fri keeps the live instance on today', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
const taskTitle = `${testPrefix}-EditStrands7951`;
|
||||
|
||||
// Fix today to Tuesday, June 16 2026 so both Daily and Mon-Fri include today.
|
||||
await page.clock.setFixedTime(FIXED_TODAY);
|
||||
await page.reload();
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
// 1. Create the task and open its detail panel.
|
||||
await workViewPage.addTask(taskTitle);
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
await taskPage.openTaskDetail(task);
|
||||
|
||||
// 2. First save: default Daily. The live instance stays on today.
|
||||
await openRecurDialog(page);
|
||||
await saveDialog(page);
|
||||
|
||||
// The detail panel stays open after saving the repeat config. Reopen the
|
||||
// recur dialog and switch the quick setting to Mon-Fri (a schedule-affecting
|
||||
// change that still includes today, a Tuesday).
|
||||
await openRecurDialog(page);
|
||||
await setQuickSetting(page, /Every Monday through Friday/i);
|
||||
await saveDialog(page);
|
||||
|
||||
// 3. Verify in planner: the live task is on 16/6 (today), NOT 17/6 (tomorrow).
|
||||
// The live instance renders as <planner-task>; future occurrences render
|
||||
// as faded <planner-repeat-projection>.
|
||||
await page.goto('/#/planner');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const dayToday = page
|
||||
.locator('planner-day')
|
||||
.filter({ has: page.locator('.date', { hasText: /^16\/6$/ }) });
|
||||
await expect(
|
||||
dayToday.locator('planner-task').filter({ hasText: taskTitle }),
|
||||
).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
const dayTomorrow = page
|
||||
.locator('planner-day')
|
||||
.filter({ has: page.locator('.date', { hasText: /^17\/6$/ }) });
|
||||
await expect(
|
||||
dayTomorrow.locator('planner-task').filter({ hasText: taskTitle }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -927,4 +927,161 @@ describe('getNextRepeatOccurrence()', () => {
|
|||
expect(result!.getTime()).toBeGreaterThanOrEqual(today.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #7951: the inclusive variant is used when relocating an existing live
|
||||
// instance on a schedule edit — `fromDate` (today) must be considered instead
|
||||
// of being skipped to the day after the last creation.
|
||||
describe('inclusive option (#7951)', () => {
|
||||
const noon = (d: Date): Date => {
|
||||
const r = new Date(d);
|
||||
r.setHours(12, 0, 0, 0);
|
||||
return r;
|
||||
};
|
||||
|
||||
// Enable exactly one weekday (0 = Sunday … 6 = Saturday).
|
||||
const weekdayFlags = (enabledDay: number): Partial<TaskRepeatCfg> => ({
|
||||
sunday: enabledDay === 0,
|
||||
monday: enabledDay === 1,
|
||||
tuesday: enabledDay === 2,
|
||||
wednesday: enabledDay === 3,
|
||||
thursday: enabledDay === 4,
|
||||
friday: enabledDay === 5,
|
||||
saturday: enabledDay === 6,
|
||||
});
|
||||
|
||||
it('returns today for a DAILY repeat created today (vs tomorrow when exclusive)', () => {
|
||||
const today = noon(new Date());
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
const tomorrow = noon(new Date(today.getTime() + DAY));
|
||||
expect(getNextRepeatOccurrence(cfg, today)).toEqual(tomorrow);
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
|
||||
it('returns today for a WEEKLY repeat when today matches the enabled weekday', () => {
|
||||
const today = noon(new Date());
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
...weekdayFlags(today.getDay()),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
|
||||
it('still skips today when today is not a valid occurrence', () => {
|
||||
const today = noon(new Date());
|
||||
const tomorrow = noon(new Date(today.getTime() + DAY));
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
// only tomorrow's weekday is enabled
|
||||
...weekdayFlags(tomorrow.getDay()),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(tomorrow);
|
||||
});
|
||||
|
||||
it('returns today for a MONTHLY repeat whose day-of-month is today', () => {
|
||||
const today = noon(new Date());
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
|
||||
it('does not resolve to a past MONTHLY occurrence when this month already passed', () => {
|
||||
// fromDate is the 16th; the monthly anchor (the 10th) already passed this
|
||||
// month, so the soonest inclusive occurrence is next month's 10th — never
|
||||
// the past June 10th. Exercises the floor guard, not just the
|
||||
// epoch-neutralised gating.
|
||||
const fromDate = noon(new Date(2026, 5, 16)); // Tue Jun 16 2026
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2026-01-10',
|
||||
lastTaskCreationDay: '2026-06-10',
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, fromDate, { inclusive: true })).toEqual(
|
||||
noon(new Date(2026, 6, 10)), // Jul 10 2026
|
||||
);
|
||||
});
|
||||
|
||||
it('returns today for a YEARLY repeat whose month and day are today', () => {
|
||||
const today = noon(new Date());
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
|
||||
it('does not resolve to a past YEARLY occurrence when this year already passed', () => {
|
||||
// Yearly anchor is March 10; fromDate is mid-June, so this year's
|
||||
// occurrence already passed → next year's March 10, never the past one.
|
||||
const fromDate = noon(new Date(2026, 5, 16)); // Tue Jun 16 2026
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2020-03-10',
|
||||
lastTaskCreationDay: '2026-03-10',
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, fromDate, { inclusive: true })).toEqual(
|
||||
noon(new Date(2027, 2, 10)), // Mar 10 2027
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the repeatFromCompletionDate anchor (lastTaskCreationDay), not startDate', () => {
|
||||
// repeatFromCompletionDate cfgs anchor the pattern on lastTaskCreationDay
|
||||
// (via getEffectiveRepeatStartDate). The inclusive path must preserve that
|
||||
// anchor — it only neutralises the prior-creation *gating*, computed after
|
||||
// the start anchor is resolved. With lastTaskCreationDay = today the
|
||||
// soonest inclusive daily occurrence is today (exclusive would be tomorrow).
|
||||
const today = noon(new Date());
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
repeatFromCompletionDate: true,
|
||||
// startDate is intentionally far in the past; for repeatFromCompletionDate
|
||||
// the pattern anchors on lastTaskCreationDay, not startDate.
|
||||
startDate: '2020-01-01',
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
|
||||
it('respects repeatEvery when today is off-cycle (returns the next on-cycle day)', () => {
|
||||
// Every-2-days anchored on yesterday → today is off-cycle (diff 1, odd),
|
||||
// so inclusive must NOT force today; the next on-cycle day is tomorrow.
|
||||
const today = noon(new Date());
|
||||
const tomorrow = noon(new Date(today.getTime() + DAY));
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 2,
|
||||
startDate: getDbDateStr(new Date(today.getTime() - DAY)),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(tomorrow);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ import { Log } from '../../../core/log';
|
|||
export const getNextRepeatOccurrence = (
|
||||
taskRepeatCfg: TaskRepeatCfg,
|
||||
fromDate: Date = new Date(),
|
||||
// When `inclusive` is true the scan starts from `fromDate` itself instead of
|
||||
// the day after the last task creation. Used when relocating an existing live
|
||||
// instance on a schedule edit: `fromDate` (today) may still be a valid
|
||||
// occurrence and must not be skipped (#7951). The default (exclusive) keeps
|
||||
// the "strictly next future occurrence" semantics relied on by the preview,
|
||||
// scheduled-list and heatmap.
|
||||
{ inclusive = false }: { inclusive?: boolean } = {},
|
||||
): Date | null => {
|
||||
if (!Number.isInteger(taskRepeatCfg.repeatEvery) || taskRepeatCfg.repeatEvery < 1) {
|
||||
Log.warn(
|
||||
|
|
@ -37,11 +44,28 @@ export const getNextRepeatOccurrence = (
|
|||
lastTaskCreation.setHours(12, 0, 0, 0);
|
||||
startDateDate.setHours(12, 0, 0, 0);
|
||||
|
||||
// Start checking from the day after last task creation
|
||||
if (lastTaskCreation >= checkDate) {
|
||||
checkDate.setTime(lastTaskCreation.getTime());
|
||||
// In inclusive mode, never resolve to an occurrence before `fromDate` itself.
|
||||
// DAILY/WEEKLY only scan forward from `fromDate`, but the day-of-month
|
||||
// MONTHLY and YEARLY branches jump to this period's anchor day — which may
|
||||
// already have passed today — so they need an explicit floor (applied in
|
||||
// their loops below). The MONTHLY nth-weekday branch enforces the same floor
|
||||
// via its own `candidate >= checkDate` predicate (checkDate === fromDate in
|
||||
// inclusive mode), so it does not use `fromDateFloor`.
|
||||
const fromDateFloor = inclusive ? new Date(checkDate) : null;
|
||||
|
||||
if (inclusive) {
|
||||
// Relocating an existing instance: ignore prior-creation gating entirely so
|
||||
// the scan starts at `fromDate` and today is considered. Neutralising
|
||||
// `lastTaskCreation` (epoch) also disarms the per-cycle "skip past last
|
||||
// creation" guards (MONTHLY/YEARLY below) for a uniform inclusive scan.
|
||||
lastTaskCreation.setTime(0);
|
||||
} else {
|
||||
// Start checking from the day after last task creation
|
||||
if (lastTaskCreation >= checkDate) {
|
||||
checkDate.setTime(lastTaskCreation.getTime());
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
|
||||
switch (taskRepeatCfg.repeatCycle) {
|
||||
case 'DAILY': {
|
||||
|
|
@ -128,7 +152,11 @@ export const getNextRepeatOccurrence = (
|
|||
for (let i = 0; i < maxMonthsToCheck; i++) {
|
||||
const diffInMonth = getDiffInMonth(startDateDate, checkDate);
|
||||
|
||||
if (diffInMonth >= 0 && diffInMonth % taskRepeatCfg.repeatEvery === 0) {
|
||||
if (
|
||||
diffInMonth >= 0 &&
|
||||
diffInMonth % taskRepeatCfg.repeatEvery === 0 &&
|
||||
(!fromDateFloor || checkDate >= fromDateFloor)
|
||||
) {
|
||||
return checkDate;
|
||||
}
|
||||
checkDate.setMonth(checkDate.getMonth() + 1);
|
||||
|
|
@ -172,7 +200,11 @@ export const getNextRepeatOccurrence = (
|
|||
for (let i = 0; i < maxYearsToCheck; i++) {
|
||||
const diffInYears = getDiffInYears(startDateDate, checkDate);
|
||||
|
||||
if (diffInYears >= 0 && diffInYears % taskRepeatCfg.repeatEvery === 0) {
|
||||
if (
|
||||
diffInYears >= 0 &&
|
||||
diffInYears % taskRepeatCfg.repeatEvery === 0 &&
|
||||
(!fromDateFloor || checkDate >= fromDateFloor)
|
||||
) {
|
||||
return checkDate;
|
||||
}
|
||||
checkDate.setFullYear(checkDate.getFullYear() + 1);
|
||||
|
|
|
|||
|
|
@ -2118,6 +2118,102 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Issue #7951: editing a schedule-affecting field of a DAILY repeat whose
|
||||
// current day is still a valid occurrence must NOT strand today's live
|
||||
// instance by moving it to tomorrow. Before the fix the effect used the
|
||||
// strictly-future getNextRepeatOccurrence(), so today's task disappeared
|
||||
// from Today and lastTaskCreationDay jumped past today — permanently
|
||||
// skipping today's instance.
|
||||
it('should keep the live instance on today when a DAILY repeat still includes today (#7951)', (done) => {
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: todayStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: todayStr,
|
||||
lastTaskCreationDay: todayStr,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { repeatEvery: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(of(updatedCfg));
|
||||
taskService.getTasksByRepeatCfgId$.and.returnValue(of([liveTask]));
|
||||
|
||||
effects.rescheduleTaskOnRepeatCfgUpdate$.subscribe((result) => {
|
||||
expect(result).toEqual(
|
||||
PlannerActions.planTaskForDay({ task: liveTask as any, day: todayStr }),
|
||||
);
|
||||
expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith(
|
||||
'repeat-cfg-id',
|
||||
jasmine.objectContaining({ lastTaskCreationDay: todayStr }),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// #7951 follow-up: an OVERDUE undone instance (dueDay in the past) must also
|
||||
// be relocated to today on a schedule edit, not pushed to tomorrow (which
|
||||
// would skip both its overdue day and today).
|
||||
it('relocates an overdue live instance to today for a DAILY repeat (#7951)', (done) => {
|
||||
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000;
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
const threeDaysAgo = today.getTime() - THREE_DAYS;
|
||||
const threeDaysAgoStr = getDbDateStr(new Date(threeDaysAgo));
|
||||
|
||||
const overdueTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: threeDaysAgoStr,
|
||||
created: threeDaysAgo,
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: threeDaysAgoStr,
|
||||
lastTaskCreationDay: threeDaysAgoStr,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { repeatEvery: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(of(updatedCfg));
|
||||
taskService.getTasksByRepeatCfgId$.and.returnValue(of([overdueTask]));
|
||||
|
||||
effects.rescheduleTaskOnRepeatCfgUpdate$.subscribe((result) => {
|
||||
expect(result).toEqual(
|
||||
PlannerActions.planTaskForDay({ task: overdueTask as any, day: todayStr }),
|
||||
);
|
||||
expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith(
|
||||
'repeat-cfg-id',
|
||||
jasmine.objectContaining({ lastTaskCreationDay: todayStr }),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch scheduleTaskWithTime when schedule-affecting field changes and task is timed', (done) => {
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
|
@ -2155,6 +2251,56 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #7951 review (W1): a timed task whose start time has already passed today
|
||||
// must NOT be scheduled at that past time — that would set a past remindAt
|
||||
// and fire an immediate "missed reminder" on save (#7354). It advances to
|
||||
// the next future slot instead.
|
||||
it('does not schedule a past remindAt for a timed task whose start time passed today (#7951)', (done) => {
|
||||
const now = Date.now();
|
||||
const today = new Date();
|
||||
const todayStr = getDbDateStr(today);
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: todayStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: todayStr,
|
||||
lastTaskCreationDay: todayStr,
|
||||
// start of today — always in the past relative to "now"
|
||||
startTime: '00:00',
|
||||
remindAt: TaskReminderOptionId.AtStart,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { repeatEvery: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(of(updatedCfg));
|
||||
taskService.getTasksByRepeatCfgId$.and.returnValue(of([liveTask]));
|
||||
|
||||
effects.rescheduleTaskOnRepeatCfgUpdate$.subscribe((result) => {
|
||||
const scheduled = result as ReturnType<
|
||||
typeof TaskSharedActions.scheduleTaskWithTime
|
||||
>;
|
||||
expect(scheduled.type).toBe(TaskSharedActions.scheduleTaskWithTime.type);
|
||||
// The scheduled time and reminder must be in the future, not this morning.
|
||||
expect(scheduled.dueWithTime).toBeGreaterThan(now);
|
||||
expect(scheduled.remindAt).toBeGreaterThanOrEqual(now);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT dispatch when only non-schedule fields change', (done) => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
|
|
|
|||
|
|
@ -293,7 +293,12 @@ export class TaskRepeatCfgEffects {
|
|||
|
||||
const firstOccurrence = isStartDateMovedEarlier
|
||||
? getFirstRepeatOccurrence(fullCfg)
|
||||
: getNextRepeatOccurrence(fullCfg, new Date());
|
||||
: // inclusive: relocate the live instance to the soonest
|
||||
// occurrence on or after today. The strictly-future variant
|
||||
// always skipped today, stranding a still-valid daily
|
||||
// instance on tomorrow and advancing lastTaskCreationDay past
|
||||
// today (#7951).
|
||||
getNextRepeatOccurrence(fullCfg, new Date(), { inclusive: true });
|
||||
|
||||
if (undoneInstances.length === 0) {
|
||||
// No live instance to reschedule. But when startDate moved
|
||||
|
|
@ -333,25 +338,45 @@ export class TaskRepeatCfgEffects {
|
|||
a.created > b.created ? a : b,
|
||||
);
|
||||
|
||||
const firstOccurrenceStr = firstOccurrence
|
||||
? this._dateService.todayStr(firstOccurrence)
|
||||
: this._dateService.todayStr();
|
||||
|
||||
// Update lastTaskCreationDay on the config
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(cfgId, {
|
||||
lastTaskCreationDay: firstOccurrenceStr,
|
||||
lastTaskCreation: firstOccurrence?.getTime() || Date.now(),
|
||||
});
|
||||
|
||||
const isTimedTask = !!(
|
||||
fullCfg.startTime &&
|
||||
fullCfg.remindAt &&
|
||||
isValidSplitTime(fullCfg.startTime)
|
||||
);
|
||||
|
||||
// For a timed task, the inclusive occurrence can land on today
|
||||
// even when today's start time has ALREADY passed. Scheduling
|
||||
// that slot would set a past remindAt and fire an immediate
|
||||
// "missed reminder" on save (#7354/#7951 review). Advance to the
|
||||
// next future occurrence in that case; a start time still
|
||||
// upcoming today is kept on today.
|
||||
let targetOccurrence = firstOccurrence;
|
||||
if (isTimedTask && targetOccurrence) {
|
||||
const slot = getDateTimeFromClockString(
|
||||
fullCfg.startTime as string,
|
||||
targetOccurrence.getTime(),
|
||||
);
|
||||
if (slot < Date.now()) {
|
||||
const nextOccurrence = getNextRepeatOccurrence(fullCfg, new Date());
|
||||
if (nextOccurrence) {
|
||||
targetOccurrence = nextOccurrence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstOccurrenceStr = targetOccurrence
|
||||
? this._dateService.todayStr(targetOccurrence)
|
||||
: this._dateService.todayStr();
|
||||
|
||||
// Update lastTaskCreationDay on the config
|
||||
this._taskRepeatCfgService.updateTaskRepeatCfg(cfgId, {
|
||||
lastTaskCreationDay: firstOccurrenceStr,
|
||||
lastTaskCreation: targetOccurrence?.getTime() || Date.now(),
|
||||
});
|
||||
|
||||
if (isTimedTask) {
|
||||
const targetDayTimestamp = firstOccurrence
|
||||
? firstOccurrence.getTime()
|
||||
const targetDayTimestamp = targetOccurrence
|
||||
? targetOccurrence.getTime()
|
||||
: Date.now();
|
||||
const dateTime = getDateTimeFromClockString(
|
||||
fullCfg.startTime as string,
|
||||
|
|
@ -378,7 +403,7 @@ export class TaskRepeatCfgEffects {
|
|||
// and adds it to TODAY_TAG ordering via the planner meta
|
||||
// reducer. Skipping today here left the instance stranded on
|
||||
// its old dueDay (#7768 Bug 1).
|
||||
if (firstOccurrence) {
|
||||
if (targetOccurrence) {
|
||||
return rxOf(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: task as TaskCopy,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue