Recurrence
@hapio/calendar-recurrence and the core's series types: series, the rrule() engine, server-side expansion and planning, rule descriptions.
See Recurring events for how these fit together.
Series
A recurring series (RFC 5545), expanded by a RecurrenceEngine.
startis DTSTART, and its form sets the series zone: aZonedDateTimeor'…[Zone]'string expands in that zone (09:00 Stockholm stays 09:00 Stockholm while the calendar shows New York); an ISO string with an offset orZexpands in that fixed offset ('UTC'forZ), and aDate, epoch milliseconds or anInstantin UTC; aPlainDateor'YYYY-MM-DD'makes the series all-day; an offset-less date-time floats in the calendar zone.duration(orend, the end of the first occurrence) sets each occurrence's length; without either, the calendar's default timed or all-day duration applies.- Offset-less date-times in
rdate,exdateand the overrides are wall times in the series zone (RFC 5545: their form follows DTSTART), unlike one-off events, where they float in the calendar zone.
Takes effect in M7.
Prop
Type
A change to one occurrence of a series, keyed by its original start (RECURRENCE-ID).
Prop
Type
The engine
function rrule(options?: RRuleEngineOptions): RRuleEngine;The RFC 5545 recurrence engine on rrule-temporal 2.2.8: pass it as recurrence with series
(and to expandRecurrences on a server). Create it once at module scope; its chunk cache lives
as long as the engine.
const recurrence = rrule() // gapPolicy 'skip' (RFC 5545 §3.3.10)Throws a RangeError when gapPolicy is not 'skip' or 'shift' or maxOccurrencesPerChunk
is not an integer ≥ 1, and a TypeError when newSeriesId is not a function.
Options of rrule().
Prop
Type
type GapPolicy = "skip" | "shift";What happens to a generated occurrence whose wall time does not exist in the series zone (a DST gap):
'skip'(default, RFC 5545 §3.3.10): it is left out and does not count towardCOUNT;'shift'(opt-in, not RFC): the rule is expanded in wall time,COUNTandUNTILare applied there, and each wall time is resolved with'compatible', so a gap occurrence moves later by the gap's length and counts. One that lands on another occurrence's instant merges with it.
Under both, a repeated wall time (a fall-back hour) resolves to its earlier instant.
Changes to occurrences
type EditScope = "this" | "following" | "all";Which occurrences an edit applies to.
The recurrence part of an EventChange (null for one-off events).
Prop
Type
type SeriesEditPlan<TData> = {
readonly scope: "this";
readonly series: SeriesInput<TData>;
readonly override: OccurrenceOverride<TData>;
} | {
readonly scope: "following";
readonly truncated: SeriesInput<TData>;
readonly created: SeriesInput<TData>;
} | {
readonly scope: "all";
readonly series: SeriesInput<TData>;
};How an edit changes a series, as computed by the engine: this adds or
replaces an override; following truncates the series and creates a new one from the edited
occurrence; all shifts the whole series. A 'following' edit of the series' first occurrence
is planned as 'all', so plan.scope can differ from the scope that was chosen.
On a server
function expandRecurrences<TData>(series: readonly SeriesInput<TData>[], window: RecurrenceWindow, options: ExpandRecurrencesOptions): CalendarEventInput<TData>[];Expands series into plain events for [window.start, window.end) (an RSC, a route handler, a
server action). Each occurrence becomes a new CalendarEventInput with every field of its series
except rrule, rdate, exdate, overrides, duration, start and end; its override's
patch applied (resourceIds replacing resourceId too); start, end and allDay in the
series' form class (zoned strings …+02:00[Europe/Stockholm] for a zoned series, ISO with the
offset or Z for an instant one, offset-less wall times for a floating one, dates for all-day);
and recurrence: { seriesId, originalStart } with originalStart as written in its key (the
UTC instant or the date; a floating series' wall time), so the calendar keys it
${seriesId}@${originalStartIso} exactly as a client-side expansion would. Sorted by start, then
key. An invalid series is skipped with a development warning. Throws a RangeError for
timeZone: 'local', an unknown zone or a window date that does not parse.
// app/calendar/page.tsx (a Server Component)
const events = expandRecurrences(series, { start: '2026-10-01', end: '2026-11-01' },
{ timeZone: 'Europe/Stockholm' })The range expandRecurrences fills: the occurrences that overlap [start, end). A date (a
PlainDate or 'YYYY-MM-DD') is the start of that day in timeZone; an offset-less date-time is
a wall time in timeZone.
Prop
Type
Options of expandRecurrences and planSeriesEdit.
Prop
Type
function planSeriesEdit<TData>(series: SeriesInput<TData>, request: SeriesEditRequest, scope: EditScope, options: ExpandRecurrencesOptions): SeriesEditPlan<TData> | null;Plans an edit of one occurrence where the series lives (typically a server action receiving the
EventChange of a server-expanded occurrence, whose recurrence.plan is null): the plan the
engine would compute on the client for the same series, edit and scope ('following' of the
first occurrence as 'all'). null when the series is invalid, request.originalStartIso is
not one of its occurrences, or scope is not allowed for the edit (the engine's
allowedScopes). request.originalStartIso is the occurrence's key part as the calendar wrote
it: the UTC instant (a timed series; a floating one's wall time is accepted too) or the date
(an all-day series; a UTC instant names its date in options.timeZone). The request's dates
are read in options.timeZone.
Throws a RangeError for timeZone: 'local' or an unknown zone, for request dates that do not
parse, and for a request.end not after request.start (an all-day end not after its start
day); and the engine's plan errors (a RangeError from rrule() when it cannot plan the edit,
such as a COUNT it cannot count).
An edit of one occurrence as a server receives it, for planSeriesEdit: typically
change.recurrence.originalStartIso and change.next of a server-expanded occurrence (whose
EventChange.recurrence.plan is null).
Prop
Type
Describing a rule
function describeRule(rrule: string, locale: string): string;A short human-readable description of an RRULE value ('FREQ=WEEKLY;BYDAY=MO,WE' → "Every week
on Monday and Wednesday"; in Swedish "Varje vecka på måndagar och onsdagar"). Swedish for a
locale whose language is sv, English otherwise; names, lists and the UNTIL date come from
Intl in locale (UNTIL read in UTC; RecurrenceEngine.describe reads it in the series
zone). A locale Intl does not accept ('sv_SE') is read by its language. Parts outside the
phrase table add " (custom)" / " (anpassad)". Throws a RangeError when rrule is not an RRULE
(no valid FREQ).