Moltenship Time
Guides

Sync with Google Calendar

Load a Google calendar from the dates on screen, keep it current with sync tokens, and write changes back with etags.

Live, in your time zone, against a fake Calendar API in your browser. Change events here or “in Google”, and watch the requests.

This calendar syncs with a Google calendar. There are no Google credentials on this page, so it talks to a fake Calendar API that runs in your browser. It answers with API v3's JSON, pages, sync tokens, etags and errors. Drag an event, or delete one, and it's written back. The buttons change events as someone else would in Google, expire the sync token and rate-limit requests. The log shows each request.

This guide builds it. The code is what a real app runs. The demo hands it a fake fetch instead of your route, and a shorter retry delay.

FileWhat it does
lib/google-calendar-api.tsA small client for the API's events, over fetch
app/api/google-calendar/[...path]/route.tsSends the browser's requests to Google with the user's token
lib/google-calendar.tsMaps Google events to calendar events, from Google Calendar events
lib/google-calendar-sync.tsThe events loaded so far: loads, syncs and writes
components/google-calendar-view.tsxThe calendar

None of it is in the calendar's packages. They don't depend on Google, and this guide doesn't add a Google SDK either.

Sign-in is up to you

The Calendar API needs an OAuth 2.0 access token for the user. Ask for the https://www.googleapis.com/auth/calendar.events scope to read and write events, or calendar.events.readonly to only read them. Your auth library, such as Auth.js or Better Auth, runs the sign-in on your server and keeps the tokens there. Ask Google for offline access, so you get a refresh token, and refresh the access token when it expires, as your library's docs show. This guide starts once you have one.

Keep the tokens on the server. The browser calls your own route, and the route adds the token.

Call the API

The client covers the calls the calendar needs: list events, read one occurrence of a series, and insert, patch, replace and delete:

lib/google-calendar-api.ts
// lib/google-calendar-api.ts
import type { GoogleEvent } from "./google-calendar";

export interface GoogleCalendarApiOptions {
  /**
   * `https://www.googleapis.com/calendar/v3` on your server, with `headers` adding the user's
   * access token. In the browser, your own route that adds it.
   */
  readonly baseUrl: string;
  /** "primary", or a calendar's ID from `calendarList.list`. */
  readonly calendarId?: string;
  readonly fetch?: typeof fetch;
  /** Headers for every request, such as `Authorization: Bearer <access token>`. */
  readonly headers?: () => HeadersInit | Promise<HeadersInit>;
  /** How often to retry a rate-limited or failed request before giving up. */
  readonly retries?: number;
  /** How long to wait before retry `attempt`, from 0, when Google doesn't say. */
  readonly retryDelay?: (attempt: number) => number;
}

/** A request Google refused: its status, and the reason its error body gives. */
export class GoogleApiError extends Error {
  override readonly name = "GoogleApiError";
  readonly reason: string | undefined;
  readonly status: number;

  constructor(status: number, reason: string | undefined, message: string) {
    super(message);
    this.reason = reason;
    this.status = status;
  }
}

/**
 * The events that end after `timeMin` and start before `timeMax`, if given, or the changes since a
 * sync token. Google doesn't take both at once.
 */
export type GoogleEventsQuery =
  | { readonly timeMax?: Temporal.Instant; readonly timeMin: Temporal.Instant }
  | { readonly syncToken: string };

export interface GoogleEventTimeWrite {
  readonly date?: string | null;
  readonly dateTime?: string | null;
  readonly timeZone?: string | null;
}

/**
 * What a write sends: these fields, and any other of the Event resource. In a patch, `null` clears a
 * field, as a move between all-day and timed needs.
 */
export interface GoogleEventWrite {
  readonly colorId?: string;
  readonly end?: GoogleEventTimeWrite;
  /** Insert only: lowercase a–v and digits, 5 to 1024 of them, so you can pick it up front. */
  readonly id?: string;
  readonly originalStartTime?: GoogleEventTimeWrite;
  readonly recurrence?: readonly string[];
  readonly recurringEventId?: string;
  readonly start?: GoogleEventTimeWrite;
  readonly status?: "confirmed" | "tentative";
  readonly summary?: string;
  readonly transparency?: "opaque" | "transparent";
}

interface GoogleEventsPage {
  readonly items?: readonly GoogleEvent[];
  readonly nextPageToken?: string;
  readonly nextSyncToken?: string;
}

const rateLimits = new Set(["rateLimitExceeded", "userRateLimitExceeded"]);

/** 1, 2, 4… seconds, up to 32, plus up to a second at random, so clients don't retry in step. */
const backoff = (attempt: number) => Math.min(2 ** attempt, 32) * 1000 + Math.random() * 1000;

