diff --git a/connectors/google/src/calendar/google-api.ts b/connectors/google/src/calendar/google-api.ts index 1284db5d..cc85b0f0 100644 --- a/connectors/google/src/calendar/google-api.ts +++ b/connectors/google/src/calendar/google-api.ts @@ -91,10 +91,26 @@ export type SyncState = { min?: Date | null; max?: Date | null; sequence?: number; - // Initial sync runs two passes: "quick" (timeMin=now, front-loads upcoming - // non-recurring events and future exceptions) then "full" (timeMin=history - // limit, picks up long-running recurring masters excluded by the quick - // pass's timeMin). Undefined on incremental webhook-triggered syncs. + // Initial sync runs two passes: "quick" (timeMin=now) then "full" + // (timeMin=history floor). + // + // The quick pass front-loads everything upcoming, recurring series + // INCLUDED: Google does not apply `timeMin` to recurring masters. Verified + // against a live calendar — a FREQ=YEARLY master whose first instance was + // in 1980, and a FREQ=WEEKLY master whose RRULE `UNTIL` had already passed, + // were both returned by a `timeMin=now` listing. Only non-recurring events + // are actually bounded by `timeMin`. + // + // So the full pass is NOT what makes recurring meetings appear (an earlier + // version of this comment claimed it was). It exists to import past one-off + // events and to establish the `nextSyncToken` that incremental syncs reuse. + // + // Do not generalise this to Outlook: Microsoft Graph's + // `$filter=start/dateTime ge …` is a literal comparison against the series + // master's own (first) start, which genuinely does exclude a long-running + // master. See `outlook/src/calendar/sync.ts`. + // + // Undefined on incremental webhook-triggered syncs. phase?: "quick" | "full"; }; diff --git a/connectors/google/src/calendar/sync.test.ts b/connectors/google/src/calendar/sync.test.ts index 06c10b10..1f47a6d5 100644 --- a/connectors/google/src/calendar/sync.test.ts +++ b/connectors/google/src/calendar/sync.test.ts @@ -11,6 +11,7 @@ import type { Thread } from "@plotday/twister"; import type { CalendarSyncHost } from "./sync"; import { buildEventSources, + calendarHistoryFloor, cancelEventWithApiFn, cancellationWasSelfInitiatedFn, extractRSVPParamsFn, @@ -2148,3 +2149,18 @@ describe("processCalendarEventsFn — initial-sync occurrence round-trips", () = expect(totalRoundTrips(large.calls)).toBeLessThan(10); }); }); + +describe("calendarHistoryFloor", () => { + it("is exactly 366 days before now, so annual events stay in window", () => { + const now = new Date("2026-08-03T12:00:00.000Z"); + const floor = calendarHistoryFloor(now); + const days = (now.getTime() - floor.getTime()) / (24 * 60 * 60 * 1000); + expect(days).toBe(366); + }); + + it("keeps an event dated exactly one year ago inside the window", () => { + const now = new Date("2026-08-03T12:00:00.000Z"); + const oneYearAgo = new Date("2025-08-03T12:00:00.000Z"); + expect(oneYearAgo >= calendarHistoryFloor(now)).toBe(true); + }); +}); diff --git a/connectors/google/src/calendar/sync.ts b/connectors/google/src/calendar/sync.ts index 4d04d5d9..622674c1 100644 --- a/connectors/google/src/calendar/sync.ts +++ b/connectors/google/src/calendar/sync.ts @@ -283,17 +283,15 @@ export async function firstSeenAtFn( } /** - * Start of the history window the initial backfill imports: Jan 1 of two - * calendar years ago (mirrors the `historyMin` computed when the quick pass - * transitions to the full pass). Events scheduled before this were never - * imported, so a cancellation for one can only materialise a phantom thread. + * How far back the initial backfill imports: 366 days — a year and a day, so an + * annual event is always inside the window. Events scheduled before this were + * never imported, so a cancellation for one can only materialise a phantom + * thread (see `cancellationIsForUnimportedEventFn`). */ +export const CALENDAR_HISTORY_DAYS = 366; + export function calendarHistoryFloor(now: Date = new Date()): Date { - const floor = new Date(now); - floor.setFullYear(floor.getFullYear() - 2); - floor.setMonth(0, 1); - floor.setHours(0, 0, 0, 0); - return floor; + return new Date(now.getTime() - CALENDAR_HISTORY_DAYS * 24 * 60 * 60 * 1000); } /**