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
24 changes: 20 additions & 4 deletions connectors/google/src/calendar/google-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
};

Expand Down
16 changes: 16 additions & 0 deletions connectors/google/src/calendar/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import type { Thread } from "@plotday/twister";
import type { CalendarSyncHost } from "./sync";
import {
buildEventSources,
calendarHistoryFloor,
cancelEventWithApiFn,
cancellationWasSelfInitiatedFn,
extractRSVPParamsFn,
Expand DownExpand Up@@ -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);
});
});
16 changes: 7 additions & 9 deletions connectors/google/src/calendar/sync.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}

/**
Expand Down
Loading