Show and edit recurring events
Expand series for the dates on screen, and ask which events a change applies to.
These events repeat: a stand-up every weekday, with Wednesday's skipped and Thursday's moved an hour later, a 1:1 every other week, planning on eight Mondays, and a release day every Friday. Drag an occurrence, or select one and delete it, and the calendar asks whether the change applies to this event, this and following events, or all events. The change waits on screen while it asks.
This guide builds that calendar with @moltenship/calendar-recurrence. It expands each series into
its occurrences for the dates on screen, and turns a change to one occurrence into what to save.
Recurring events explains the model behind it.
Install
The recurrence package is separate from the calendar. It doesn't use React, so you can also expand series on a server.
Like the calendar, @moltenship/calendar-recurrence is on npm as a release candidate for 1.0.0.
pnpm add @moltenship/calendar-recurrenceThe dialog that asks for the scope is UI source, like the views. Add it with the shadcn CLI, as
described in UI source. It brings shadcn's button and
dialog:
npx shadcn@latest add https://time.molten.sh/r/recurrence-scope-dialog.jsonWire the adapters
The calendar shows occurrences, not your events. A RecurrenceOccurrence is one occurrence of a
series, or a one-off event passed through, and createOccurrenceAdapter turns your event adapter
into the calendar's adapter for them. A recurrence adapter beside it reads what makes an event a
series' master, or an override of one occurrence. Together, they're your RecurrenceAdapters.
An app without an event type of its own can store RecurringCalendarEvents: core's
CalendarEvent with an optional recurrence
and override. createCanonicalRecurrenceAdapter returns both adapters for them:
"use client";
import { createCalendar } from "@moltenship/calendar";
import {
createCanonicalRecurrenceAdapter,
createOccurrenceAdapter,
type RecurrenceOccurrence,
type RecurringCalendarEvent,
} from "@moltenship/calendar-recurrence";
import type { CalendarEventColor } from "@/components/calendar/event-colors";
import { createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";
export interface TeamEventData {
readonly color: CalendarEventColor;
}
/**
* A series' master has a `recurrence`, an event that replaces one of its occurrences has an
* `override`, and any other event happens once.
*/
export type TeamEvent = RecurringCalendarEvent<TeamEventData>;
/** What the calendar shows: one occurrence of a series, or a one-off event. */
export type TeamOccurrence = RecurrenceOccurrence<TeamEvent, string>;
export const adapters = createCanonicalRecurrenceAdapter<TeamEventData>();
/** Lucide's "repeat" icon, for the chips of repeating events. */
const repeatIcon = (
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="m17 2 4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="m7 22-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
</svg>
);
export const Calendar = createCalendar<TeamOccurrence>()({
// Occurrences are the calendar's events. It reads their titles through your adapter.
eventAdapter: createOccurrenceAdapter(adapters.event),
views: [
createWeekView<TeamOccurrence>({
// The icon marks a repeating event on screen, and the description says so to screen readers.
describeEvent: (occurrence) => (occurrence.recurrence ? "repeating event" : undefined),
getEventColor: (occurrence) => occurrence.event.data.color,
getEventIcon: (occurrence) => (occurrence.recurrence ? repeatIcon : null),
}),
],
});Views receive occurrences, so getEventColor reads the color from occurrence.event, the event
the occurrence shows: the master, the override, or the one-off event. occurrence.recurrence is set
on occurrences of a series, so getEventIcon marks their chips with a repeat icon, and
describeEvent adds "repeating event" to their accessible names, since the icon is decorative.
Your own event type
With your own event type, write the recurrence adapter yourself. getRecurrence returns a master's
rule, exception dates and extra dates, and getOverride returns the series and original start an
override replaces:
import { createCalendar, defineEventAdapter } from "@moltenship/calendar";
import {
createOccurrenceAdapter,
parseRecurrenceRule,
type RecurrenceAdapters,
type RecurrenceOccurrence,
} from "@moltenship/calendar-recurrence";
export interface Meeting {
readonly id: string;
readonly title: string;
readonly start: Temporal.Instant;
readonly end: Temporal.Instant;
/** Where it was booked. A series repeats on this zone's wall clock. */
readonly timeZone: string;
/** On a series' first meeting: an RRULE, such as "FREQ=WEEKLY;BYDAY=MO,WE". */
readonly repeats?: string;
/** Starts of meetings the series skips. */
readonly skips?: readonly Temporal.Instant[];
/** On a meeting that replaces one of a series: the series, and the start it replaces. */
readonly replaces?: { readonly seriesId: string; readonly start: Temporal.Instant };
}
export const adapters: RecurrenceAdapters<Meeting, string, never> = {
event: defineEventAdapter<Meeting>()({
getId: (meeting) => meeting.id,
getTitle: (meeting) => meeting.title,
getInterval: (meeting) => ({
kind: "timed",
start: meeting.start,
end: meeting.end,
sourceTimeZone: meeting.timeZone,
}),
getResourceIds: () => [],
}),
recurrence: {
getRecurrence: (meeting) =>
meeting.repeats === undefined
? undefined
: { rule: parseRecurrenceRule(meeting.repeats).rule, exceptionDates: meeting.skips },
getOverride: (meeting) =>
meeting.replaces && {
seriesId: meeting.replaces.seriesId,
recurrenceId: meeting.replaces.start,
},
},
};
export const Calendar = createCalendar<RecurrenceOccurrence<Meeting, string>>()({
eventAdapter: createOccurrenceAdapter(adapters.event),
});- A master's
getIntervalis its first occurrence. A timed series repeats on the wall clock ofsourceTimeZone, so a 09:00 meeting stays at 09:00 across daylight saving time. parseRecurrenceRulereads RRULE text, with or without theRRULE:prefix. It never throws: check itsissueswhen you save the text, and show an error then.- The expander reads each event once while it's the same object, so parsing in the adapter is cheap. Replace events rather than changing them.
Expand the dates on screen
A series can be endless, so expand it only for the dates the calendar needs. The calendar reports
them through onVisibleRangeChange:
import type { CalendarVisibleRange, PlainDateInterval } from "@moltenship/calendar";
import { createOccurrenceExpander } from "@moltenship/calendar-recurrence";
import { useMemo, useState } from "react";
import { adapters, type TeamEvent } from "./recurrence-calendar";
/**
* The occurrences of `events` on the dates the calendar asks for. Pass `occurrences` to `events`
* on `Root`, and `onVisibleRangeChange` to the prop of that name.
*/
export function useOccurrences(
events: readonly TeamEvent[],
timeZone: string,
// The dates the calendar opens on, so its first render isn't empty.
initialRange: PlainDateInterval,
) {
// One expander for as long as the calendar is mounted: it keeps its work for series that
// didn't change, and returns the same occurrence objects for them.
const [expander] = useState(() => createOccurrenceExpander(adapters));
const [range, setRange] = useState(initialRange);
const expansion = useMemo(
() => expander.expand(events, { range, timeZone }),
[expander, events, range, timeZone],
);
return {
issues: expansion.issues,
occurrences: expansion.occurrences,
onVisibleRangeChange: ({ loadRange }: CalendarVisibleRange) => setRange(loadRange),
};
}- One expander. Create it once, in
useStateas here or at module scope. It keeps its work for each series between calls, and gives back the same occurrence objects for events that didn't change, so the calendar and its views skip them. useMemoover events and range. Expansion runs again only when your events or the dates change. A new array holding the same event objects is cheap: unchanged series reuse their work.loadRange, notrange. Scrolling views render a page of days on either side of the ones they show, so a swipe never reveals empty days.loadRangeincludes those pages;rangeis only what's visible. In paged views the two are the same.- The first render. The calendar reports its range after it first renders, so the hook starts
from the week it opens on, from
weekRange. Without a first guess, the first frame has no events. - Time zones.
timeZoneis the calendar's zone, which places the range's dates for timed series. Series don't repeat in it. They repeat in their ownsourceTimeZone, or, for events without one, in the expander'sdefaultTimeZone, which is UTC unless you set it.
expansion.issues lists what the expander couldn't read, such as an invalid rule or an override
whose series isn't loaded, with each event and its position. Bad data never throws: the rest of
the series and the other events still show. Log the issues, as you would
diagnostics.
Ask which events a change applies to
When someone drags an occurrence, the calendar calls onEventChange with a change whose event is
the occurrence. useRecurrenceScopePrompt from the dialog you added returns ask, which opens the
dialog and resolves with the scope picked, and dialog, to render once:
async function onEventChange(change: OccurrenceChange) {
// A new event, or a change to a one-off event, needs no question.
let scope: RecurrenceEditScope = "this";
if (isRecurringChange(change)) {
const scopes = recurrenceEditScopes(change, adapters, { timeZone });
// None applies, so there's nothing to ask: tell the user why instead. Throwing rolls the
// change back.
if (scopes.length === 0) throw new Error("This repeating event can't change that way.");
const picked = await ask({
action: change.cause === "delete" ? "delete" : "edit",
eventTitle: change.event.event.title,
scopes,
});
// An `AbortError` is announced as a cancel, not as a change that couldn't be saved.
if (picked === null) throw new DOMException("Cancelled", "AbortError");
scope = picked;
}
const edit = resolveRecurrenceEdit(change, scope, adapters, {
// Picked now, so the IDs of a "following" edit's new series are known.
newSeriesId: crypto.randomUUID(),
occurrences,
// The zone the calendar moves occurrences on, so a series dragged by days keeps its time.
timeZone,
});
if (edit.type === "rejected") throw new Error(edit.message);
// Save the edit on your server here. The change stays pending until this function returns.
if (edit.type === "event") {
setEvents((current) => applyEventChange(current, edit.change));
return;
}
setEvents((current) =>
applyRecurrenceEdit(current, edit, { createId: () => crypto.randomUUID() }),
);
// "following" and "all" give the occurrences new IDs. The selection and keyboard focus
// follow them, and the calendar moves focus to the occurrence under its new ID.
setSelection((current) => renamed(current, edit.occurrenceIds));
setFocus((current) => renamed(current, edit.occurrenceIds));
}isRecurringChangesays whether the change is to an occurrence of a series. A new event, or a change to a one-off event, needs no question.askopens the dialog.actionpicks its wording for an edit or a delete, andscopescomes fromrecurrenceEditScopes, which offers only the scopes that apply: no "This and following events" on a series' first occurrence, and only "This event" when the rule can't follow the change, such as the 28th of each month dragged to a 29th. When none applies, the list is empty and there's nothing to ask: tell the user why, and roll the change back.- Cancel resolves
null. Throwing rolls the change back, as any error inonEventChangedoes. Throw anAbortError, as the handler does, and the calendar's announcements say the change was cancelled rather than that it couldn't be saved. resolveRecurrenceEditturns the change into an edit: what to save for that scope. Pass the calendar'stimeZoneto it and torecurrenceEditScopes, so the change is replayed on the series' own clock: a series dragged by days keeps its time of day when the calendar's zone and the series' change DST on different dates, and one dragged an hour later moves an hour. If you keep deleted occurrences as cancelled overrides, as Google's API does, pass them asoverridestoo. A change that needs no scope comes back astype: "event", with your event unwrapped from the occurrence, to apply as you would without recurrence. A scope that can't apply comes back astype: "rejected", with areasonand amessage.- Save the edit, below. The change stays pending until
onEventChangereturns.
While the dialog is open, the change waits on screen as pending: the dragged occurrence shows where it was dropped. Only that occurrence shows the change. The calendar keeps pending changes per event and knows nothing about series, so for "This and following events" or "All events" the rest of the series moves when your events change, and the calendar keeps showing the change until then.
Deleting
The views don't delete events; your own UI does, with proposeEventChange. The change goes through
the same onEventChange, and ask uses the dialog's wording for a delete:
/** The selected occurrence's title, and a button that deletes it through `onEventChange`. */
function SelectedEvent({ occurrence }: { readonly occurrence: TeamOccurrence | undefined }) {
const { proposeEventChange } = Calendar.useCalendarActions();
return (
<div className="flex min-h-12 shrink-0 items-center gap-3 border-t px-4 py-2 text-sm">
{occurrence === undefined ? (
<span className="text-muted-foreground">Click an event to select it.</span>
) : (
<>
<span className="min-w-0 truncate font-medium">{occurrence.event.title}</span>
<Button
className="ms-auto"
onClick={() => void proposeEventChange({ cause: "delete", eventId: occurrence.id })}
size="sm"
variant="outline"
>
Delete
</Button>
</>
)}
</div>
);
}Save the edit
A series edit lists its writes as plain data, so you can send it to a server as it is or apply it
to your own store. If your events are RecurringCalendarEvents, applyRecurrenceEdit applies it
and returns the new array, as onEventChange above does. createId names the events it creates:
overrides, and the series a "This and following events" edit starts when you didn't pick its ID.
With your own event type or a database, write the same steps yourself:
| Field | What to write |
|---|---|
series | "update": the master at placement, its first occurrence, with recurrence in place of the stored one. "delete": remove the master. |
newSeries | For "This and following events": a new master, copied from from apart from its ID, at placement, with recurrence, and id if you gave one. |
override | The edited occurrence's override: "create" one from the master from, "update" the stored one, or "delete" it. |
otherOverrides | Call recurrenceOverrideEdit(edit, recurrenceId) for each override of seriesId you store, loaded or not, and keep, delete or rekey it. |
The edit describes placement and recurrence only. If the change also edited the event's title or other fields, copy them yourself. The reference describes each write.
A "This event" delete adds the occurrence's start to the master's exception dates, which every iCalendar store understands. Google's API records a deleted occurrence as a cancelled instance instead; this package reads both.
Keep the selection
An occurrence's ID is its series' ID and its original start, such as
standup::2026-10-14T07:30:00Z. It's stable across views, time zones and reloads, and when "This
event" moves the occurrence, since its override keeps the original start.
"This and following events" and "All events" change the original starts, and so the IDs.
edit.occurrenceIds maps old IDs to new ones, and the handler above moves the selection and the
keyboard focus with it: select an occurrence in the preview, drag it with "All events", and it
stays selected.
-
Control
focuswithonFocusChange, and map it beforeonEventChangereturns.askresolves once the dialog has closed and focus is back on the occurrence, and the calendar then moves focus to the occurrence under its new ID. Left alone, focus would name the old ID, which no longer shows. -
Pass
occurrences, the expansion you give the calendar, so the map covers every occurrence on screen, not only the dragged one. -
Pass
newSeriesIdfor "This and following events", so the map can name the new series' occurrences. Pick it before you save, as you would for an optimistic create. -
Map anything else that holds occurrence IDs the same way, such as an open details panel or a URL.
-
Read an occurrence's series from
occurrence.recurrence, which keeps the series ID's type. UseparseOccurrenceIdonly for IDs you stored.
Put it together
The whole calendar: the occurrences go to events, the handlers to Root, and the dialog renders
beside it. Clicks on events select them, through the data-event-id every view puts on its
events, and SelectedEvent is the component from Deleting.
"use client";
import {
eventIdOf,
weekRange,
type CalendarEventChange,
type CalendarFocus,
type CalendarSelection,
type EventId,
} from "@moltenship/calendar";
import {
applyRecurrenceEdit,
isRecurringChange,
recurrenceEditScopes,
resolveRecurrenceEdit,
type RecurrenceEditScope,
} from "@moltenship/calendar-recurrence";
import { useState, type MouseEvent } from "react";
import { calendarAnnouncements } from "@/components/calendar/announcements";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { useRecurrenceScopePrompt } from "@/components/calendar/recurrence-scope-dialog/use-recurrence-scope-prompt";
import { Button } from "@/components/ui/button";
import { adapters, Calendar, type TeamEvent, type TeamOccurrence } from "./recurrence-calendar";
import { useOccurrences } from "./use-occurrences";
type OccurrenceChange = CalendarEventChange<TeamOccurrence, string, string>;
/** A selection or focus on an occurrence, moved to the occurrence's new ID after an edit. */
function renamed<T extends CalendarSelection | CalendarFocus>(
current: T | null,
ids: ReadonlyMap<EventId, string>,
): T | null {
return current?.kind === "event"
? { ...current, eventId: ids.get(current.eventId) ?? current.eventId }
: current;
}
const weekRules = { firstDay: 1, minimalDays: 4, weekend: [6, 7] } as const;
interface TeamCalendarProps {
readonly initialEvents: readonly TeamEvent[];
readonly timeZone: string;
}
export function TeamCalendar({ initialEvents, timeZone }: TeamCalendarProps) {
const [today] = useState(() => Temporal.Now.plainDateISO(timeZone));
const [events, setEvents] = useState(initialEvents);
const [selection, setSelection] = useState<CalendarSelection | null>(null);
const [focus, setFocus] = useState<CalendarFocus | null>(null);
const { occurrences, onVisibleRangeChange } = useOccurrences(
events,
timeZone,
weekRange(today, { weekStartsOn: weekRules.firstDay }),
);
const { ask, dialog } = useRecurrenceScopePrompt();
async function onEventChange(change: OccurrenceChange) {
// A new event, or a change to a one-off event, needs no question.
let scope: RecurrenceEditScope = "this";
if (isRecurringChange(change)) {
const scopes = recurrenceEditScopes(change, adapters, { timeZone });
// None applies, so there's nothing to ask: tell the user why instead. Throwing rolls the
// change back.
if (scopes.length === 0) throw new Error("This repeating event can't change that way.");
const picked = await ask({
action: change.cause === "delete" ? "delete" : "edit",
eventTitle: change.event.event.title,
scopes,
});
// An `AbortError` is announced as a cancel, not as a change that couldn't be saved.
if (picked === null) throw new DOMException("Cancelled", "AbortError");
scope = picked;
}
const edit = resolveRecurrenceEdit(change, scope, adapters, {
// Picked now, so the IDs of a "following" edit's new series are known.
newSeriesId: crypto.randomUUID(),
occurrences,
// The zone the calendar moves occurrences on, so a series dragged by days keeps its time.
timeZone,
});
if (edit.type === "rejected") throw new Error(edit.message);
// Save the edit on your server here. The change stays pending until this function returns.
if (edit.type === "event") {
setEvents((current) => applyEventChange(current, edit.change));
return;
}
setEvents((current) =>
applyRecurrenceEdit(current, edit, { createId: () => crypto.randomUUID() }),
);
// "following" and "all" give the occurrences new IDs. The selection and keyboard focus
// follow them, and the calendar moves focus to the occurrence under its new ID.
setSelection((current) => renamed(current, edit.occurrenceIds));
setFocus((current) => renamed(current, edit.occurrenceIds));
}
// Apps open an event by delegating clicks on `[data-event-id]`.
function selectClicked(event: MouseEvent) {
const element = (event.target as Element).closest("[data-event-id]");
const eventId = element === null ? null : eventIdOf(element);
setSelection(eventId === null ? null : { eventId, kind: "event" });
}
const selected =
selection?.kind === "event"
? occurrences.find((occurrence) => occurrence.id === selection.eventId)
: undefined;
return (
<>
<Calendar.Root
events={occurrences}
focus={focus}
onFocusChange={setFocus}
onEventChange={onEventChange}
onVisibleRangeChange={onVisibleRangeChange}
selection={selection}
onSelectionChange={setSelection}
defaultAnchorDate={today}
defaultTimeZone={timeZone}
locale="en-US"
weekRules={weekRules}
>
<Calendar.Frame className="flex h-full flex-col">
<CalendarToolbar calendar={Calendar} className="border-b" />
<Calendar.Viewport className="min-h-0 flex-1" onClick={selectClicked}>
<Calendar.ActiveView />
</Calendar.Viewport>
<SelectedEvent occurrence={selected} />
<Calendar.Announcer messages={calendarAnnouncements} />
</Calendar.Frame>
</Calendar.Root>
{dialog}
</>
);
}
/** A change to a one-off event, or a new event, as any calendar applies it. */
function applyEventChange(
events: readonly TeamEvent[],
change: CalendarEventChange<TeamEvent, string, string>,
): readonly TeamEvent[] {
if (change.cause === "delete") return events.filter((event) => event.id !== change.eventId);
const { resourceIds: _resourceIds, ...placement } = change.proposed;
if (change.cause === "create") {
const id = crypto.randomUUID();
return [...events, { data: { color: "blue" }, id, title: "New event", ...placement }];
}
return events.map((event) =>
event.id === change.eventId
? { data: event.data, id: event.id, title: event.title, ...placement }
: event,
);
}Recipes
Last updated on