/** The Calendar API's events, over `fetch`. */
export function createGoogleCalendarApi({
  baseUrl,
  calendarId = "primary",
  fetch: send = fetch,
  headers,
  retries = 5,
  retryDelay = backoff,
}: GoogleCalendarApiOptions) {
  const events = `${baseUrl}/calendars/${encodeURIComponent(calendarId)}/events`;

  async function request<T>(
    method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT",
    url: string,
    { body, etag }: { readonly body?: GoogleEventWrite; readonly etag?: string | undefined } = {},
  ): Promise<T> {
    const sent = new Headers(await headers?.());
    if (body !== undefined) sent.set("Content-Type", "application/json");
    // Google writes only over the version you read, and answers 412 if it has changed since.
    if (etag !== undefined) sent.set("If-Match", etag);

    for (let attempt = 0; ; attempt++) {
      const response = await send(url, {
        body: body === undefined ? null : JSON.stringify(body),
        headers: sent,
        method,
      });
      if (response.ok) return (response.status === 204 ? undefined : await response.json()) as T;

      const error = await errorOf(response);
      const rateLimited =
        error.status === 429 || (error.status === 403 && rateLimits.has(error.reason ?? ""));
      if (attempt >= retries || !(rateLimited || error.status >= 500)) throw error;
      const retryAfter = Number(response.headers.get("Retry-After"));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : retryDelay(attempt));
    }
  }

  return {
    /**
     * Every page of `events.list`. Series come as their masters, with their changed and cancelled
     * occurrences, since `singleEvents` stays off. The sync token is on the last page.
     */
    async list(query: GoogleEventsQuery) {
      const items: GoogleEvent[] = [];
      let pageToken: string | undefined;
      for (;;) {
        const params = new URLSearchParams(
          "syncToken" in query
            ? { syncToken: query.syncToken }
            : { timeMin: query.timeMin.toString() },
        );
        if ("timeMax" in query && query.timeMax !== undefined) {
          params.set("timeMax", query.timeMax.toString());
        }
        if (pageToken !== undefined) params.set("pageToken", pageToken);
        const page = await request<GoogleEventsPage>("GET", `${events}?${params}`);
        items.push(...(page.items ?? []));
        pageToken = page.nextPageToken;
        if (pageToken === undefined) return { items, nextSyncToken: page.nextSyncToken };
      }
    },

    /**
     * The occurrence a series' rule puts at `originalStart`, with its own ID and etag, cancelled
     * or not.
     */
    async instance(seriesId: string, originalStart: string) {
      const params = new URLSearchParams({ originalStart, showDeleted: "true" });
      for (;;) {
        const page = await request<GoogleEventsPage>(
          "GET",
          `${events}/${encodeURIComponent(seriesId)}/instances?${params}`,
        );
        const [instance] = page.items ?? [];
        if (instance !== undefined) return instance;
        // A page may be empty and still have more.
        if (page.nextPageToken === undefined) break;
        params.set("pageToken", page.nextPageToken);
      }
      throw new GoogleApiError(
        404,
        "notFound",
        `${seriesId} has no occurrence at ${originalStart}`,
      );
    },

    insert: (event: GoogleEventWrite) => request<GoogleEvent>("POST", events, { body: event }),

    patch: (id: string, fields: GoogleEventWrite, etag: string | undefined) =>
      request<GoogleEvent>("PATCH", `${events}/${encodeURIComponent(id)}`, { body: fields, etag }),

    remove: (id: string, etag: string | undefined) =>
      request<void>("DELETE", `${events}/${encodeURIComponent(id)}`, { etag }),

    /** Replaces the whole event: a field the write leaves out is cleared. */
    update: (id: string, event: GoogleEventWrite, etag: string | undefined) =>
      request<GoogleEvent>("PUT", `${events}/${encodeURIComponent(id)}`, { body: event, etag }),
  };
}

export type GoogleCalendarApi = ReturnType<typeof createGoogleCalendarApi>;

