Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 66 additions & 5 deletions src/services/task-service/taskUpdatePlanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
applyGoogleCalendarRecurringExceptionCleanup,
applyGoogleCalendarRecurringExceptionForScheduledChange,
resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction,
} from "./googleCalendarRecurringExceptions";
import {
applyPropertyTaskIdentifier,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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();
});
});
62 changes: 62 additions & 0 deletions tests/unit/services/taskUpdatePlanning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading