diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 21617927f..adcaf0e88 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -16,6 +16,8 @@ Example: ``` ## Fixed +- Avoid showing a recurring task twice when its original occurrence date is recorded by a calendar move. Keep requested completion and skip history visible. + - (#768) Fixed calendar view appearing empty in week and day views due to invalid time configuration values - Added time validation in settings UI with proper error messages and debouncing - Prevents "Cannot read properties of null (reading 'years')" error from FullCalendar diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 0a25be2c2..8d5281b18 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -1141,6 +1141,13 @@ export function generateRecurringTaskInstances( const hasOriginalTime = hasTimeComponent(task.scheduled); const templateTime = getRecurringTime(task); const nextScheduledDate = getDatePart(task.scheduled); + // A moved occurrence is represented at its current scheduled placement. + // Its original rule date must not also become a projected task. Recorded + // completions/skips are handled separately below and remain available. + const movedOriginalDates = new Set(task.googleCalendarMovedOriginalDates || []); + if (task.googleCalendarExceptionOriginalScheduled) { + movedOriginalDates.add(getDatePart(task.googleCalendarExceptionOriginalScheduled)); + } const spanDayOffset = showScheduledToDueSpan ? getScheduledToDueSpanDayOffset(task) : null; const shouldCreateRecurringSpan = spanDayOffset !== null; const recurringSearchStartDate = shouldCreateRecurringSpan @@ -1227,7 +1234,7 @@ export function generateRecurringTaskInstances( } // Skip if conflicts with next scheduled occurrence - if (instanceDate === nextScheduledDate) { + if (instanceDate === nextScheduledDate || movedOriginalDates.has(instanceDate)) { continue; } diff --git a/src/bases/calendarDataSignature.ts b/src/bases/calendarDataSignature.ts index f1e3469e3..3ea7a1fa4 100644 --- a/src/bases/calendarDataSignature.ts +++ b/src/bases/calendarDataSignature.ts @@ -18,6 +18,8 @@ const CALENDAR_DATA_SIGNATURE_FIELDS: FieldMappingKey[] = [ "blockedBy", "icsEventId", "googleCalendarEventId", + "googleCalendarExceptionOriginalScheduled", + "googleCalendarMovedOriginalDates", "reminders", "sortOrder", ]; diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 88b2ff3df..99fdfc47d 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -198,6 +198,8 @@ function createTaskInfoFromProperties( "icsEventId", "complete_instances", "skipped_instances", + "googleCalendarExceptionOriginalScheduled", + "googleCalendarMovedOriginalDates", "blockedBy", "blocking", "sortOrder", @@ -267,6 +269,10 @@ function createTaskInfoFromProperties( icsEventId: toStringArray(props.icsEventId), complete_instances: toStringArray(props.complete_instances), skipped_instances: toStringArray(props.skipped_instances), + googleCalendarExceptionOriginalScheduled: toOptionalString( + props.googleCalendarExceptionOriginalScheduled + ), + googleCalendarMovedOriginalDates: toStringArray(props.googleCalendarMovedOriginalDates), blockedBy: toDependencies(props.blockedBy), blocking: blockingTasks.length > 0 ? blockingTasks : undefined, isBlocked: isBlocked, diff --git a/tests/unit/bases/calendarDataSignature.test.ts b/tests/unit/bases/calendarDataSignature.test.ts index 1b9298394..1c8cddf9f 100644 --- a/tests/unit/bases/calendarDataSignature.test.ts +++ b/tests/unit/bases/calendarDataSignature.test.ts @@ -7,6 +7,16 @@ import { } from "../../../src/bases/calendarDataSignature"; describe("calendarDataSignature", () => { + it("invalidates the calendar when mapped occurrence-move markers change", () => { + const properties = buildCalendarDataSignaturePropertyIds({ + mapField: (field) => field === "googleCalendarExceptionOriginalScheduled" ? "originalOccurrence" : undefined, + showPropertyBasedEvents: false, + }); + const before = [{path: "task.md", properties: {originalOccurrence: "2026-09-12"}}]; + const after = [{path: "task.md", properties: {originalOccurrence: "2026-09-19"}}]; + expect(buildCalendarDataSignature(before, properties)).not.toEqual(buildCalendarDataSignature(after, properties)); + expect(properties).toContain("googleCalendarMovedOriginalDates"); + }); it("selects mapped core fields, visible properties, and property-event fields", () => { const propertyIds = buildCalendarDataSignaturePropertyIds({ mapField: (field) => (field === "scheduled" ? "planned" : undefined), diff --git a/tests/unit/issues/issue-1603-recurring-calendar-instance-visibility.test.ts b/tests/unit/issues/issue-1603-recurring-calendar-instance-visibility.test.ts index c444673fc..2d47d971f 100644 --- a/tests/unit/issues/issue-1603-recurring-calendar-instance-visibility.test.ts +++ b/tests/unit/issues/issue-1603-recurring-calendar-instance-visibility.test.ts @@ -14,6 +14,7 @@ import { } from "../../../src/bases/calendar-core"; import type TaskNotesPlugin from "../../../src/main"; import { TaskFactory } from "../../helpers/mock-factories"; +import { createTaskInfoFromBasesData } from "../../../src/bases/helpers"; function createPlugin(): TaskNotesPlugin { return { @@ -35,6 +36,59 @@ describe("Issue #1603: recurring calendar instance visibility", () => { const start = new Date("2026-02-01T00:00:00.000Z"); const end = new Date("2026-02-06T00:00:00.000Z"); + it("renders a moved occurrence once and keeps later weekly occurrences", () => { + const task = TaskFactory.createRecurringTask("DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA", { + scheduled: "2026-09-11", + googleCalendarExceptionOriginalScheduled: "2026-09-12", + googleCalendarMovedOriginalDates: ["2026-09-05"], + }); + const before = JSON.stringify(task); + const events = generateRecurringTaskInstances( + task, + new Date("2026-09-01T00:00:00Z"), + new Date("2026-09-21T00:00:00Z"), + plugin + ); + expect(getInstanceDates(events)).toEqual(["2026-09-11", "2026-09-19"]); + expect(JSON.stringify(task)).toBe(before); + }); + + it("preserves move markers through Bases conversion before the task cache is warm", () => { + const task = createTaskInfoFromBasesData({ + path: "tasks/moved.md", + properties: { + recurrence: "DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA", + scheduled: "2026-09-11", + googleCalendarExceptionOriginalScheduled: "2026-09-12", + googleCalendarMovedOriginalDates: ["2026-09-05"], + }, + }); + expect(task).not.toBeNull(); + expect(getInstanceDates(generateRecurringTaskInstances( + task!, new Date("2026-09-01T00:00:00Z"), new Date("2026-09-21T00:00:00Z"), plugin + ))).toEqual(["2026-09-11", "2026-09-19"]); + }); + + it("keeps recorded history for moved dates when history is requested", () => { + const task = TaskFactory.createRecurringTask("DTSTART:20260905;FREQ=WEEKLY;BYDAY=SA", { + scheduled: "2026-09-19", + googleCalendarMovedOriginalDates: ["2026-09-12"], + complete_instances: ["2026-09-12"], + }); + const range = [new Date("2026-09-11T00:00:00Z"), new Date("2026-09-21T00:00:00Z")] as const; + expect(getInstanceDates(generateRecurringTaskInstances(task, ...range, plugin))).toEqual([ + "2026-09-12", + "2026-09-19", + ]); + expect( + getInstanceDates( + generateRecurringTaskInstances(task, ...range, plugin, { + showCompletedRecurringInstances: false, + }) + ) + ).toEqual(["2026-09-19"]); + }); + it("keeps completed and skipped recurring instances visible by default", () => { const task = TaskFactory.createRecurringTask("FREQ=DAILY;INTERVAL=1", { path: "tasks/recur.md",