mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(task-repeat-cfg): re-anchor schedule when startDate moves earlier (#7423)
When editing a recurring task to move startDate earlier than the existing lastTaskCreationDay, the live instance was rescheduled to the day after the OLD startDate (getNextRepeatOccurrence advanced past the stale anchor) instead of landing on the NEW startDate. Re-anchor via getFirstRepeatOccurrence in that case. Skipped for repeatFromCompletionDate configs where startDate is decoupled from scheduling.
This commit is contained in:
parent
d3ac26dd25
commit
01e1776094
3 changed files with 419 additions and 4 deletions
|
|
@ -0,0 +1,129 @@
|
|||
import { Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
|
||||
/**
|
||||
* Bug: https://github.com/super-productivity/super-productivity/issues/7423
|
||||
*
|
||||
* When a recurring task already has lastTaskCreationDay set (because
|
||||
* updateTaskAfterMakingItRepeatable$ stamped it on initial save), and the
|
||||
* user later edits the repeat config to move startDate to an EARLIER date,
|
||||
* the live task instance was rescheduled to the day after the OLD
|
||||
* startDate (because getNextRepeatOccurrence advanced from the stale
|
||||
* lastTaskCreationDay) instead of landing on the NEW startDate.
|
||||
*
|
||||
* Repro from the issue (today fixed to 2026-05-01):
|
||||
* 1. Create task → make recurring → startDate = 2026-05-04 → save
|
||||
* 2. Reopen recur dialog → change startDate to 2026-05-02 → save
|
||||
* Expected post-fix: task lands on 2026-05-02.
|
||||
* Bug: task lands on 2026-05-05 (May 4 + 1 day).
|
||||
*
|
||||
* Dates kept close to today so they fit inside the planner's default
|
||||
* 15-day desktop window without horizontal scrolling.
|
||||
*/
|
||||
|
||||
const FIXED_TODAY = new Date('2026-05-01T10:00:00');
|
||||
|
||||
const openRecurDialog = async (page: Page): Promise<Locator> => {
|
||||
const recurItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon[svgIcon="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;
|
||||
};
|
||||
|
||||
// Set the Start date by typing into the matInput directly. The input parses
|
||||
// the locale's display format (en-GB → "DD/MM/YYYY") on blur/Enter. This is
|
||||
// far more robust than driving the calendar overlay across Material versions.
|
||||
const setStartDate = async (page: Page, ddmmyyyy: string): Promise<void> => {
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
const startDateInput = dialog
|
||||
.locator('mat-form-field')
|
||||
.filter({ hasText: /Start date/i })
|
||||
.locator('input')
|
||||
.first();
|
||||
await expect(startDateInput).toBeVisible({ timeout: 5000 });
|
||||
await startDateInput.fill('');
|
||||
await startDateInput.fill(ddmmyyyy);
|
||||
await startDateInput.press('Tab');
|
||||
await expect(startDateInput).toHaveValue(ddmmyyyy, { timeout: 3000 });
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
test.describe('Recurring Task - Move Start Date Earlier (#7423)', () => {
|
||||
test('changing startDate to an earlier date re-anchors the task on the new startDate', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
const taskTitle = `${testPrefix}-MoveStartEarlier7423`;
|
||||
|
||||
// Fix today to Friday, May 1, 2026 so calendar interactions are deterministic.
|
||||
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: startDate = May 4, 2026 (3 days from today; daily by default).
|
||||
await openRecurDialog(page);
|
||||
await setStartDate(page, '04/05/2026');
|
||||
await saveDialog(page);
|
||||
|
||||
// After the first save the task moves out of TODAY (dueDay = May 4) and the
|
||||
// detail panel re-renders. Reopen the panel via the Inbox project view,
|
||||
// which lists tasks regardless of due day. The leave/enter animations need
|
||||
// a moment to settle.
|
||||
await page.goto('/#/project/INBOX_PROJECT/tasks');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(800);
|
||||
const taskAfterFirstSave = page
|
||||
.locator('task')
|
||||
.filter({ hasText: taskTitle })
|
||||
.first();
|
||||
await expect(taskAfterFirstSave).toBeVisible({ timeout: 15000 });
|
||||
await taskPage.openTaskDetail(taskAfterFirstSave);
|
||||
|
||||
// 3. Second save: change startDate to May 2, 2026 (1 day from today,
|
||||
// still earlier than the previous value).
|
||||
await openRecurDialog(page);
|
||||
await setStartDate(page, '02/05/2026');
|
||||
await saveDialog(page);
|
||||
|
||||
// 4. Verify in planner: task lands on May 2 ("2/5"), NOT on May 5 ("5/5").
|
||||
// The planner header for each day uses the format DD/M (locale-aware).
|
||||
await page.goto('/#/planner');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The LIVE task instance lives in <planner-task>; subsequent days render
|
||||
// the same recurring task as <planner-repeat-projection> (faded preview).
|
||||
// The bug puts the live task on May 5; the fix puts it on May 2.
|
||||
const dayMay2 = page
|
||||
.locator('planner-day')
|
||||
.filter({ has: page.locator('.date', { hasText: /^2\/5$/ }) });
|
||||
await expect(
|
||||
dayMay2.locator('planner-task').filter({ hasText: taskTitle }),
|
||||
).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
const dayMay5 = page
|
||||
.locator('planner-day')
|
||||
.filter({ has: page.locator('.date', { hasText: /^5\/5$/ }) });
|
||||
await expect(
|
||||
dayMay5.locator('planner-task').filter({ hasText: taskTitle }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -2060,6 +2060,278 @@ describe('TaskRepeatCfgEffects - Repeatable Subtasks', () => {
|
|||
done();
|
||||
});
|
||||
});
|
||||
|
||||
const addDays = (base: Date, days: number): Date => {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
};
|
||||
|
||||
// Issue #7423: moving startDate earlier than the existing lastTaskCreationDay
|
||||
// must re-anchor on the new startDate. Previously, the stale lastTaskCreationDay
|
||||
// bumped checkDate inside getNextRepeatOccurrence, so the task landed one day
|
||||
// after the OLD startDate instead of on the NEW startDate.
|
||||
it('should plan task for the new startDate when moving startDate earlier than lastTaskCreationDay (#7423)', (done) => {
|
||||
const today = new Date();
|
||||
const oldStartDateStr = getDbDateStr(addDays(today, 29));
|
||||
const newStartDateStr = getDbDateStr(addDays(today, 9));
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: oldStartDateStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
// Config as it would look right after the user saves the new startDate:
|
||||
// startDate is the new value but lastTaskCreationDay still points at the
|
||||
// old startDate (set by updateTaskAfterMakingItRepeatable$ on initial save).
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: newStartDateStr,
|
||||
lastTaskCreationDay: oldStartDateStr,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { startDate: newStartDateStr },
|
||||
},
|
||||
});
|
||||
|
||||
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: newStartDateStr,
|
||||
}),
|
||||
);
|
||||
expect(taskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalledWith(
|
||||
'repeat-cfg-id',
|
||||
jasmine.objectContaining({
|
||||
lastTaskCreationDay: newStartDateStr,
|
||||
}),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #7423: same earlier-startDate scenario for WEEKLY repeat.
|
||||
it('should plan task for the new startDate for WEEKLY when moved earlier (#7423)', (done) => {
|
||||
const today = new Date();
|
||||
const todayDayOfWeek = today.getDay();
|
||||
|
||||
// Both old and new startDate land on the same weekday, 28 days and 7 days out.
|
||||
const oldStartDateStr = getDbDateStr(addDays(today, 28));
|
||||
const newStartDateStr = getDbDateStr(addDays(today, 7));
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: oldStartDateStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const weekdayKeys = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
const todayWeekdayKey = weekdayKeys[todayDayOfWeek];
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: newStartDateStr,
|
||||
lastTaskCreationDay: oldStartDateStr,
|
||||
sunday: todayWeekdayKey === 'sunday',
|
||||
monday: todayWeekdayKey === 'monday',
|
||||
tuesday: todayWeekdayKey === 'tuesday',
|
||||
wednesday: todayWeekdayKey === 'wednesday',
|
||||
thursday: todayWeekdayKey === 'thursday',
|
||||
friday: todayWeekdayKey === 'friday',
|
||||
saturday: todayWeekdayKey === 'saturday',
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { startDate: newStartDateStr },
|
||||
},
|
||||
});
|
||||
|
||||
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: newStartDateStr,
|
||||
}),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #7423: same earlier-startDate scenario for MONTHLY repeat.
|
||||
it('should plan task for the new startDate for MONTHLY when moved earlier (#7423)', (done) => {
|
||||
const today = new Date();
|
||||
const oldStartDateStr = getDbDateStr(addDays(today, 29));
|
||||
const newStartDateStr = getDbDateStr(addDays(today, 9));
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: oldStartDateStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: newStartDateStr,
|
||||
lastTaskCreationDay: oldStartDateStr,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { startDate: newStartDateStr },
|
||||
},
|
||||
});
|
||||
|
||||
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: newStartDateStr,
|
||||
}),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #7423 guard: for repeatFromCompletionDate=true, startDate is
|
||||
// decoupled from scheduling (getEffectiveRepeatStartDate returns
|
||||
// lastTaskCreationDay). Editing startDate must NOT take the re-anchor
|
||||
// branch — doing so would wipe completion history and re-schedule from
|
||||
// an arbitrary past anchor.
|
||||
it('should NOT re-anchor on startDate edit when repeatFromCompletionDate is true (#7423)', (done) => {
|
||||
const today = new Date();
|
||||
|
||||
// lastTaskCreationDay (April 15) > new startDate (Feb 1) — the same
|
||||
// shape as the bug, but on a repeatFromCompletionDate config.
|
||||
const lastCompletionStr = getDbDateStr(addDays(today, -16));
|
||||
const newStartDateStr = getDbDateStr(addDays(today, -89));
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: lastCompletionStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: newStartDateStr,
|
||||
lastTaskCreationDay: lastCompletionStr,
|
||||
repeatFromCompletionDate: true,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { startDate: newStartDateStr },
|
||||
},
|
||||
});
|
||||
|
||||
actions$ = of(action);
|
||||
taskRepeatCfgService.getTaskRepeatCfgById$.and.returnValue(of(updatedCfg));
|
||||
taskService.getTasksByRepeatCfgId$.and.returnValue(of([liveTask]));
|
||||
|
||||
// The re-anchor branch (which would call getFirstRepeatOccurrence and
|
||||
// schedule for newStartDateStr) must NOT fire. Capture the planned day
|
||||
// and assert it never equals the past startDate.
|
||||
let emittedDay: string | undefined;
|
||||
effects.rescheduleTaskOnRepeatCfgUpdate$.subscribe((result) => {
|
||||
if (result.type === PlannerActions.planTaskForDay.type) {
|
||||
emittedDay = result.day;
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
expect(emittedDay).not.toBe(newStartDateStr);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Issue #7423: edge case where the new startDate lies in the past relative
|
||||
// to today. The re-anchor branch should still fire (newStartDate <
|
||||
// lastTaskCreationDay holds), and the task is planned for that past date —
|
||||
// mirroring how updateTaskAfterMakingItRepeatable$ schedules past first
|
||||
// occurrences.
|
||||
it('should plan task for the new startDate even when it is in the past (#7423)', (done) => {
|
||||
const today = new Date();
|
||||
// lastTaskCreationDay 5 days out (set when old startDate was T+5).
|
||||
const oldStartDateStr = getDbDateStr(addDays(today, 5));
|
||||
// New startDate is 3 days in the past.
|
||||
const newStartDateStr = getDbDateStr(addDays(today, -3));
|
||||
|
||||
const liveTask: Task = {
|
||||
...mockTask,
|
||||
isDone: false,
|
||||
dueDay: oldStartDateStr,
|
||||
created: today.getTime(),
|
||||
};
|
||||
|
||||
const updatedCfg: TaskRepeatCfgCopy = {
|
||||
...mockRepeatCfg,
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: newStartDateStr,
|
||||
lastTaskCreationDay: oldStartDateStr,
|
||||
};
|
||||
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'repeat-cfg-id',
|
||||
changes: { startDate: newStartDateStr },
|
||||
},
|
||||
});
|
||||
|
||||
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: newStartDateStr,
|
||||
}),
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -267,10 +267,24 @@ export class TaskRepeatCfgEffects {
|
|||
a.created > b.created ? a : b,
|
||||
);
|
||||
|
||||
// Editing an existing recurring cfg: compute the next occurrence
|
||||
// from today, not from startDate. Using getFirstRepeatOccurrence
|
||||
// here would retro-anchor on the original startDate.
|
||||
const firstOccurrence = getNextRepeatOccurrence(fullCfg, new Date());
|
||||
// If the user moved startDate earlier than the existing
|
||||
// lastTaskCreationDay, the anchor is stale — re-anchor on
|
||||
// the new startDate (#7423). Skip for repeatFromCompletionDate
|
||||
// configs: there startDate is decoupled from scheduling
|
||||
// (getEffectiveRepeatStartDate uses lastTaskCreationDay), so
|
||||
// editing startDate must not clear completion history.
|
||||
const changes = taskRepeatCfg.changes as Partial<TaskRepeatCfgCopy>;
|
||||
const lastCreationDay = getEffectiveLastTaskCreationDay(fullCfg);
|
||||
const isStartDateMovedEarlier =
|
||||
'startDate' in changes &&
|
||||
!fullCfg.repeatFromCompletionDate &&
|
||||
!!fullCfg.startDate &&
|
||||
!!lastCreationDay &&
|
||||
fullCfg.startDate < lastCreationDay;
|
||||
|
||||
const firstOccurrence = isStartDateMovedEarlier
|
||||
? getFirstRepeatOccurrence(fullCfg)
|
||||
: getNextRepeatOccurrence(fullCfg, new Date());
|
||||
const firstOccurrenceStr = firstOccurrence
|
||||
? this._dateService.todayStr(firstOccurrence)
|
||||
: this._dateService.todayStr();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue