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:
Johannes Millan 2026-06-02 15:24:25 +02:00
parent 9fe602893c
commit 11cdeca683
5 changed files with 489 additions and 20 deletions

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

View file

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

View file

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

View file

@ -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: {

View file

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