diff --git a/e2e/tests/recurring/repeat-edit-strands-today-bug-7951.spec.ts b/e2e/tests/recurring/repeat-edit-strands-today-bug-7951.spec.ts new file mode 100644 index 0000000000..8444d80e7b --- /dev/null +++ b/e2e/tests/recurring/repeat-edit-strands-today-bug-7951.spec.ts @@ -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 is on 16/6, not 17/6. + * - Fail (pre-fix): the live 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 => { + 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 => { + 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 => { + 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 ; future occurrences render + // as faded . + 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); + }); +}); diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts index 928aad7330..0d742b597d 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts @@ -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 => ({ + 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); + }); + }); }); diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts index c5b48073eb..ec203902ba 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts @@ -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); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts index 9aca5e02ba..05111668ef 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.spec.ts @@ -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: { diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts index 9f6b057564..2800f0612d 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts @@ -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,