From e4aab5b9c80806b39006637407278c4ebe79e2b5 Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:27:26 +0100 Subject: [PATCH] advance recurring occurrences moved before their original date A moved occurrence replaces its original recurrence slot, but next-occurrence planning ignored that stored exclusion, so completing or skipping the moved occurrence could schedule the replaced original date again. Treat pending and resolved moved-original dates as processed while calculating the next open occurrence only. Persisted completion and skip history stays keyed to the moved date. --- docs/releases/unreleased.md | 3 + .../task-service/taskRecurringPlanning.ts | 57 ++++++- .../services/taskRecurringPlanning.test.ts | 161 ++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index cc1bd1185..73bc8a1c9 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -35,3 +35,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed - Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. +- (#2191) Completing or skipping a recurring occurrence that was moved earlier + than its original date no longer schedules that original date again. Thanks to + @martin-forge for the contribution. diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index 8ea1d38b3..bff215b1e 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -149,6 +149,49 @@ function moveScheduledDateToOccurrence( return `${occurrenceDateStr}${scheduled.slice(scheduledDatePart.length)}`; } +function withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + task: TaskInfo, + originalTask: TaskInfo, + actionDate: string +): TaskInfo { + if ((originalTask.recurrence_anchor || "scheduled") !== "scheduled") { + return task; + } + + const replacedDates = new Set(); + for (const date of originalTask.googleCalendarMovedOriginalDates || []) { + const normalized = getDatePart(date); + if (normalized) { + replacedDates.add(normalized); + } + } + + const pendingOriginal = getDatePart( + originalTask.googleCalendarExceptionOriginalScheduled || "" + ); + const movedDate = getDatePart(originalTask.scheduled || ""); + if (pendingOriginal && pendingOriginal !== actionDate && movedDate === actionDate) { + replacedDates.add(pendingOriginal); + } + + // Moved occurrences replace their original series dates. Mark those dates as + // processed only for this calculation; persisted state stays keyed to moved dates. + const completeInstances = new Set(getStringArray(task.complete_instances)); + const skippedInstances = getStringArray(task.skipped_instances); + const skippedInstanceSet = new Set(skippedInstances); + const additionalSkippedInstances = Array.from(replacedDates) + .filter((date) => !completeInstances.has(date) && !skippedInstanceSet.has(date)) + .sort(); + if (additionalSkippedInstances.length === 0) { + return task; + } + + return { + ...task, + skipped_instances: [...skippedInstances, ...additionalSkippedInstances], + }; +} + export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date { if (date) { return date; @@ -225,7 +268,7 @@ export function buildRecurringTaskCompletePlan({ // due-date offset calculation and next-occurrence search. // Pass max(today, instanceDate) as the floor so future-dated tasks advance past // the current cycle rather than jumping back to today's nearest occurrence. - const taskForNextOccurrence = owningRecurrenceDate + const taskForNextOccurrenceBase = owningRecurrenceDate ? { ...updatedTask, scheduled: moveScheduledDateToOccurrence( @@ -234,6 +277,11 @@ export function buildRecurringTaskCompletePlan({ ), } : updatedTask; + const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + taskForNextOccurrenceBase, + freshTask, + dateStr + ); const todayStr = getTodayString(); const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( @@ -353,7 +401,7 @@ export function buildRecurringTaskSkippedPlan({ updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr); } - const taskForNextOccurrence = owningRecurrenceDate + const taskForNextOccurrenceBase = owningRecurrenceDate ? { ...updatedTask, scheduled: moveScheduledDateToOccurrence( @@ -362,6 +410,11 @@ export function buildRecurringTaskSkippedPlan({ ), } : updatedTask; + const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence( + taskForNextOccurrenceBase, + freshTask, + dateStr + ); const todayStr = getTodayString(); const floorDate = dateStr > todayStr ? dateStr : todayStr; const nextDates = updateToNextScheduledOccurrence( diff --git a/tests/unit/services/taskRecurringPlanning.test.ts b/tests/unit/services/taskRecurringPlanning.test.ts index bb70cb41d..bfd607a73 100644 --- a/tests/unit/services/taskRecurringPlanning.test.ts +++ b/tests/unit/services/taskRecurringPlanning.test.ts @@ -30,6 +30,15 @@ function createRecurringTask(overrides: Partial = {}): TaskInfo { } as TaskInfo; } +function createMovedWeeklyTask(overrides: Partial = {}): TaskInfo { + return createRecurringTask({ + recurrence: "DTSTART:20260406;FREQ=WEEKLY;BYDAY=MO", + scheduled: "2026-04-10", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + ...overrides, + }); +} + describe("taskRecurringPlanning", () => { beforeEach(() => { mockGetTodayString.mockReturnValue("2026-05-19"); @@ -193,4 +202,156 @@ describe("taskRecurringPlanning", () => { expect(frontmatter.scheduled).toBe("2026-05-20"); expect(frontmatter.dateModified).toBe("2026-05-19T07:15:00+10:00"); }); + + describe("Google Calendar occurrences moved before their series date", () => { + beforeEach(() => { + mockGetTodayString.mockReturnValue("2026-04-10"); + }); + + it("completes the moved date and advances past the replaced series date", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createMovedWeeklyTask(), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.complete_instances).toEqual(["2026-04-10"]); + expect(plan.updatedTask.skipped_instances).toEqual([]); + expect(plan.updatedTask.scheduled).toBe("2026-04-20"); + expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); + + it("skips the moved date and advances past the replaced series date", () => { + const plan = buildRecurringTaskSkippedPlan({ + freshTask: createMovedWeeklyTask(), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.skipped_instances).toEqual(["2026-04-10"]); + expect(plan.updatedTask.complete_instances).toEqual([]); + expect(plan.updatedTask.scheduled).toBe("2026-04-20"); + expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); + + it("keeps the replaced date excluded after intervening occurrences", () => { + const movedPlan = buildRecurringTaskCompletePlan({ + freshTask: createMovedWeeklyTask({ + recurrence: "DTSTART:20260409;FREQ=DAILY", + }), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + const interveningPlan = buildRecurringTaskCompletePlan({ + freshTask: movedPlan.updatedTask, + targetDate: new Date("2026-04-11T12:00:00.000Z"), + currentTimestamp: "2026-04-11T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + const beforeReplacedDatePlan = buildRecurringTaskCompletePlan({ + freshTask: interveningPlan.updatedTask, + targetDate: new Date("2026-04-12T12:00:00.000Z"), + currentTimestamp: "2026-04-12T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(movedPlan.updatedTask.scheduled).toBe("2026-04-11"); + expect(interveningPlan.updatedTask.scheduled).toBe("2026-04-12"); + expect(beforeReplacedDatePlan.updatedTask.scheduled).toBe("2026-04-14"); + expect(beforeReplacedDatePlan.updatedTask.complete_instances).toEqual([ + "2026-04-10", + "2026-04-11", + "2026-04-12", + ]); + expect(beforeReplacedDatePlan.updatedTask.skipped_instances).toEqual([]); + expect(beforeReplacedDatePlan.updatedTask.googleCalendarMovedOriginalDates).toEqual([ + "2026-04-13", + ]); + }); + + it("does not persist the replaced series date when uncompleting or unskipping", () => { + const completePlan = buildRecurringTaskCompletePlan({ + freshTask: createMovedWeeklyTask({ complete_instances: ["2026-04-10"] }), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + const skipPlan = buildRecurringTaskSkippedPlan({ + freshTask: createMovedWeeklyTask({ skipped_instances: ["2026-04-10"] }), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(completePlan.newComplete).toBe(false); + expect(completePlan.updatedTask.complete_instances).toEqual([]); + expect(completePlan.updatedTask.skipped_instances).toEqual([]); + expect(skipPlan.newSkipped).toBe(false); + expect(skipPlan.updatedTask.complete_instances).toEqual([]); + expect(skipPlan.updatedTask.skipped_instances).toEqual([]); + for (const plan of [completePlan, skipPlan]) { + expect(plan.updatedTask.scheduled).toBe("2026-04-20"); + expect(plan.updatedTask.complete_instances).not.toContain("2026-04-13"); + expect(plan.updatedTask.skipped_instances).not.toContain("2026-04-13"); + } + }); + + it("preserves the moved date's due offset when advancing", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createMovedWeeklyTask({ due: "2026-04-12" }), + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.scheduled).toBe("2026-04-20"); + expect(plan.updatedTask.due).toBe("2026-04-22"); + }); + + it("keeps later moved occurrences advancing normally", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createMovedWeeklyTask({ scheduled: "2026-04-15" }), + targetDate: new Date("2026-04-15T12:00:00.000Z"), + currentTimestamp: "2026-04-15T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.updatedTask.complete_instances).toEqual(["2026-04-15"]); + expect(plan.updatedTask.scheduled).toBe("2026-04-20"); + }); + + it("does not change completion-anchored recurrence calculation", () => { + const baseTask = createMovedWeeklyTask({ recurrence_anchor: "completion" }); + const withoutGoogleException = { + ...baseTask, + googleCalendarExceptionOriginalScheduled: undefined, + }; + const input = { + targetDate: new Date("2026-04-10T12:00:00.000Z"), + currentTimestamp: "2026-04-10T12:30:00.000Z", + maintainDueDateOffsetInRecurring: true, + }; + + const withException = buildRecurringTaskCompletePlan({ + freshTask: baseTask, + ...input, + }); + const withoutException = buildRecurringTaskCompletePlan({ + freshTask: withoutGoogleException, + ...input, + }); + + expect(withException.updatedTask.scheduled).toBe( + withoutException.updatedTask.scheduled + ); + expect(withException.updatedTask.recurrence).toBe( + withoutException.updatedTask.recurrence + ); + }); + }); });