diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 1a0f22f07..2a37e6287 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,6 +39,9 @@ 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. - (#2193) Google Calendar event descriptions written with formatting now read as plain text in event details, copied text, and generated notes, instead of showing raw HTML tags. Paragraph breaks, list structure, and link addresses are 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 + ); + }); + }); });