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.
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.
| File | What it does |
|---|---|
lib/google-calendar-api.ts | A small client for the API's events, over fetch |
app/api/google-calendar/[...path]/route.ts | Sends the browser's requests to Google with the user's token |
lib/google-calendar.ts | Maps Google events to calendar events, from Google Calendar events |
lib/google-calendar-sync.ts | The events loaded so far: loads, syncs and writes |
components/google-calendar-view.tsx | The 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
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,
baseUrlis Google's andheadersadds the access token. In the browser,baseUrlis your route, below, and the cookie your auth library set signs the request. The rest of the guide runs in the browser. - Pages.
listfollowsnextPageTokenuntil the last page, which hasnextSyncTokeninstead. Google may send fewer events thanmaxResults, or none, and still have more. - Rate limits. A 429, a 403 with
rateLimitExceededoruserRateLimitExceeded, and a 5xx are retried after 1, 2, 4… seconds, up to 32, plus a random part so that clients don't retry together. ARetry-Afterheader wins. Other errors throw aGoogleApiErrorat 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 becomeGoogleApiErrors with the status and reason, which the store reads. Plainfetchalso 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
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-Afterand 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:
| Calendar | |
|---|---|
start.dateTime, end.dateTime | A 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.date | An 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, originalStartTime | A series' master, and its changed and cancelled occurrences. See Series. |
colorId | One 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:
"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.
isBusyis the one place the calendar asks whether an event takes up time:busyAt,useBusyResourcesandconflictPolicyall 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 withtransparency, 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
colorIdis a key intocolors.get, "1" (Lavender) to "11" (Tomato). The map picks the theme's nearest named color. An event withoutcolorIdhas its calendar's color, whichcalendarList.getreturns. - Imports. The view imports the
lib/files through@/lib/…, the alias Next.js apps start with.lib/google-calendar-sync.tsimports its neighbors with./.
Changes go back the other way. toGoogleTimes writes an interval as Google's start and end:
/**
* 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 betweentimeMinandtimeMaxand leaves the master out. Each occurrence is an event of its own, with its series inrecurringEventId, 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-recurrenceexpands 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, andresolveRecurrenceEditworks them out from it. WithsingleEvents=trueyou 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:
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
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:
/**
* 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.
timeMinbounds an event's end andtimeMaxits 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.
getEventsreturns 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:
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 returns | The store |
|---|---|
| An event | Replaces the one with its ID, or adds it |
| A cancelled event or master | Removes 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 astimeMin,timeMax,q,orderByandupdatedMinwith 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:
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, inX-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 withchannels.stop. The first message on a channel sayssync, 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:
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));
}
}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
onEventChangeruns, 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
etagthat changes on every write. Writes send the etag of the version on screen asIf-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:
/** 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:
| Scope | The edit | The Calendar API |
|---|---|---|
| This event | override | events.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, deleted | series with a new exception date | Delete the instance. Google keeps it as cancelled, and the master stays as it was. |
| This and following events | newSeries, and series, which ends the rule | Insert 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 events | series | Patch the master's start, end and recurrence. |
| All events, deleted | series: "delete" | Delete the master. Google deletes its changed and cancelled occurrences with it. |
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.
fromGoogleEventsdoesn'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:
/** 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.
Last updated on