/** Google's error body: `{ error: { code, message, errors: [{ reason }] } }`. */
async function errorOf(response: Response): Promise<GoogleApiError> {
  const body = (await response.json().catch(() => undefined)) as
    | { error?: { errors?: { reason?: string }[]; message?: string } }
    | undefined;
  return new GoogleApiError(
    response.status,
    body?.error?.errors?.[0]?.reason,
    body?.error?.message ?? response.statusText,
  );
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
  • Where it runs. On your server, baseUrl is Google's and headers adds the access token. In the browser, baseUrl is your route, below, and the cookie your auth library set signs the request. The rest of the guide runs in the browser.
  • Pages. list follows nextPageToken until the last page, which has nextSyncToken instead. Google may send fewer events than maxResults, or none, and still have more.
  • Rate limits. A 429, a 403 with rateLimitExceeded or userRateLimitExceeded, and a 5xx are retried after 1, 2, 4… seconds, up to 32, plus a random part so that clients don't retry together. A Retry-After header wins. Other errors throw a GoogleApiError at once: a 410 or a 412 means something, and retrying won't change it.
  • googleapis. On a Node.js server you can use Google's SDK instead. calendar.events.list({ calendarId, timeMin, timeMax, pageToken, syncToken }) takes the same parameters. Wrap its calls so their errors become GoogleApiErrors with the status and reason, which the store reads. Plain fetch also runs in the browser and on edge runtimes, and adds no dependency.

The route

A Route Handler sends the browser's requests on to Google with the user's access token:

app/api/google-calendar/[...path]/route.ts
// app/api/google-calendar/[...path]/route.ts
import type { NextRequest } from "next/server";

/**
 * The signed-in user's Google access token, from your auth library, with the
 * `https://www.googleapis.com/auth/calendar.events` scope. `null` when they aren't signed in.
 */
declare function googleAccessToken(request: NextRequest): Promise<string | null>;

const calendarApi = "https://www.googleapis.com/calendar/v3";

/** The query parameters the client sends. */
const parameters = new Set([
  "originalStart",
  "pageToken",
  "showDeleted",
  "syncToken",
  "timeMax",
  "timeMin",
]);

/** Which calls go through: the methods each path takes, below `calendars/primary/events`. */
function allowed(method: string, event: readonly string[]): boolean {
  const [id, instances, ...rest] = event;
  if (id === undefined) return method === "GET" || method === "POST"; // list, insert
  if (id === "." || id === ".." || rest.length > 0) return false;
  if (instances !== undefined) return instances === "instances" && method === "GET";
  return method === "PATCH" || method === "PUT" || method === "DELETE";
}

/**
 * Sends the browser's Calendar API requests on to Google with the user's access token, which never
 * reaches the browser. Only the calls the client makes, on the user's own events.
 */
async function forward(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
  // Only your own pages: browsers mark a request from another site.
  if (request.headers.get("Sec-Fetch-Site") === "cross-site") {
    return Response.json({ error: { code: 403, message: "Forbidden" } }, { status: 403 });
  }
  const { path } = await params;
  const [calendars, calendarId, events, ...event] = path;
  const { searchParams } = request.nextUrl;
  if (
    calendars !== "calendars" ||
    calendarId !== "primary" ||
    events !== "events" ||
    !allowed(request.method, event) ||
    [...searchParams.keys()].some((name) => !parameters.has(name))
  ) {
    return Response.json({ error: { code: 404, message: "Not found" } }, { status: 404 });
  }

  const token = await googleAccessToken(request);
  if (token === null) {
    return Response.json({ error: { code: 401, message: "Sign in again" } }, { status: 401 });
  }

  const url = new URL(`${calendarApi}/${path.map(encodeURIComponent).join("/")}`);
  url.search = searchParams.toString();
  const headers = new Headers({ Authorization: `Bearer ${token}` });
  for (const name of ["Content-Type", "If-Match"]) {
    const value = request.headers.get(name);
    if (value !== null) headers.set(name, value);
  }
  const response = await fetch(url, {
    body: request.method === "GET" || request.method === "DELETE" ? null : await request.text(),
    headers,
    method: request.method,
  });

  // The status, Retry-After and Google's error body pass through, so the browser handles 410, 412
  // and 429 itself.
  const passed = new Headers();
  for (const name of ["Content-Type", "Retry-After"]) {
    const value = response.headers.get(name);
    if (value !== null) passed.set(name, value);
  }
  return new Response(response.body, { headers: passed, status: response.status });
}

export const GET = forward;
export const POST = forward;
export const PATCH = forward;
export const PUT = forward;
export const DELETE = forward;
  • Only what the client calls. It forwards the calls above, on the user's primary calendar's events, with the query parameters the client sends. Anything else gets a 404, so the route can't be used to reach the rest of their Google account.
  • Only your pages. Browsers mark a request another site makes with Sec-Fetch-Site: cross-site, and the route refuses it.
  • Errors pass through. The status, Retry-After and Google's error body reach the browser, so the client there handles 410, 412 and 429 as it would on the server.

Map the events

fromGoogleEvents, from Google Calendar events, maps the Event resource to the calendar's events, and keeps the Google event as each one's data:

GoogleCalendar
start.dateTime, end.dateTimeA timed event. RFC 3339; without an offset, the time is read in timeZone. start.timeZone becomes sourceTimeZone, the clock a series repeats on.
start.date, end.dateAn all-day event. Google's end.date is the day after the last, which is what the calendar's exclusive end means, so it maps as it is.
status: "cancelled"Deleted. A cancelled occurrence of a series hides that occurrence.
recurrence, recurringEventId, originalStartTimeA series' master, and its changed and cancelled occurrences. See Series.
colorIdOne of Google's 11 event colors. Read by getEventColor.
transparency"transparent" is "Show as: Free". Read by isBusy.

The calendar's adapters read color and busy from data:

components/google-calendar-view.tsx
"use client";

import {
  createCalendar,
  eventIdOf,
  weekRange,
  type CalendarEventChange,
  type CalendarFocus,
  type CalendarSelection,
  type CalendarVisibleRange,
  type EventId,
} from "@moltenship/calendar";
import {
  createCanonicalRecurrenceAdapter,
  createOccurrenceAdapter,
  createOccurrenceExpander,
  isRecurringChange,
  recurrenceEditScopes,
  resolveRecurrenceEdit,
  type RecurrenceAdapters,
  type RecurrenceEditScope,
  type RecurrenceOccurrence,
} from "@moltenship/calendar-recurrence";
import { useEffect, useMemo, useState, useSyncExternalStore, type MouseEvent } from "react";

import { calendarAnnouncements } from "@/components/calendar/announcements";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import type { CalendarEventColor } from "@/components/calendar/event-colors";
import { useRecurrenceScopePrompt } from "@/components/calendar/recurrence-scope-dialog/use-recurrence-scope-prompt";
import { createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";
import { Button } from "@/components/ui/button";

import type { GoogleCalendarEvent, GoogleEvent } from "@/lib/google-calendar";
import { createGoogleCalendarApi, GoogleApiError } from "@/lib/google-calendar-api";
import {
  createGoogleCalendarSync,
  newGoogleId,
  type GoogleCalendarSync,
} from "@/lib/google-calendar-sync";

/** What the calendar shows: an occurrence of a series, or a one-off event. */
export type GoogleOccurrence = RecurrenceOccurrence<GoogleCalendarEvent, string>;

const canonical = createCanonicalRecurrenceAdapter<GoogleEvent>();

export const adapters: RecurrenceAdapters<GoogleCalendarEvent, string, string> = {
  ...canonical,
  event: {
    ...canonical.event,
    // Google's "Show as": a free event doesn't make you busy, for `busyAt`, `useBusyResources`
    // and `conflictPolicy` alike. Without `transparency`, Google counts an event as busy, all-day
    // ones too; its own app saves new all-day events as free.
    isBusy: (event) => event.data.transparency !== "transparent",
  },
};

/**
 * Google's event colors, "1" (Lavender) to "11" (Tomato), as the theme's nearest. `colors.get`
 * has their hex values, if you'd rather show Google's own.
 */
const googleColors: Readonly<Record<string, CalendarEventColor>> = {
  "1": "purple",
  "2": "green",
  "3": "purple",
  "4": "pink",
  "5": "yellow",
  "6": "orange",
  "7": "teal",
  "8": "gray",
  "9": "blue",
  "10": "green",
  "11": "red",
};

export const Calendar = createCalendar<GoogleOccurrence>()({
  eventAdapter: createOccurrenceAdapter(adapters.event),
  views: [
    createWeekView<GoogleOccurrence>({
      describeEvent: ({ event }) =>
        event.data.transparency === "transparent" ? "free" : undefined,
      // Without `colorId`, an event has its calendar's color, here blue.
      getEventColor: ({ event }) => googleColors[event.data.colorId ?? ""] ?? "blue",
    }),
  ],
});
  • Busy or free. isBusy is the one place the calendar asks whether an event takes up time: busyAt, useBusyResources and conflictPolicy all follow it, as Busy or available describes. Without the adapter, the calendar treats all-day events as free, as Google's app does when it saves them. Google's API says it with transparency, and counts an event without it as busy, all-day or not, so the adapter reads the field rather than the kind.
  • Colors. Google's colorId is a key into colors.get, "1" (Lavender) to "11" (Tomato). The map picks the theme's nearest named color. An event without colorId has its calendar's color, which calendarList.get returns.
  • Imports. The view imports the lib/ files through @/lib/…, the alias Next.js apps start with. lib/google-calendar-sync.ts imports its neighbors with ./.

Changes go back the other way. toGoogleTimes writes an interval as Google's start and end:

lib/google-calendar-sync.ts
/**
 * An interval as Google's `start` and `end`, on `timeZone`'s clock unless the event has its own.
 * Each sets the field it uses and clears the other, so a move between all-day and timed works.
 */
export function toGoogleTimes(
  interval: CalendarEventInterval,
  timeZone: string,
): Pick<GoogleEventWrite, "end" | "start"> {
  if (interval.kind === "all-day") {
    // Google's `end.date` is the day after the last one, as the calendar's `end` is.
    const date = (day: Temporal.PlainDate) => ({ date: day.toString(), dateTime: null });
    return { end: date(interval.end), start: date(interval.start) };
  }
  const zone = (interval.kind === "timed" && interval.sourceTimeZone) || timeZone;
  const time = (at: Temporal.Instant | Temporal.PlainDateTime) => ({
    date: null,
    // RFC 3339 with the offset, such as 2026-10-20T11:00:00+02:00.
    dateTime: (at instanceof Temporal.Instant
      ? at.toZonedDateTimeISO(zone)
      : at.toZonedDateTime(zone)
    ).toString({ timeZoneName: "never" }),
    timeZone: zone,
  });
  return { end: time(interval.end), start: time(interval.start) };
}

/** Google event IDs are base32hex: lowercase a–v and digits. A UUID's hex digits fit. */
export const newGoogleId = () => crypto.randomUUID().replaceAll("-", "");

events.patch merges start and end field by field, so a write sets the field it uses and clears the other with null. Without that, an event dragged from the all-day row into the time grid would keep its date beside the new dateTime.

Series: masters, not instances

events.list can return a series two ways:

  • singleEvents=true. Google expands each series between timeMin and timeMax and leaves the master out. Each occurrence is an event of its own, with its series in recurringEventId, but not the series' rule.
  • Masters, the default. Each series comes once, with its rule in recurrence, plus the occurrences that changed or were cancelled. @moltenship/calendar-recurrence expands them for the dates on screen, as Show and edit recurring events does.

This guide loads masters:

  • Edits need the rule. "This and following events" ends the series before the occurrence and starts a new one, and "All events" moves the rule. Both rewrite the master's recurrence, and resolveRecurrenceEdit works them out from it. With singleEvents=true you would fetch each master as well.
  • Smaller syncs. A change to a series is one item in a sync, the master, rather than every occurrence Google expanded. A series that never ends is one event.
  • Fewer requests. Once a master has loaded, the calendar expands it for whatever dates it shows.

The cost is expanding series yourself, which the recurrence package does, with Google's DST and interop cases in its tests. A read-only calendar that never edits series can take singleEvents=true and skip the recurrence package.

Load the dates on screen

The calendar reports the dates it shows through onVisibleRangeChange. loadInstantRange is those dates, and the pages a scrolling view keeps ready, as instants:

components/google-calendar-view.tsx
export function GoogleCalendar({ sync, timeZone }: GoogleCalendarProps) {
  const [today] = useState(() => Temporal.Now.plainDateISO(timeZone));
  const events = useSyncExternalStore(sync.subscribe, sync.getEvents, sync.getEvents);
  // One expander while the calendar is mounted, so series that didn't change keep their work.
  const [expander] = useState(() => createOccurrenceExpander(adapters));
  const [range, setRange] = useState(() => weekRange(today, { weekStartsOn: weekRules.firstDay }));
  const { occurrences } = useMemo(
    () => expander.expand(events, { range, timeZone }),
    [expander, events, range, timeZone],
  );
  const [selection, setSelection] = useState<CalendarSelection | null>(null);
  const [focus, setFocus] = useState<CalendarFocus | null>(null);
  const [status, setStatus] = useState("");
  const { ask, dialog } = useRecurrenceScopePrompt();

  function onVisibleRangeChange({ loadInstantRange, loadRange }: CalendarVisibleRange) {
    setRange(loadRange);
    // `timeMin` and `timeMax` for the dates on screen, and the pages a scrolling view keeps ready.
    sync
      .load(loadInstantRange)
      .then(loaded, (error) => setStatus(`Couldn't load: ${reasonOf(error)}`));
  }

  // A later load or sync that works clears a load error.
  function loaded() {
    setStatus((current) => (current.startsWith("Couldn't load") ? "" : current));
  }

The sync store keeps Google's events by ID and maps each once:

lib/google-calendar-sync.ts
// lib/google-calendar-sync.ts
import type { CalendarEventChange, CalendarEventInterval } from "@moltenship/calendar";
import {
  createCanonicalRecurrenceAdapter,
  createOccurrenceExpander,
  recurrenceOverrideEdit,
  type RecurrenceDate,
  type RecurrenceEdit,
  type RecurrenceSeriesEdit,
} from "@moltenship/calendar-recurrence";

import {
  fromGoogleEvents,
  readGoogleRecurrence,
  toGoogleRecurrence,
  type GoogleCalendarEvent,
  type GoogleEvent,
} from "./google-calendar";
import {
  GoogleApiError,
  type GoogleCalendarApi,
  type GoogleEventWrite,
} from "./google-calendar-api";

/** Instants to load events for, half-open, as `onVisibleRangeChange`'s `loadInstantRange`. */
export interface TimeRange {
  readonly end: Temporal.Instant;
  readonly start: Temporal.Instant;
}

/** What `resolveRecurrenceEdit` returns for a change it accepted. */
export type GoogleEdit = Exclude<
  RecurrenceEdit<GoogleCalendarEvent, string, string>,
  { type: "rejected" }
>;

export interface GoogleCalendarSync {
  /** The events loaded so far, for the expander: the same array until one changes. */
  readonly getEvents: () => readonly GoogleCalendarEvent[];
  /** Calls `listener` whenever the events change, and returns a function that stops it. */
  readonly subscribe: (listener: () => void) => () => void;
  /** Loads the events `range` needs that aren't loaded yet. */
  readonly load: (range: TimeRange) => Promise<void>;
  /** Applies every change since the last sync, or syncs again from scratch after a 410. */
  readonly sync: () => Promise<void>;
  /** Writes an edit to Google, then syncs. Throws, after the sync, if Google refused a write. */
  readonly save: (edit: GoogleEdit, timeZone: string) => Promise<void>;
}

const day = { hours: 24 };

export function createGoogleCalendarSync(api: GoogleCalendarApi): GoogleCalendarSync {
  /** Google's events by ID: masters, changed and cancelled occurrences, one-off events. */
  const stored = new Map<string, GoogleEvent>();
  const mapped = new WeakMap<GoogleEvent, GoogleCalendarEvent | null>();
  const listeners = new Set<() => void>();
  let events: readonly GoogleCalendarEvent[] = [];
  /** The sync covers every event from here on. */
  let syncedFrom: Temporal.Instant | undefined;
  /** Dates before `syncedFrom` loaded since, sorted and apart. */
  let earlier: TimeRange[] = [];
  let lastRange: TimeRange | undefined;
  let syncToken: string | undefined;

  // One request chain at a time, so an older answer never overwrites a newer one.
  let queue: Promise<unknown> = Promise.resolve();
  function enqueue<T>(task: () => Promise<T>): Promise<T> {
    const run = queue.then(task, task);
    queue = run.catch(() => undefined);
    return run;
  }

  function changed() {
    // Each Google event maps once, so the expander reuses its work for the ones that didn't change.
    const next = [...stored.values()].flatMap((item) => {
      if (!mapped.has(item)) {
        const { events: mappedEvents, problems } = fromGoogleEvents([item]);
        for (const problem of problems) console.warn(`Google Calendar: ${problem}`);
        mapped.set(item, mappedEvents[0] ?? null);
      }
      return mapped.get(item) ?? [];
    });
    if (next.length === events.length && next.every((event, index) => event === events[index])) {
      return;
    }
    events = next;
    for (const listener of listeners) listener();
  }

  /** Google's answer, by ID. A cancelled occurrence stays, since it hides that occurrence. */
  function apply(items: readonly GoogleEvent[]) {
    const deleted = new Set<string>();
    for (const item of items) {
      if (item.recurringEventId !== undefined && deleted.has(item.recurringEventId)) continue;
      if (item.status !== "cancelled" || item.recurringEventId !== undefined) {
        stored.set(item.id, item);
        continue;
      }
      stored.delete(item.id);
      // A deleted series takes its changed and cancelled occurrences with it, in this answer too.
      deleted.add(item.id);
      for (const [id, other] of stored) if (other.recurringEventId === item.id) stored.delete(id);
    }
  }
  /** The etags of a series' master and changed occurrences, or of one occurrence's, by ID. */
  function versions(seriesId: string, recurrenceId?: RecurrenceDate) {
    const found = new Map<string, string | undefined>();
    for (const { data, override } of events) {
      const inSeries = data.id === seriesId || override?.seriesId === seriesId;
      const at =
        recurrenceId === undefined ||
        (override !== undefined &&
          keyOf(override.seriesId, override.recurrenceId) === keyOf(seriesId, recurrenceId));
      if (inSeries && at) found.set(data.id, data.etag);
    }
    return found;
  }

The first range starts the sync. Its list has a timeMin and no timeMax, as the full sync in Google's sync guide does, so the store gets every event from the first date on screen on, and a sync token for them. Dates before it load when the calendar shows them, between timeMin and timeMax:

lib/google-calendar-sync.ts
/**
 * The full sync, as Google's sync guide does it: every event that ends after the first date on
 * screen, with no `timeMax`, and the token that syncs them from then on.
 */
async function fullSync(range: TimeRange) {
  // All-day events are dates on the Google calendar's own clock, which may not be the one on
  // screen, so lists reach a day further.
  const { items, nextSyncToken } = await api.list({ timeMin: range.start.subtract(day) });
  // Swapped in once the list has worked, so a failure leaves the store as it was.
  stored.clear();
  apply(items);
  syncedFrom = range.start;
  earlier = [];
  syncToken = nextSyncToken;
  changed();
}

async function loadNow(range: TimeRange) {
  lastRange = range;
  if (syncedFrom === undefined) return fullSync(range);
  // Later dates are in the store already. Earlier ones load once, between two bounds.
  const end = Temporal.Instant.compare(range.end, syncedFrom) < 0 ? range.end : syncedFrom;
  for (const gap of missing(earlier, { end, start: range.start })) {
    const { items } = await api.list({
      timeMax: gap.end.add(day),
      timeMin: gap.start.subtract(day),
    });
    apply(items);
    earlier = merged(earlier, gap);
    changed();
  }
}
  • Half-open on both sides. timeMin bounds an event's end and timeMax its start, both exclusive, so Google returns the events that overlap [timeMin, timeMax): the same rule as the calendar's. An event that ends at midnight doesn't show on the next day.
  • All-day events are dates on the Google calendar's clock, which may not be the viewer's, so each list reaches a day further.
  • Later dates need no request. Scroll forward, and the store has the events already. Scroll back past the first date, and it lists each earlier range once.
  • Every page first. A master on one page and its changed occurrences on the next are one series, so the events show once the last page is in.
  • One request at a time. Loads, syncs and writes wait for each other, so a slow answer never overwrites a newer one.
  • One map per event. getEvents returns the same array until an event changes, and the same calendar event for a Google event that didn't change, so the expander only redoes the series that did.

For a calendar with years of events ahead, the first list is long. Then bound it with a timeMax too, and list later dates as the calendar reaches them, as it does earlier ones.

Sync changes

A list with syncToken returns every event that changed since the token, and a new token:

lib/google-calendar-sync.ts
async function syncNow() {
  if (syncToken === undefined) {
    // The first load failed: try it again.
    if (syncedFrom === undefined && lastRange !== undefined) await fullSync(lastRange);
    return;
  }
  try {
    // Every change since the last sync, whatever its dates.
    const { items, nextSyncToken } = await api.list({ syncToken });
    apply(items);
    syncToken = nextSyncToken ?? syncToken;
    changed();
  } catch (error) {
    // 410 Gone: the token expired. Sync again from scratch, from the dates on screen. Until that
    // works, the old events stay, and the next sync tries again.
    if (!isStatus(error, 410) || lastRange === undefined) throw error;
    await fullSync(lastRange);
  }
}
A sync returnsThe store
An eventReplaces the one with its ID, or adds it
A cancelled event or masterRemoves it, and a master's changed and cancelled occurrences with it
A cancelled occurrence (recurringEventId)Keeps it: it hides that occurrence
  • The same request. With syncToken, Google refuses filters such as timeMin, timeMax, q, orderBy and updatedMin with a 400. Keep any other parameter the same as on the first list.
  • One token. Lists of earlier dates return tokens too, and the store ignores them. A newer token would skip what changed between the two lists. Google's guide doesn't say whether the sync token reports changes to events before its timeMin; the store applies whatever a sync returns.
  • A failed first load is tried again by the next sync, so the calendar fills in once the network is back.
  • 410 Gone. Tokens expire, and then Google answers 410. The store syncs again from scratch, from the dates on screen. It swaps the new events in once they've loaded, so a failure keeps what's on screen, and the next sync tries again. Try it in the demo: expire the sync token, then sync.

Stay live

A sync needs a reason to run. The calendar syncs after every write, when the tab comes back, and once a minute:

components/google-calendar-view.tsx
useEffect(() => {
  // Catch up when the tab comes back, and every minute: push notifications can be late, or lost.
  const catchUp = () => {
    if (document.visibilityState === "visible") sync.sync().then(loaded, () => undefined);
  };
  document.addEventListener("visibilitychange", catchUp);
  const timer = setInterval(catchUp, 60_000);
  return () => {
    document.removeEventListener("visibilitychange", catchUp);
    clearInterval(timer);
  };
}, [sync]);

To show changes as they happen, subscribe to push notifications. Your server opens a watch channel for the user's calendar, with an HTTPS address of its own:

POST https://www.googleapis.com/calendar/v3/calendars/primary/events/watch
Authorization: Bearer <access token>
Content-Type: application/json

{ "id": "<a new UUID>", "type": "web_hook", "address": "https://example.com/api/google-calendar/notifications" }
  • Notifications carry no events. Each one says that something changed, in X-Goog-Resource-State: exists, and which channel it's for, in X-Goog-Channel-ID. Tell the user's open tabs, over server-sent events or a WebSocket, and they sync. The demo's "Push notifications" box does that part.
  • Channels expire. Open a new one before expiration, and close old ones with channels.stop. The first message on a channel says sync, and needs no action.
  • Some are lost. Google drops notifications now and then, so keep the timer above.

Write changes back

When someone drags or deletes an event, onEventChange works out what to save and hands it to the store:

components/google-calendar-view.tsx
async function onEventChange(change: OccurrenceChange) {
  let scope: RecurrenceEditScope = "this";
  // Google keeps deleted instances as cancelled overrides, which hide occurrences as EXDATEs do.
  const options = { overrides: events, timeZone };
  if (isRecurringChange(change)) {
    const scopes = recurrenceEditScopes(change, adapters, options);
    if (scopes.length === 0) {
      setStatus("This repeating event can't change that way.");
      throw new Error("No scope applies");
    }
    const picked = await ask({
      action: change.cause === "delete" ? "delete" : "edit",
      eventTitle: change.event.event.title,
      scopes,
    });
    if (picked === null) throw new DOMException("Cancelled", "AbortError");
    scope = picked;
  }
  const edit = resolveRecurrenceEdit(change, scope, adapters, {
    ...options,
    // Google takes IDs you pick, so a "following" edit's new series is known before it's saved.
    newSeriesId: newGoogleId(),
    occurrences,
  });
  if (edit.type === "rejected") {
    setStatus(edit.message);
    throw new Error(edit.message);
  }

  setStatus("Saving…");
  try {
    // The change shows, pending, until Google answers. Throwing rolls it back.
    await sync.save(edit, timeZone);
  } catch (error) {
    setStatus(`Not saved: ${reasonOf(error)}`);
    throw error;
  }
  setStatus("Saved.");
  if (edit.type === "series") {
    setSelection((current) => renamed(current, edit.occurrenceIds));
    setFocus((current) => renamed(current, edit.occurrenceIds));
  }
}
lib/google-calendar-sync.ts
async function saveNow(edit: GoogleEdit, timeZone: string) {
  try {
    if (edit.type === "event") await saveEvent(edit.change, timeZone);
    else await saveSeries(edit, timeZone);
  } finally {
    // Picks up what Google did, such as the newer version a 412 refused to overwrite.
    await syncNow().catch((error: unknown) => console.warn("Google Calendar sync failed", error));
    changed();
  }
}

/** A new event, or a change to a one-off event. */
async function saveEvent(
  change: CalendarEventChange<GoogleCalendarEvent, string, string>,
  timeZone: string,
) {
  if (change.cause === "create") {
    const times = toGoogleTimes(change.proposed, timeZone);
    await insert({ id: newGoogleId(), summary: "New event", ...times });
    return;
  }
  // The etag of the version on screen: if someone changed it since, Google answers 412.
  const { data } = change.event;
  if (change.cause === "delete") {
    await remove(data, data.etag);
    return;
  }
  const times = toGoogleTimes(change.proposed, data.start?.timeZone ?? timeZone);
  apply([await api.patch(data.id, times, data.etag)]);
}

/** Inserts an event with the ID it names. A 409 means a try whose answer was lost got there. */
async function insert(event: GoogleEventWrite) {
  try {
    apply([await api.insert(event)]);
  } catch (error) {
    if (!isStatus(error, 409)) throw error;
  }
}

/** Deletes an event, which Google keeps as cancelled. One already gone is done. */
async function remove(event: GoogleEvent, etag: string | undefined) {
  if (event.status === "cancelled") return;
  try {
    await api.remove(event.id, etag);
  } catch (error) {
    if (!isStatus(error, 404, 410)) throw error;
  }
  // Google's answer to a delete has no body, so the cancelled one's etag isn't known.
  apply([{ ...event, etag: undefined, status: "cancelled" }]);
}
  • Optimistic, with rollback. The calendar shows the change as pending while onEventChange runs, and keeps it until the events change. If it throws, the calendar puts the event back and announces that the change wasn't saved. There's no copy of the events to restore by hand.
  • Etags. Every Google event has an etag that changes on every write. Writes send the etag of the version on screen as If-Match. A change to a recurring event syncs first, and stops with a 412 if the series changed in Google since the user saw it: for "This event", the occurrence itself. The instances it then rewrites and clears go without an etag.
  • 412 Precondition Failed. Someone changed the event since it loaded. The write is refused, the sync after it brings in their version, and the drag rolls back onto it. The footer says what happened. To try it: turn off push notifications, move "Design review" with the button, then drag it. You could also retry with the new etag when their change didn't touch the times.
  • Rate limits. The client's backoff waits it out while the change stays pending. Rate-limit two requests in the demo, then drag an event.
  • Already deleted. Deleting an event someone else deleted gets a 410, which counts as done. So is deleting an occurrence someone cancelled, with "This event". Moving one throws a 410 instead, or a 412 for "This and following events" and "All events", so the change rolls back rather than bring the occurrence back.
  • New events get an ID from newGoogleId. Google takes IDs you pick, so you know an event's ID before it's saved, as you do for "This and following events". If an insert gets a 5xx after Google stored it, the retry gets a 409, and the event is there.

The footer under the calendar says what happened to the last change, in words:

components/google-calendar-view.tsx
/** Why Google refused a request, for the user. */
function reasonOf(error: unknown): string {
  if (!(error instanceof GoogleApiError)) return error instanceof Error ? error.message : "";
  if (error.status === 412) return "it changed in Google Calendar. This is the latest.";
  if (error.status === 410) return "it was deleted in Google Calendar.";
  const rateLimited = ["rateLimitExceeded", "userRateLimitExceeded"].includes(error.reason ?? "");
  if (error.status === 429 || rateLimited) return "Google is busy. Try again in a minute.";
  return error.message;
}

This, following and all events

A series edit from resolveRecurrenceEdit says what to write: the master, a new series, the edited occurrence and the series' other changed occurrences. In Google, those are these calls:

ScopeThe editThe Calendar API
This eventoverrideevents.instances with originalStart returns the occurrence's instance, with its ID and etag. Write it with events.update, and Google keeps it as a changed occurrence of the series.
This event, deletedseries with a new exception dateDelete the instance. Google keeps it as cancelled, and the master stays as it was.
This and following eventsnewSeries, and series, which ends the ruleInsert the new series, then patch the master's recurrence with UNTIL or a smaller COUNT. That's Google's own split: two events, with no link between them. If the patch fails, the store deletes the new series again.
All eventsseriesPatch the master's start, end and recurrence.
All events, deletedseries: "delete"Delete the master. Google deletes its changed and cancelled occurrences with it.
lib/google-calendar-sync.ts
async function saveSeries(
  edit: RecurrenceSeriesEdit<GoogleCalendarEvent, string, string>,
  timeZone: string,
) {
  const { newSeries, override, series, seriesId } = edit;
  const zoneOf = (event: GoogleEvent) => event.start?.timeZone ?? timeZone;
  // Google deletes a series' changed and cancelled occurrences with it.
  if (series.action === "delete") {
    await remove(series.event.data, series.event.data.etag);
    return;
  }
  // The writes below replace instances the store read, some without etags. So sync first, and
  // stop if the series changed in Google since the user saw it: the master and the changed
  // occurrences, or for "this", the occurrence's own instance. The edit holds what they saw of
  // the master and the edited occurrence; the store may have newer versions already.
  const only = edit.scope === "this" ? edit.recurrenceId : undefined;
  const edited = override?.action === "create" ? undefined : override?.event;
  const before = versions(seriesId, only);
  if (only !== undefined) before.clear();
  // The edited occurrence as the user saw it: its exception, or none.
  else for (const id of versions(seriesId, edit.recurrenceId).keys()) before.delete(id);
  if (series.action === "update" && only === undefined) {
    before.set(seriesId, series.event.data.etag);
  }
  if (edited !== undefined) before.set(edited.data.id, edited.data.etag);
  await syncNow();
  const after = versions(seriesId, only);
  if (only !== undefined) {
    // Cancelled in Google meanwhile: a delete is done, and a move would bring it back.
    const now = events.find(
      (event) => event.override?.seriesId === seriesId && after.has(event.data.id),
    );
    if (now?.override?.cancelled === true && before.get(now.data.id) !== now.data.etag) {
      if (edit.cause === "delete") return;
      throw new GoogleApiError(410, "deleted", "The occurrence was deleted in Google Calendar.");
    }
  }
  const changedInGoogle = [...after].some(([id, etag]) => {
    if (!before.has(id)) return true;
    // Undefined: cancelled here, and Google's answer to a delete has no etag.
    const seen = before.get(id);
    return seen !== undefined && seen !== etag;
  });
  if (changedInGoogle || (before.has(seriesId) && !after.has(seriesId))) {
    throw new GoogleApiError(412, "conditionNotMet", "The series changed in Google Calendar.");
  }

  // "This event" deletes cancel the occurrence's instance, as Google does, and leave the master.
  if (edit.scope === "this" && edit.cause === "delete") {
    const write: OccurrenceWrite = {
      from: edited,
      placement: "cancel",
      recurrenceId: edit.recurrenceId,
      seriesId,
    };
    await writeOccurrences([write], [], timeZone);
    return;
  }
  // The new `recurrence` is written from what `fromGoogleEvents` read, which would drop the
  // lines it couldn't, such as EXRULE.
  const master = series.action === "update" ? series.event.data : undefined;
  if (master !== undefined && readGoogleRecurrence(master.recurrence ?? []).problems.length > 0) {
    throw new Error("This series has rules the app can't write back. Change it in Google.");
  }
  // The series' other changed and cancelled occurrences, which the edit moves or ends.
  const others = events
    .filter((event) => event.override?.seriesId === seriesId && event.data.id !== edited?.data.id)
    .map((event) => ({ event, next: recurrenceOverrideEdit(edit, event.override!.recurrenceId) }))
    .filter(({ next }) => next.action !== "keep");

  // "This and following events" is Google's split: a new series from the occurrence on, and the
  // old one ended before it. The new one goes first, so a failure leaves the old one whole.
  const newSeriesId = newSeries?.id ?? newGoogleId();
  if (newSeries !== undefined) {
    const zone = zoneOf(newSeries.from.data);
    await insert({
      ...contentOf(newSeries.from.data),
      id: newSeriesId,
      ...toGoogleTimes(newSeries.placement, zone),
      recurrence: toGoogleRecurrence(newSeries.recurrence, zone),
    });
  }
  if (series.action === "update") {
    const zone = zoneOf(series.event.data);
    const fields = {
      ...toGoogleTimes(series.placement, zone),
      recurrence: toGoogleRecurrence(series.recurrence, zone),
    };
    try {
      apply([await api.patch(seriesId, fields, series.event.data.etag)]);
    } catch (error) {
      // With the old series still whole, the new one would repeat it: take it back.
      if (newSeries !== undefined) {
        await remove({ id: newSeriesId }, undefined).catch(() => undefined);
      }
      throw error;
    }
  }

  // The edited occurrence, and the others, which follow the series' new times or end with it.
  const writes: OccurrenceWrite[] = [];
  const left: GoogleCalendarEvent[] = [];
  if (override?.action === "create") {
    writes.push({ placement: override.placement, recurrenceId: override.recurrenceId, seriesId });
  } else if (override?.action === "update") {
    writes.push({
      from: override.event,
      placement: override.cancelled ? "cancel" : override.placement,
      recurrenceId: override.recurrenceId,
      seriesId: override.series === "new" ? newSeriesId : seriesId,
    });
  } else if (override?.action === "delete") {
    left.push(override.event);
  }
  for (const { event, next } of others) {
    if (next.action === "delete") left.push(event);
    if (next.action !== "update") continue;
    writes.push({
      from: event,
      placement: event.override!.cancelled ? "cancel" : event,
      recurrenceId: next.recurrenceId,
      seriesId: next.series === "new" ? newSeriesId : seriesId,
    });
  }
  await writeOccurrences(writes, left, timeZone);
}

/**
 * Writes each occurrence to the instance at its series and original start, then clears the
 * instances the moved ones leave. Google names an exception by its series and original start,
 * and neither can change, so an occurrence that moves to another original start is written to
 * the instance there. One may land where another leaves, so every write comes first.
 */
async function writeOccurrences(
  writes: readonly OccurrenceWrite[],
  left: readonly GoogleCalendarEvent[],
  timeZone: string,
) {
  const written = new Set<string>();
  const vacated = [...left];
  for (const { from, placement, recurrenceId, seriesId } of writes) {
    written.add(keyOf(seriesId, recurrenceId));
    const stays =
      from?.override !== undefined &&
      keyOf(from.override.seriesId, from.override.recurrenceId) === keyOf(seriesId, recurrenceId);
    // The etag of the version on screen, or of the instance just read.
    let instance: GoogleEvent;
    try {
      instance = stays ? from.data : await api.instance(seriesId, recurrenceId.toString());
    } catch (error) {
      // An occurrence to cancel whose series is gone is gone too.
      if (placement === "cancel" && !stored.has(seriesId) && isStatus(error, 404, 410)) continue;
      throw error;
    }
    // Cancelled in Google since it loaded: a change to it would bring it back.
    if (from === undefined && instance.status === "cancelled" && placement !== "cancel") {
      throw new GoogleApiError(410, "deleted", "The occurrence was deleted in Google Calendar.");
    }
    if (placement === "cancel") await remove(instance, instance.etag);
    // The moved occurrence's own title, color and the rest, not the instance's.
    else await replace(instance, from?.data ?? instance, placement, instance.etag, timeZone);
    if (from !== undefined && !stays) vacated.push(from);
  }
  for (const { data, override } of vacated) {
    if (override === undefined || written.has(keyOf(override.seriesId, override.recurrenceId))) {
      continue;
    }
    // No etags: the edit replaces these, whatever changed on them since.
    const master = stored.get(override.seriesId);
    const occurrence = master && occurrenceAt(master, override.recurrenceId);
    // The series' rule still has an occurrence there, so it shows as the series has it.
    if (master !== undefined && occurrence !== undefined) {
      await replace(data, master, occurrence, undefined, timeZone);
    } else {
      await remove(data, undefined);
    }
  }
}

/**
 * Replaces `instance` with `content` at `interval`: all of it, with `events.update`, so nothing
 * of what was there before stays.
 */
async function replace(
  instance: GoogleEvent,
  content: GoogleEvent,
  interval: CalendarEventInterval,
  etag: string | undefined,
  timeZone: string,
) {
  const fields: GoogleEventWrite = {
    ...contentOf(content),
    ...toGoogleTimes(interval, content.start?.timeZone ?? timeZone),
    originalStartTime: instance.originalStartTime,
    recurringEventId: instance.recurringEventId,
    // A cancelled instance comes back.
    status: content.status === "tentative" ? "tentative" : "confirmed",
  };
  try {
    apply([await api.update(instance.id, fields, etag)]);
  } catch (error) {
    // An instance the edit clears may be gone already.
    if (etag !== undefined || !isStatus(error, 404, 410)) throw error;
  }
}
  • Changed occurrences can't move. Google names an occurrence by its series and original start, and neither can change. When "All events" moves a series, a changed occurrence keeps its own time and content, but its original start moves with the series. So the store writes it to the instance at the new original start, with events.update, which replaces the whole instance: nothing of what was there stays. Then it clears the instance it left: back to the series' occurrence if the new rule still has one there, else cancelled. Every write comes before the clearing, since one occurrence may land where another leaves.
  • "This event" deletes cancel the instance instead of adding an EXDATE line. That's what Google does, and it leaves the master as it was.
  • Lines it can't write. fromGoogleEvents doesn't read EXRULE or RDATE periods, so writing a master back would drop them. The store refuses series changes to such a master, and the change rolls back.
  • What a copy keeps. A new series, and an occurrence written to another instance, copy everything but the fields Google sets and the ones that name the event.
  • Several requests. A series edit is a few writes in a row. If one fails partway, the writes before it stay in Google, the sync after it shows them, and the dragged occurrence rolls back onto what Google has.

Limits

  • Changed occurrences out of view. The store knows the changed occurrences from the first date on screen on, and in the earlier ranges it has loaded. A series edit doesn't move the ones before those. An occurrence moved to a date more than a day before what the store has loaded shows at its original time until the date it moved to loads.
  • Everything ahead. The first list returns every event from the first date on screen on. See Load the dates on screen for calendars with years of them.

Put it together

The page's calendar creates one sync store for as long as it's mounted, with the client pointed at your route. Choose the time zone on the server, as the Next.js guide says:

components/google-calendar-view.tsx
/** The calendar a page renders, with one sync store for as long as it's mounted. */
export function SyncedCalendar({ timeZone }: { readonly timeZone: string }) {
  // The browser calls your route, which adds the user's access token.
  const [sync] = useState(() =>
    createGoogleCalendarSync(createGoogleCalendarApi({ baseUrl: "/api/google-calendar" })),
  );
  return <GoogleCalendar sync={sync} timeZone={timeZone} />;
}

Sync on the server

This guide syncs in the browser, which suits a calendar the user looks at. When your server needs the events too, for reminders, booking pages or other people's availability, run the same sync there into your database, with a token per calendar, and let push notifications start it. The browser then loads the dates on screen from your own API, with the same onVisibleRangeChange, as Load events with TanStack Query does.

Edit on GitHub

Last updated on

On this page