Recurrence
Expand recurring series for the dates on screen, and edit one occurrence, the following ones or all of them, with @moltenship/calendar-recurrence.
@moltenship/calendar-recurrence turns your recurring events into the occurrences the calendar
shows. A series is stored the way iCalendar, Google Calendar and Outlook store it: one master event
with a rule, plus events that replace single occurrences. The package expands masters for a range
of dates, and turns a change to one occurrence into what to save for "this event", "this and
following events" or "all events".
It's a separate package. @moltenship/calendar doesn't re-export it, and the calendar doesn't need
it: occurrences reach the calendar as ordinary events, through an adapter. It depends on
@moltenship/calendar-core, @moltenship/calendar-temporal and @moltenship/ics, and not on
React.
Recurring events explains the model, and Show and edit recurring events wires it into a calendar.
Install
Like the calendar, @moltenship/calendar-recurrence is on npm as a release candidate for 1.0.0.
pnpm add @moltenship/calendar-recurrenceIt needs the same setup as the calendar: a runtime with Temporal, or a polyfill loaded first,
and TypeScript's ESNext library. See Installation.
Series
A series is a master event, which sits at the series' first occurrence, and any number of override
events. The package reads both through a RecurrenceAdapter. Events with neither are
one-off events, and pass through.
Every date of a series has the kind of its first occurrence. That's a RecurrenceDate:
| The series is | Its dates are | Its occurrence IDs end in |
|---|---|---|
| Timed | Temporal.Instant | 2026-10-28T08:00:00Z |
| All-day | Temporal.PlainDate | 2026-10-28 |
| Floating | Temporal.PlainDateTime | 2026-10-28T09:00:00 |
A date of another kind is converted with coerceRecurrenceDate, or
dropped with an issue when it can't be.
EventRecurrence
An EventRecurrence is what makes an event a series' master: its RRULE, EXDATE, RDATE and
DURATION.
Prop
Type
A RecurrencePeriod is an extra occurrence with its own start and end, both in the series'
kind.
RecurrenceRule is @moltenship/ics's IcsRecurrenceRule, with
until as a RecurrenceDate: frequency, interval, count, until, byDay, byMonth,
byMonthDay, bySetPosition, weekStart and the rest. UNTIL is inclusive. With COUNT too, the
series ends at whichever comes first.
RecurrenceOverride
A RecurrenceOverride is what makes an event replace one occurrence of a series, as RECURRENCE-ID
does.
Prop
Type
An event that is both a master and an override is read as the override, with a
conflicting-recurrence issue.
RecurringCalendarEvent
RecurringCalendarEvent<TData, TEventId, TResourceId> is core's
CalendarEvent with an optional
recurrence: EventRecurrence and override: RecurrenceOverride. Use it when your app has no event
type of its own, with createCanonicalRecurrenceAdapter. It's also what
recurringEventsFromIcs returns and applyRecurrenceEdit
changes.
Adapters
A RecurrenceAdapter reads recurrence from your events, beside the EventAdapter that reads their
IDs, titles and intervals. A master's getInterval is its first occurrence, as DTSTART and DTEND
are. Timed series repeat on the wall clock of the interval's sourceTimeZone.
Prop
Type
RecurrenceAdapters<TEvent, TEventId, TResourceId> holds both: { event, recurrence }. The
expander and the edit functions take it.
createCanonicalRecurrenceAdapter<TData, TEventId, TResourceId>()returns the adapters forRecurringCalendarEvent: core'screateCanonicalEventAdapter, and a recurrence adapter that readsrecurrenceandoverride.createOccurrenceAdapter(eventAdapter)returns theEventAdapteryou give the calendar, whose events are occurrences. IDs and intervals come from the occurrence. Titles, resources, read-only and busy state come from the event it shows, througheventAdapter.isBusyandisReadOnlyexist only wheneventAdapterhas them, so core's defaults still apply.
import { createCalendar } from "@moltenship/calendar";
import {
createCanonicalRecurrenceAdapter,
createOccurrenceAdapter,
type RecurrenceOccurrence,
type RecurringCalendarEvent,
} from "@moltenship/calendar-recurrence";
type Event = RecurringCalendarEvent<{ color: string }>;
const adapters = createCanonicalRecurrenceAdapter<{ color: string }>();
const Calendar = createCalendar<RecurrenceOccurrence<Event, string>>()({
eventAdapter: createOccurrenceAdapter(adapters.event),
});An occurrence's revision combines the revisions of its event and its series' master with its
interval. Without getRevision on your adapter, an event's revision is its object identity, so
replace events rather than changing them.
Expansion
createOccurrenceExpander(adapters, options) returns an OccurrenceExpander.
expander.expand(events, range) returns the occurrences of events that overlap range:
import { createOccurrenceExpander } from "@moltenship/calendar-recurrence";
const expander = createOccurrenceExpander(adapters, { defaultTimeZone: "Europe/Berlin" });
const { occurrences, issues } = expander.expand(events, {
range: visibleRange.loadRange,
timeZone: "Europe/Berlin",
});Create one expander and keep it, in useState or at module scope. It reuses its work for events
that are the same objects as last time: each series keeps its last four ranges and eight rule
windows, and an occurrence that comes out the same keeps its object, so memoized views don't
render again. Treat events as immutable.
options is an OccurrenceExpanderOptions:
Prop
Type
OccurrenceRange
expand takes the dates as an OccurrenceRange:
Prop
Type
An occurrence overlapping the range is included, even one that starts before it. Overrides and one-off events pass through whatever the range, since your app chose to load them.
OccurrenceExpansion
expand returns an OccurrenceExpansion: { occurrences, issues }. occurrences holds a RecurrenceOccurrence for each
occurrence in the range, each override that isn't cancelled, and each one-off event. They come in
the input order of their events, and each series' in time order. IDs are unique. Each one has:
Prop
Type
OccurrenceRecurrence has the seriesId, the recurrenceId (the original start, which names the
occurrence and stays put when it moves), the series master and the override that replaces the
occurrence, when there is one. series is absent for an override whose master isn't among the
events, such as an invitation to one occurrence. It keeps its ID, so it matches once the master
loads.
Issues
Bad data becomes an issue, with the event and its position in the input, rather than an exception. A problem with one date drops only that date; the series stays.
code | Meaning |
|---|---|
adapter-error | An adapter function threw. The event is left out, and message names the function. |
date-kind-mismatch | A date that coerceRecurrenceDate can't bring into the series' kind, such as a date-only RDATE on a timed series. |
conflicting-recurrence | An event is both a master and an override. It's read as the override. |
duplicate-event-id | Two items have the same ID, such as series 42 and "42". The later one is left out. |
duplicate-override | Two overrides replace the same occurrence. The later one in the input wins. |
invalid-rule | The rule can't be expanded. The series still shows its first occurrence and its RDATEs. |
invalid-time-zone | sourceTimeZone or defaultTimeZone isn't a time zone. Dates that need it are dropped. |
iteration-limit | A series ran out of maxIterations before it finished the range. |
missing-series | An override's master isn't among the events. It shows on its own, under its occurrence ID. |
unsupported-rule | A valid RFC 5545 construct the engine doesn't handle, such as a sub-daily FREQ on an all-day series. |
Each RecurrenceIssue has its code, the event, its eventId when the adapter could read it, its
index in the input, and a message.
Occurrence IDs
import { occurrenceId, parseOccurrenceId, recurrenceKey } from "@moltenship/calendar-recurrence";
occurrenceId("standup", Temporal.Instant.from("2026-10-28T08:00:00Z"));
// "standup::2026-10-28T08:00:00Z"
occurrenceId(42, Temporal.PlainDate.from("2026-10-28")); // "42::2026-10-28"
recurrenceKey(Temporal.Instant.from("2026-10-28T09:00:00+01:00")); // "2026-10-28T08:00:00Z"
parseOccurrenceId("standup::2026-10-28T08:00:00Z");
// { seriesId: "standup", recurrenceKey: "2026-10-28T08:00:00Z" }
parseOccurrenceId("standup"); // undefinedoccurrenceId(seriesId, recurrenceId)is<seriesId>::<recurrenceKey>: the series and the occurrence's original start. It stays the same across views, time zones and reloads, and when an override moves the occurrence. Edits to the following occurrences or to all of them change starts, and so IDs.recurrenceKey(recurrenceId)is the original start in ISO form: instants in UTC, dates and wall times in the ISO calendar, without a calendar annotation. The format is stable, since apps store IDs.parseOccurrenceId(id)splits an ID into the series ID, as a string, and the key, or returnsundefinedfor an ID that doesn't end in a key. Use it only for IDs you stored; an occurrence'srecurrencehas the series ID with its type.
Rules
RRULE text
parseRecurrenceRule(text) reads an RRULE as a RECUR value (FREQ=WEEKLY;BYDAY=MO) or a content
line (RRULE:FREQ=WEEKLY;BYDAY=MO), as the Google Calendar API stores it. It never throws:
import { formatRecurrenceRule, parseRecurrenceRule } from "@moltenship/calendar-recurrence";
parseRecurrenceRule("RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T225959Z");
// { rule: { frequency: "WEEKLY", byDay: [{ weekday: "MO" }, { weekday: "WE" }],
// until: Temporal.Instant 2026-12-31T22:59:59Z }, issues: [] }
parseRecurrenceRule("INTERVAL=2");
// { issues: [{ severity: "error", code: "invalid-value",
// message: "\"INTERVAL=2\" has no FREQ, which a recurrence rule requires" }] }
formatRecurrenceRule({ frequency: "MONTHLY", byDay: [{ weekday: "TU", ordinal: 2 }], count: 6 });
// "FREQ=MONTHLY;COUNT=6;BYDAY=2TU"parseRecurrenceRulereturns aRecurrenceRuleTextResult:rule, absent when the text holds no usable rule, andissues. EachRecurrenceRuleTextIssuehas aseverity, thecode@moltenship/icsreports it under, and amessage. Errors leave no rule. Warnings keep a best-effort one, as when an invalid BYDAY is dropped.- UNTIL becomes a
PlainDatefor a DATE, anInstantfor a UTC or zoned DATE-TIME, and aPlainDateTimefor a floating one. formatRecurrenceRule(rule)writes the RECUR value, withoutRRULE:, its parts in RFC 5545's order. AnInstantUNTIL is written in UTC and rounded down to the second. It throws aRangeErrorfor an UNTIL outside the years 0 to 9999.recurrenceRuleFromIcs(icsRule)convertsIcsEvent.recurrenceRulefrom@moltenship/ics, and returns aRecurrenceRuleTextResult. An UNTIL whose TZID resolved to no time zone is an error, since the series' end would be unknown.recurrenceRuleToIcs(rule)converts back.
The engine
expandRecurrenceRule(rule, first, window) lists a rule's starts. The expander calls it for each
series; call it yourself to list starts without events:
import { expandRecurrenceRule } from "@moltenship/calendar-recurrence";
const start = Temporal.ZonedDateTime.from("2026-10-19T09:00[Europe/Berlin]");
const { dates, truncated } = expandRecurrenceRule(
{ frequency: "WEEKLY", byDay: [{ weekday: "MO" }] },
{
kind: "timed",
start: start.toInstant(),
end: start.add({ minutes: 30 }).toInstant(),
sourceTimeZone: "Europe/Berlin",
},
{
from: Temporal.Instant.from("2026-10-20T00:00Z"),
before: Temporal.Instant.from("2026-11-10T00:00Z"),
},
);
// dates: 2026-10-26T08:00:00Z, 2026-11-02T08:00:00Z, 2026-11-09T08:00:00Z
// (09:00 in Berlin each Monday, after the switch to winter time); truncated: falseIt covers RFC 5545: every FREQ, every BY part, BYSETPOS, WKST, COUNT and UNTIL. first is a
RecurrenceRuleStart: the first occurrence's interval, with an optional wallStart. The first
occurrence is always one and counts toward COUNT. Dates that don't exist, such as 30 February,
are skipped and don't count. window is a RecurrenceRuleWindow:
Prop
Type
It returns a RecurrenceRuleExpansion: dates, strictly increasing in the kind of the first
occurrence and the ISO calendar, and truncated, which is true when maxIterations ran out while
the rule may still have had starts to give. DEFAULT_MAX_ITERATIONS is 100,000.
A rule that can never match again ends after its first occurrence, without truncated, when the
engine can tell up front: day parts that meet on no date, such as BYMONTH=2;BYMONTHDAY=30, and an
INTERVAL whose steps never reach the BY values of a fixed cycle, such as
FREQ=MINUTELY;INTERVAL=2;BYMINUTE=1 from an even minute. The cycles are the second, minute and
hour of the day, the weekday of a DAILY rule and the month of a MONTHLY one. Other impossible
rules, such as FREQ=MONTHLY;BYDAY=MO;BYSETPOS=6, show only over time: they run to
maxIterations and report truncated.
For a rule it can't expand, or a sourceTimeZone that isn't a time zone, it throws a
RecurrenceRuleError, a RangeError whose code is a RecurrenceRuleErrorCode:
invalid-rule, invalid-time-zone or unsupported-rule. The expander reports the same code as an
issue. Window dates of another kind than first, and a maxIterations that isn't a positive
integer, throw a plain RangeError.
Dates of another kind
Real feeds mix kinds: a UTC UNTIL on an all-day series, a date-only EXDATE on a timed one.
coerceRecurrenceDate(date, first, role, options) brings a date into the kind of the series' first
occurrence. It's the one place dates convert, and the expander and the ICS bridge use it.
| On a series that is | A PlainDate | A PlainDateTime | An Instant |
|---|---|---|---|
| Timed | "exception": stays, the whole day. "until": the end of that day. "start": undefined | its instant in the series' zone | itself |
| All-day | itself | its date | its date in defaultTimeZone |
| Floating | "exception": stays. "until": the end of that day. "start": undefined | itself | its wall time in defaultTimeZone |
role is a RecurrenceDateRole: "exception" for EXDATE, "start" for RDATE and
RECURRENCE-ID, "until" for UNTIL. Wall times resolve with compatible, as RFC 5545 §3.3.5 says.
options is a CoerceRecurrenceDateOptions with a defaultTimeZone, UTC by default, for timed
series without sourceTimeZone and for instants on the other kinds. undefined means the date
can't stand for its role; report it and drop only that date.
ICS bridge
recurringEventsFromIcs(events, options) groups the VEVENTs that @moltenship/ics parsed into
masters, overrides and one-off events, as RecurringCalendarEvents whose data is the IcsEvent:
import {
createCanonicalRecurrenceAdapter,
createOccurrenceExpander,
recurringEventsFromIcs,
} from "@moltenship/calendar-recurrence";
import { parseIcs, type IcsEvent } from "@moltenship/ics";
const calendar = parseIcs(text).calendars[0]!;
const { events, issues } = recurringEventsFromIcs(calendar.events, {
timeZone: calendar.timeZone,
});
const expander = createOccurrenceExpander(createCanonicalRecurrenceAdapter<IcsEvent>());- Masters and one-off events keep their UID as their ID. An override gets the ID of the occurrence
it replaces,
occurrenceId(uid, recurrenceId), with or without its master. - A UTC DTSTART repeats in UTC, and a zoned one in its zone. A DTSTART in a DST gap keeps its
written time as
recurrence.wallStart. It moves forward by the gap, as RFC 5545 says; a series with DTEND in the same zone keeps the length written between them, and a single event its exact length, unless the gap leaves it none. DURATION without DTEND becomesrecurrence.duration. - With
X-MICROSOFT-CDO-ALLDAYEVENT:TRUE, an event written from midnight to a later midnight, or from midnight without an end, as older Outlook and Exchange exports write all-day events, is all-day on the dates written, even when its TZID resolves to no zone. Its series' midnight EXDATE, UNTIL and RECURRENCE-ID values are those dates. UTC ones read in its DTSTART's zone (UTC for a UTC one), else intimeZone. - EXDATE, RDATE, UNTIL and RECURRENCE-ID take the series' kind through
coerceRecurrenceDate. - A cancelled master or one-off event is left out, with the master's overrides. A cancelled
override comes out with
override.cancelled, which hides its occurrence. - Of events with the same UID, or UID and RECURRENCE-ID, the highest SEQUENCE wins, then the later one.
- RANGE=THISANDFUTURE, as Apple's "this and future" changes write it, applies to the occurrence it
names and every later one (RFC 5545 §3.8.4.4). The series splits as a "this and following" edit
splits it: it ends before the occurrence, and a new series, with the override's ID, VEVENT and
title, starts where the override is, the rest of the rule moved by the same difference. Later
EXDATEs, RDATEs and overrides move to it, and overrides take the IDs of the occurrences they then
replace. Later occurrences are keyed by their new starts, so their
recurrenceIds aren't the feed's RECURRENCE-IDs. On the first occurrence the whole series changes instead. A cancelled one ends the series there, and later overrides stay. When the rule can't follow, as a BYSETPOS rule moved to another day can't, the override replaces only the occurrence it names, with anunsupported-ruleissue.
options is an IcsRecurringEventsOptions:
Prop
Type
It returns an IcsRecurringEvents: events, in input order, and issues. Bad data becomes an
issue; only invalid options throw, as a RangeError. Each IcsRecurrenceIssue has its code, the
event, its index, a message and the property it concerns, such as EXDATE:
code (IcsRecurrenceIssueCode) | Meaning |
|---|---|
date-kind-mismatch | EXDATE, RDATE, UNTIL or RECURRENCE-ID has another kind than the series, or an unresolved TZID. It was converted, or dropped. |
duplicate-event | Another VEVENT has the same UID, or the same UID and RECURRENCE-ID. The higher SEQUENCE wins, then the later one. |
invalid-period | An RDATE PERIOD that doesn't end after it starts. Only its start is kept. |
unresolved-time-zone | DTSTART, or the RECURRENCE-ID of an override without its master, has a TZID that resolved to no time zone. The event is skipped. |
unsupported-rule | RANGE=THISANDFUTURE the series can't take, or whose series or occurrence isn't in the feed. The override replaces only that one. |
The expander reports its own issues on top, such as a rule it can't expand.
Edits
When someone moves, resizes or deletes an occurrence, onEventChange receives a change whose
event is the occurrence: a RecurrenceOccurrenceChange<TEvent, TEventId, TResourceId>. The edit
functions turn it into what to save.
// `ask` comes from the registry's useRecurrenceScopePrompt.
const scope = isRecurringChange(change)
? await ask({ scopes: recurrenceEditScopes(change, adapters, { timeZone }) })
: "this";
// Rolls the change back, and is announced as a cancel.
if (scope === null) throw new DOMException("Cancelled", "AbortError");
const edit = resolveRecurrenceEdit(change, scope, adapters, { newSeriesId, occurrences, timeZone });
if (edit.type === "rejected") throw new Error(edit.message);
setEvents(
(events) =>
edit.type === "series"
? applyRecurrenceEdit(events, edit, { createId })
: saveOneOff(events, edit.change), // a one-off event, unwrapped: save it as without recurrence
);isRecurringChange(change)istruefor a change to an occurrence of a series, which needs a scope. It narrowschangeto aRecurringOccurrenceChange. A create, or a change to a one-off event, needs none.recurrenceEditScopes(change, adapters, options)lists theRecurrenceEditScopes worth offering, in order:"this","following","all". It leaves out thoseresolveRecurrenceEditwould reject, and"following"where it means"all", as on the first occurrence. It's empty for a change that isn't recurring. Hand it to the scope dialog.resolveRecurrenceEdit(change, scope, adapters, options)returns aRecurrenceEdit. It reads its arguments only: it never changes them or calls your code.
Both take a ResolveRecurrenceEditOptions:
Prop
Type
What each scope does
"this". A move, resize or update creates the occurrence's override, or updates it. ItsrecurrenceIdstays, so the occurrence keeps its ID. A delete adds the original start to the master'sexceptionDatesand deletes an existing override. An override whose master isn't loaded is markedcancelledinstead. Google's API records a deleted instance as a cancelled one; this package reads those, but writes EXDATE, which every iCalendar store understands."following". The master ends before the occurrence: UNTIL becomes the previous occurrence's start, or COUNT the number of occurrences before it. A new series starts where the change puts the occurrence, with the rest of the rule moved as"all"moves it, and later EXDATEs, RDATEs and overrides move to it. A delete only ends the series. Google Calendar and Outlook on the web split a series the same way. When nothing before the occurrence is left to show, it's"all": EXDATEs count for that, and cancelled overrides when you passoverrides."all". The change from where the occurrence showed to where it goes, measured on the series' wall clock, applies to every occurrence. The rule moves with it, such as BYDAY when the day changes, and so do EXDATEs, RDATEs, UNTIL and every override'srecurrenceId, so deleted occurrences stay deleted and moved ones don't appear twice. Overrides keep their own placements. A move keeps the series' length; a resize changes it. A delete removes the master and its overrides. An occurrence dropped on the later of two equal wall times, as DST's end repeats an hour, gets an override there, since a rule can only say the earlier one, where the rest of the series goes.
A timed occurrence dropped on a day, or an all-day one dropped on a time, works in every scope.
"following" and "all" change the series' kind and convert its dates; a rule with several times
a day can't become all-day.
RecurrenceEdit
A RecurrenceEdit is one of three types:
{ type: "event", change }, aRecurrenceEventEdit: a create, or a change to a one-off event, with the event unwrapped. Applychangeas you would without recurrence.{ type: "series", … }, aRecurrenceSeriesEdit, below.{ type: "rejected", reason, message }, aRecurrenceEditRejection: the scope can't apply.
A RecurrenceSeriesEdit describes the writes as plain data. Only placement and recurrence are
described; copy anything else an "update" changed, such as the title, yourself.
Prop
Type
RecurrenceSeriesWrite, RecurrenceNewSeries and RecurrenceOverrideWrite are the types of
series, newSeries and override. A placement is a CalendarEventPlacement: an interval and its
resourceIds.
recurrenceOverrideEdit
recurrenceOverrideEdit(edit, recurrenceId) says what an edit does to one stored override of
edit.seriesId, by its recurrenceId. It returns a RecurrenceOverrideRekey:
{ action: "keep" }, as for the edited occurrence's own override, whichedit.overridecovers;{ action: "delete" };{ action: "update", recurrenceId, series }: its new original start, and"new"when it moves to the series a"following"edit starts. Its own placement stays.
otherOverrides is a RecurrenceOverridesRule: an action ("keep", "rekey" or "delete"),
after, the original start after which it applies, series, and shift, a RecurrenceKeyShift
that says how original starts move. Read them through recurrenceOverrideEdit; the fields of
RecurrenceKeyShift and of its RecurrenceOrdinalShift, which moves ordinal weekdays such as the
2nd Tuesday within their month or year, may change.
Rejections
RecurrenceEditRejection.reason is a RecurrenceEditRejectionReason:
reason | Meaning |
|---|---|
date-kind-mismatch | The change's dates don't fit the series' kind. |
extra-occurrence | "following" from an occurrence only an RDATE adds, which no rule can continue from. |
invalid-rule | The engine can't expand the rule, or the one the edit would write. |
invalid-time-zone | The series' or the placement's zone isn't one Temporal knows. |
iteration-limit | The search for the previous occurrence ran past maxIterations. |
missing-series | "following" or "all" on an override whose master isn't loaded. |
rule-cannot-move | The rule can't follow the change, such as a BYSETPOS rule moved to another day, or the 28th to the 29th. |
unknown-occurrence | The rule doesn't produce the occurrence any more. |
unsupported-rule | The engine doesn't handle the rule, or the one the edit would write. |
recurrenceEditScopes leaves out the scopes that would be rejected, so the dialog offers only
scopes that apply.
applyRecurrenceEdit
applyRecurrenceEdit(events, edit, { createId }) applies a series edit to an array of
RecurringCalendarEvents and returns a new array. It updates or deletes the master, adds the
series a "following" edit starts, writes the edited occurrence's override, and applies
recurrenceOverrideEdit to every other override of the series. Events it doesn't touch stay the
same objects.
createId(event), the one field of ApplyRecurrenceEditOptions, returns the ID of an event the
edit creates: an override, or the new series when newSeries.id isn't set. It gets the event
without its ID. Apps with their own event type or store write the same steps themselves; see
Save the edit.
Last updated on