diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ca144a1ba..2dc7355b2 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -76,6 +76,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- Completing or skipping an instance of a scheduled-anchored recurring task from + the edit modal's completions calendar no longer creates a duplicate Google + Calendar event at the next occurrence date. The scheduled date advancing as + part of completion is no longer mistaken for manually moving that occurrence. - (#2182) Checklist progress on task cards now excludes cancelled markdown checklist items such as `[-]` from the completed/total count. Thanks to @ctrl-q for reporting this. diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..0d86d6d58 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -6,6 +6,7 @@ import { import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction, } from "./googleCalendarRecurringExceptions"; import { applyPropertyTaskIdentifier, @@ -90,6 +91,54 @@ function stripTimeEntryDuration(entry: TimeEntry): TimeEntry { return sanitizedEntry; } +function getStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** + * When completing or skipping a recurring instance also advances `scheduled` to the + * next occurrence, that's the series cursor rolling forward - not a manual reschedule + * of a single occurrence. Returns the instance date that was newly marked complete or + * skipped, if any, so the caller can resolve (rather than create) a Google Calendar + * "moved occurrence" exception. Without this distinction, sync would create a detached + * event for the next occurrence in addition to the recurring series event already + * covering that date. + */ +function getNewlyRecordedInstanceDate( + originalTask: TaskInfo, + updates: TaskUpdateInput +): string | undefined { + const originalCompleted = new Set(getStringArray(originalTask.complete_instances)); + const originalSkipped = new Set(getStringArray(originalTask.skipped_instances)); + + let latest: string | undefined; + const consider = (dateStr: string) => { + if (!latest || dateStr > latest) { + latest = dateStr; + } + }; + + if (Object.prototype.hasOwnProperty.call(updates, "complete_instances")) { + for (const dateStr of getStringArray(updates.complete_instances)) { + if (!originalCompleted.has(dateStr)) { + consider(dateStr); + } + } + } + + if (Object.prototype.hasOwnProperty.call(updates, "skipped_instances")) { + for (const dateStr of getStringArray(updates.skipped_instances)) { + if (!originalSkipped.has(dateStr)) { + consider(dateStr); + } + } + } + + return latest; +} + export function normalizeTaskUpdateDetails(updates: TaskUpdateInput): string | null { if (!Object.prototype.hasOwnProperty.call(updates, "details")) { return null; @@ -167,13 +216,25 @@ export function buildTaskUpdateRecurrenceUpdates({ if (Object.prototype.hasOwnProperty.call(updates, "scheduled")) { const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; - applyGoogleCalendarRecurringExceptionForScheduledChange( - originalTask, - updates.scheduled, - nextTask - ); + const completionActionDate = getNewlyRecordedInstanceDate(originalTask, updates); + + if (completionActionDate) { + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + originalTask, + completionActionDate, + nextTask + ); + } else { + applyGoogleCalendarRecurringExceptionForScheduledChange( + originalTask, + updates.scheduled, + nextTask + ); + } + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = nextTask.googleCalendarExceptionOriginalScheduled; + recurrenceUpdates.googleCalendarMovedOriginalDates = nextTask.googleCalendarMovedOriginalDates; } const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; diff --git a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts index 3ebc83c33..2a5cba2c8 100644 --- a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts +++ b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts @@ -284,4 +284,34 @@ describe("Issue #1696: Google Calendar recurring reschedule sync", () => { expect(frontmatter.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); }); + + it("does not flag a moved occurrence when the completions calendar advances scheduled (regression)", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-13", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + } as TaskInfo; + + // Mirrors what the task edit modal's completions calendar sends: it checks off + // the current instance and advances `scheduled` to the next occurrence in the + // same update, unlike a manual drag-to-reschedule which only changes `scheduled`. + const updatedTask = await taskService.updateTask(task, { + complete_instances: ["2026-04-13"], + scheduled: "2026-05-11", + }); + + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); }); diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts index 31215bfc9..a4b758bed 100644 --- a/tests/unit/services/taskUpdatePlanning.test.ts +++ b/tests/unit/services/taskUpdatePlanning.test.ts @@ -137,6 +137,68 @@ describe("taskUpdatePlanning", () => { }); }); + it("does not flag a moved-occurrence exception when scheduled advances from completing an instance", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-13", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + }), + updates: { + scheduled: "2026-05-11", + complete_instances: ["2026-04-13"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); + + it("resolves a pending moved-occurrence exception when the moved instance is completed", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-15", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + }), + updates: { + scheduled: "2026-05-13", + complete_instances: ["2026-04-15"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(result.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + }); + + it("still flags a moved-occurrence exception for a genuine manual reschedule alongside instance edits", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-13", + complete_instances: ["2026-03-16"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + }), + updates: { + scheduled: "2026-04-14", + complete_instances: ["2026-03-16"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + }); + it("adds DTSTART when a scheduled recurring task moves and the rule lacks DTSTART", () => { const addDTSTARTToRecurrenceRuleFn = jest.fn(() => "DTSTART:20260521;FREQ=DAILY");