Google Calendar events
Read recurring events from the Google Calendar API, with their RRULE, EXDATE and RDATE lines, and show and edit them.
The Google Calendar API stores a repeating event the way iCalendar does. events.list, without
singleEvents=true, returns each series once:
- The master, at the series' first occurrence, with its rule and exceptions as iCalendar lines
in
recurrence, and the zone it repeats in asstart.timeZone. - Changed occurrences: events with the master's ID in
recurringEventId, and the start the series gave them inoriginalStartTime. - Deleted occurrences: the same, with
status: "cancelled"and no times of their own.
[
{
"id": "standup",
"summary": "Stand-up",
"start": { "dateTime": "2026-10-19T09:00:00+02:00", "timeZone": "Europe/Berlin" },
"end": { "dateTime": "2026-10-19T09:30:00+02:00", "timeZone": "Europe/Berlin" },
"recurrence": ["RRULE:FREQ=DAILY;COUNT=5", "EXDATE;TZID=Europe/Berlin:20261021T090000"]
},
{
"id": "standup_20261020T070000Z",
"recurringEventId": "standup",
"originalStartTime": { "dateTime": "2026-10-20T09:00:00+02:00", "timeZone": "Europe/Berlin" },
"summary": "Stand-up (late)",
"start": { "dateTime": "2026-10-20T11:00:00", "timeZone": "Europe/Berlin" },
"end": { "dateTime": "2026-10-20T11:30:00", "timeZone": "Europe/Berlin" }
},
{
"id": "standup_20261022T070000Z",
"recurringEventId": "standup",
"originalStartTime": { "dateTime": "2026-10-22T09:00:00+02:00", "timeZone": "Europe/Berlin" },
"status": "cancelled"
}
]That's a master, overrides and cancelled overrides, as in
Recurring events. This page maps them to RecurringCalendarEvents,
which the calendar from Show and edit recurring events expands and
edits. Here, the stand-up shows on Monday at 09:00, Tuesday at 11:00 and Friday at 09:00.
With singleEvents=true, Google expands series itself, between timeMin and timeMax, and
leaves the masters out. Each occurrence is then an event of its own. It names its series in
recurringEventId, but not the series' rule, which "This and following events" and "All events"
change. Sync with Google Calendar
compares the two.
The event type
The mapping reads a few fields of Google's
Event resource, and
keeps the whole Google event as each calendar event's data:
// lib/google-calendar.ts
import type { CalendarEventInterval } from "@moltenship/calendar";
import {
formatRecurrenceRule,
parseRecurrenceRule,
type EventRecurrence,
type RecurrenceDate,
type RecurrenceRule,
type RecurringCalendarEvent,
} from "@moltenship/calendar-recurrence";
import {
decodeIcsDateValue,
encodeIcsValue,
icsValueTypeOf,
parseIcsComponents,
splitIcsValues,
type IcsDateValue,
} from "@moltenship/ics";
/** The fields of a Google Calendar API event that this mapping and the sync guide read. */
export interface GoogleEvent {
readonly id: string;
/** Changes on every write. Send it as `If-Match`, so a write never overwrites a newer one. */
readonly etag?: string;
readonly status?: "confirmed" | "tentative" | "cancelled";
readonly summary?: string;
/** An event color from `colors.get`, "1" to "11". Without it, the calendar's color. */
readonly colorId?: string;
/** "transparent" shows the time as free. "opaque", the default, as busy. */
readonly transparency?: "opaque" | "transparent";
readonly start?: GoogleEventTime;
readonly end?: GoogleEventTime;
/** On a series' master: RRULE, EXRULE, RDATE and EXDATE lines. */
readonly recurrence?: readonly string[];
/** On a changed or cancelled occurrence: the master's ID. */
readonly recurringEventId?: string;
/** On a changed or cancelled occurrence: where the series put it. */
readonly originalStartTime?: GoogleEventTime;
}
export interface GoogleEventTime {
/** An all-day event's date, such as "2026-10-12". */
readonly date?: string;
/** RFC 3339. The offset may be left out when `timeZone` is set. */
readonly dateTime?: string;
/** The IANA time zone a series repeats in. */
readonly timeZone?: string;
}
/** A Google event as the calendar's event, with the Google event as its `data`. */
export type GoogleCalendarEvent = RecurringCalendarEvent<GoogleEvent>;Read the recurrence lines
parseRecurrenceRule reads an RRULE: line as Google writes it. EXDATE and RDATE lines need their
parameters: TZID names the zone of their wall times, and VALUE=DATE makes them dates. The
syntax layer of @moltenship/ics reads those lines, and its value decoders read each date:
/** A master's `recurrence` lines as an `EventRecurrence`, and the lines it couldn't read. */
export function readGoogleRecurrence(lines: readonly string[]): {
readonly problems: readonly string[];
readonly recurrence: EventRecurrence;
} {
const problems: string[] = [];
const exceptionDates: RecurrenceDate[] = [];
const recurrenceDates: RecurrenceDate[] = [];
let rule: RecurrenceRule | undefined;
// The ICS syntax layer splits content lines into names, parameters and values.
const { components } = parseIcsComponents(["BEGIN:VEVENT", ...lines, "END:VEVENT"].join("\r\n"));
for (const property of components[0]?.properties ?? []) {
if (property.name === "RRULE") {
const parsed = parseRecurrenceRule(property.value);
rule = parsed.rule;
problems.push(...parsed.issues.map((issue) => `RRULE: ${issue.message}`));
continue;
}
// EXRULE, which RFC 5545 deprecated, and anything else.
if (property.name !== "EXDATE" && property.name !== "RDATE") {
problems.push(`${property.name} isn't supported`);
continue;
}
// A DATE-TIME unless VALUE says otherwise, as in EXDATE;VALUE=DATE:20261014.
const type = icsValueTypeOf(property);
if (type !== "DATE" && type !== "DATE-TIME") {
problems.push(`${property.name};VALUE=${type} isn't read`);
continue;
}
const tzid = property.parameters.TZID?.[0];
const dates = property.name === "EXDATE" ? exceptionDates : recurrenceDates;
for (const raw of splitIcsValues(type, property.value)) {
const decoded = decodeIcsDateValue(raw, type, tzid === undefined ? {} : { tzid });
const date = decoded.ok ? recurrenceDateOf(decoded.value) : undefined;
if (date === undefined) problems.push(`${property.name}: can't read ${raw}`);
else dates.push(date);
}
}
return { problems, recurrence: { exceptionDates, recurrenceDates, rule } };
}
/** An ICS date in the series' terms. The expander converts dates of another kind. */
function recurrenceDateOf(value: IcsDateValue): RecurrenceDate | undefined {
switch (value.kind) {
case "date":
return value.date;
case "utc":
return value.instant;
case "zoned":
return value.zoned.toInstant();
case "floating":
return value.wallTime;
case "unresolved":
return undefined;
}
}parseIcsComponentssplits each line into its name, parameters and raw value. It reads content lines inside a component, so the lines are wrapped in aVEVENT.icsValueTypeOfgives the line's value type: itsVALUEparameter, else DATE-TIME for EXDATE and RDATE.splitIcsValuessplits a line that lists several dates, anddecodeIcsDateValuereads each one, in the zoneTZIDnames.- Dates of another kind are fine. A UTC EXDATE on a series in Berlin, or a date-only one that excludes a whole day, converts when the series is expanded.
- What it leaves out goes to
problems: EXRULE, which RFC 5545 deprecated, RDATE periods, and dates it can't read. The rest of the series still shows.
Map the events
Masters get their recurrence, changed and cancelled occurrences become overrides, and everything else is a one-off event:
/**
* Events from `events.list` without `singleEvents`: masters with `recurrence`, occurrences that
* changed or were cancelled with `recurringEventId`, and one-off events.
*/
export function fromGoogleEvents(items: readonly GoogleEvent[]): {
readonly events: readonly GoogleCalendarEvent[];
readonly problems: readonly string[];
} {
const events: GoogleCalendarEvent[] = [];
const problems: string[] = [];
for (const item of items) {
const base = { data: item, id: item.id, title: item.summary ?? "(No title)" };
if (item.recurringEventId !== undefined) {
// An occurrence that changed, or was cancelled, which Google returns without times.
const recurrenceId = item.originalStartTime && pointOf(item.originalStartTime);
const interval = intervalOf(item.start, item.end) ?? recurrenceId?.interval;
if (recurrenceId === undefined || interval === undefined) {
problems.push(`${item.id}: no original start`);
continue;
}
const cancelled = item.status === "cancelled";
events.push({
...base,
...interval,
override: { cancelled, recurrenceId: recurrenceId.date, seriesId: item.recurringEventId },
});
continue;
}
if (item.status === "cancelled") continue;
const interval = intervalOf(item.start, item.end);
if (interval === undefined) {
problems.push(`${item.id}: no start or end`);
continue;
}
if (item.recurrence === undefined) {
events.push({ ...base, ...interval });
continue;
}
const { problems: lines, recurrence } = readGoogleRecurrence(item.recurrence);
problems.push(...lines.map((problem) => `${item.id}: ${problem}`));
events.push({ ...base, ...interval, recurrence });
}
return { events, problems };
}
function intervalOf(
start: GoogleEventTime | undefined,
end: GoogleEventTime | undefined,
): CalendarEventInterval | undefined {
if (start?.date !== undefined && end?.date !== undefined) {
const [from, to] = [Temporal.PlainDate.from(start.date), Temporal.PlainDate.from(end.date)];
return { end: to, kind: "all-day", start: from };
}
if (start?.dateTime === undefined || end?.dateTime === undefined) return undefined;
const timeZone = start.timeZone;
return {
end: instantOf(end.dateTime, end.timeZone ?? timeZone),
kind: "timed",
start: instantOf(start.dateTime, timeZone),
// A series repeats on this zone's wall clock, so 09:00 stays 09:00 across DST.
...(timeZone === undefined ? {} : { sourceTimeZone: timeZone }),
};
}
/** `originalStartTime` as a recurrence ID, and an interval there for a cancelled occurrence. */
function pointOf(time: GoogleEventTime) {
const interval = intervalOf(time, time);
return interval && { date: interval.start, interval };
}
function instantOf(dateTime: string, timeZone: string | undefined): Temporal.Instant {
// "2026-10-12T09:00:00+02:00" has its offset; "2026-10-12T09:00:00" is a wall time in timeZone.
return /(?:Z|[+-]\d{2}:\d{2})$/i.test(dateTime)
? Temporal.Instant.from(dateTime)
: Temporal.PlainDateTime.from(dateTime)
.toZonedDateTime(timeZone ?? "UTC")
.toInstant();
}- Times.
start.dateTimeis RFC 3339, and may leave out its offset whentimeZoneis set, soinstantOfreads it in that zone.start.datemakes an all-day event, whoseend.dateis exclusive, as the calendar's is. - The zone a series repeats in is
start.timeZone, which Google requires on recurring events. It becomessourceTimeZone, so a 09:00 meeting stays at 09:00 across daylight saving time. - Overrides keep Google's instance IDs as their own. The calendar shows them under the ID of
the occurrence they replace, such as
standup::2026-10-20T07:00:00Z, before and after their master loads. - Cancelled occurrences have no times, so they're placed at their original start. A cancelled override only hides its occurrence and never shows, so its length doesn't matter.
Give the events to the calendar with the canonical adapters, as the guide does:
import {
createCanonicalRecurrenceAdapter,
createOccurrenceExpander,
} from "@moltenship/calendar-recurrence";
const adapters = createCanonicalRecurrenceAdapter<GoogleEvent>();
const expander = createOccurrenceExpander(adapters);
const { events, problems } = fromGoogleEvents(response.items);Google returns an event list a page at a time, with nextPageToken. Read every page before you
expand: a master on one page and its overrides on another are one series.
Write changes back
An edit from resolveRecurrenceEdit writes the master's whole recurrence. Google stores it as the
master's recurrence lines, so write them back in Google's format:
/**
* A series' recurrence as Google's `recurrence` lines, such as the one an edit writes. Instants
* are written on the series' wall clock when it has a time zone.
*/
export function toGoogleRecurrence(
recurrence: EventRecurrence,
timeZone: string | undefined,
): string[] {
const lines =
recurrence.rule === undefined ? [] : [`RRULE:${formatRecurrenceRule(recurrence.rule)}`];
for (const date of recurrence.exceptionDates ?? [])
lines.push(`EXDATE${dateText(date, timeZone)}`);
for (const date of recurrence.recurrenceDates ?? []) {
// This mapping reads no RDATE periods, so edits write none.
if ("start" in date) continue;
lines.push(`RDATE${dateText(date, timeZone)}`);
}
return lines;
}
function dateText(date: RecurrenceDate, timeZone: string | undefined): string {
if (date instanceof Temporal.PlainDate) {
return `;VALUE=DATE:${encodeIcsValue("DATE", { date, kind: "date" })}`;
}
if (date instanceof Temporal.PlainDateTime) {
return `:${encodeIcsValue("DATE-TIME", { kind: "floating", wallTime: date })}`;
}
if (timeZone === undefined)
return `:${encodeIcsValue("DATE-TIME", { instant: date, kind: "utc" })}`;
const wallTime = date.toZonedDateTimeISO(timeZone).toPlainDateTime();
return `;TZID=${timeZone}:${encodeIcsValue("DATE-TIME", { kind: "floating", wallTime })}`;
}formatRecurrenceRule writes the rule's value without the RRULE: prefix, so the line adds it.
Exception dates are written on the series' wall clock, with its TZID, as Google writes them.
Apply the edit to your copy of the events with applyRecurrenceEdit, as the guide does, and send
its writes to Google:
| The edit says | In the Calendar API |
|---|---|
series.action: "update" | Patch the master: start and end from placement, recurrence from toGoogleRecurrence. |
series.action: "delete" | Delete the master, which deletes the whole series. |
newSeries | Insert a new event, copied from the master, with its own recurrence. |
override ("create" or "update") | Patch the occurrence: the instance whose originalStartTime is the edit's recurrenceId. |
override ("delete") | Delete that instance. |
After a write that changes the master, list the series again, so your copy matches what Google stored for its other occurrences. Sync with Google Calendar does this with sync tokens, and writes with etags.
A "This event" delete adds an EXDATE line to the master, which Google reads like any other
recurrence line. Google records a deleted occurrence as a cancelled instance instead;
fromGoogleEvents reads both.
Last updated on