Recurring events
RFC 5545 series expanded in the browser or on your server, with this, this-and-following and all-events edits.
Recurring events are series: a start, a rule (RFC 5545's RRULE) and their exceptions. The
calendar expands them over the visible range with the engine of @hapio/calendar-recurrence, so a
series costs nothing outside the dates on screen, and every way of changing an event works on an
occurrence, followed by the question which events to change.
npm install @hapio/calendar-recurrence'use client'
import type { EventChange, SeriesEditPlan, SeriesInput } from '@hapio/calendar-react'
import { rrule } from '@hapio/calendar-recurrence'
import { useState } from 'react'
import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
// Once per app, at module scope: the engine caches what it expands for as long as it lives.
const recurrence = rrule()
const INITIAL: readonly SeriesInput[] = [
{
id: 'standup',
title: 'Stand-up',
// 09:00 in Stockholm on weekdays, whatever zone the calendar shows.
start: '2026-10-05T09:00[Europe/Stockholm]',
duration: 'PT15M',
rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
// Offset-less dates in a series are wall times in the series' zone.
exdate: ['2026-10-23T09:00'],
overrides: [{ originalStart: '2026-10-21T09:00', patch: { title: 'Stand-up · Demo day' } }],
},
]
/** The plan's series in place of the edited one; a 'following' plan splits it in two. */
function applyPlan(
series: readonly SeriesInput[],
plan: SeriesEditPlan<undefined>,
): readonly SeriesInput[] {
if (plan.scope === 'following') {
return series.flatMap((item) =>
item.id === plan.truncated.id ? [plan.truncated, plan.created] : [item],
)
}
return series.map((item) => (item.id === plan.series.id ? plan.series : item))
}
export function TeamCalendar() {
const [series, setSeries] = useState(INITIAL)
const onEventChange = (change: EventChange<undefined>) => {
// The scope the user chose ('this', 'following' or 'all') and how the series changes.
const plan = change.recurrence?.plan
if (plan == null) return false // this calendar has no one-off events
setSeries((current) => applyPlan(current, plan)) // new series: the echo
return true
}
return (
<CalendarEditing>
<HapioCalendar
series={series}
recurrence={recurrence}
timeZone="America/New_York"
editable
onEventChange={onEventChange}
className="h-[36rem]"
/>
</CalendarEditing>
)
}Try it in the recurrence example: a Stockholm stand-up shown in New York across both DST changes, with each scope and a server-expanded mode.
Series
A series (SeriesInput) has the fields of an event (id, title, color, resourceId,
editable, data…) and:
start: DTSTART, the first occurrence's start. Its form sets the series' zone (below).duration('PT45M',{ minutes: 45 }) orend(the end of the first occurrence): the length of each occurrence. Without either, the calendar's default durations.rrule: the rule,'FREQ=WEEKLY;BYDAY=MO,WE'or a full'RRULE:…'line.rdateandexdate: extra occurrences and removed ones (matched on their original start).overrides: changes to single occurrences, keyed byoriginalStart:cancelled: true, or apatchofstart,end,allDay,title,resourceIds,color,classNameordata.
Each occurrence is an event keyed ${seriesId}@${originalStartIso}, with
event.recurrence set (seriesId, originalStart, originalStartIso). Pass the same array and
series objects while they are unchanged: the calendar compares them by identity.
recurrence takes the engine. Create it once, at module scope: its cache lives with it.
rrule({ gapPolicy }) decides what happens to an occurrence whose wall time does not exist on a
spring-forward day: 'skip' (the default, as RFC 5545 says) leaves it out, 'shift' moves it
later by the gap and counts it. A wall time that happens twice takes its first instant.
Series zones
start written as | The series repeats in |
|---|---|
'2026-10-05T09:00[Europe/Stockholm]', a ZonedDateTime | that zone: 09:00 Stockholm all year, whatever the calendar shows |
'2026-10-05T09:00+02:00', '…Z' | that fixed offset (Z is UTC) |
a Date, epoch ms, an Instant | UTC |
'2026-10-05', a PlainDate | all-day dates |
'2026-10-05T09:00' (no offset) | the calendar's zone ("floating": it moves with the calendar) |
Offset-less dates inside a series (rdate, exdate, an override's originalStart and its
patch) are wall times in the series' zone, as RFC 5545 reads them; in one-off events they float
in the calendar's zone. A series in another zone than the calendar's moves against its hours
around DST changes: the Stockholm stand-up is at 03:00 in New York, then at 04:00 for the week
between Europe's and the US's changes.
Changing an occurrence
Inside <CalendarEditing>, occurrences move and resize like any event: a drag, the keyboard, the
actions menu, the reschedule form, placing. The change then waits, the occurrence held at its new
place, for its scope:
recurrenceScope: 'prompt'(the default): the scope dialog asks "This event", "This and following events" or "All events", offering the scopes the engine allows for this change ("This event" has focus). Cancel or Escape puts the occurrence back. When only "This event" is possible, it does not ask.- A scope (
'this','following','all'): used when the engine allows it, else'this'. - A function of the change returns a scope,
nullto cancel, or a promise of either.
Then onEventChange receives the change with change.recurrence: scope (the one chosen),
allowedScopes, and plan, how the series changes:
plan.scope | Save |
|---|---|
'this' | plan.series: the series with an override for this occurrence (plan.override) |
'following' | plan.truncated (the series ending before this occurrence) and plan.created (a new series from it) |
'all' | plan.series: the whole series shifted |
A 'following' change of the first occurrence is planned as 'all', so plan.scope can differ
from the scope chosen. Some changes allow 'this' only: a rule that pins the day of the month
(BYMONTHDAY=22) cannot move all its occurrences to another day, and a move between the all-day
row and the times changes the kind of one occurrence only.
Until the change settles, every occurrence of the series is pending: a series takes one change at
a time, so the next change is planned on the series as saved. Navigating does not settle it: the
series expanded over another range is not new data (new events or a source's result still are,
as for every change), so a change waiting for your
save is still pending when the user comes back.
Rules and the series. While an occurrence moves, the series' occurrences that the change may
move along with it do not count as overlaps: all the others while the scope is still to be chosen
('prompt' or a function) or is 'all', the later ones with 'following', none with 'this'.
Once the scope is chosen the rules judge the change again with it, so a 'this' change that lands
on another occurrence of its series goes back with the overlap's reason.
With markup of your own, render the scope prompt of useRecurrenceScopePrompt (the registry's
<CalendarEditing> does): with 'prompt' an occurrence otherwise waits for an answer nothing
asks for, and development builds warn when a prompt opens and nothing renders it.
Server-expanded occurrences
When your server owns the series, expand them there and send plain events. expandRecurrences
writes each occurrence as an event with recurrence: { seriesId, originalStart }, keyed exactly
as a client-side expansion would key it:
// A Server Component.
import { expandRecurrences } from '@hapio/calendar-recurrence'
import { loadSeries } from '@/lib/series'
import { ServerTeamCalendar } from './server-team-calendar'
export default async function TeamPage() {
const series = await loadSeries()
// Plain events, one per occurrence, each with `recurrence: { seriesId, originalStart }`.
const events = expandRecurrences(
series,
{ start: '2026-10-01', end: '2026-11-01' },
{ timeZone: 'Europe/Stockholm' },
)
return <ServerTeamCalendar events={events} />
}A change to such an occurrence offers every scope and arrives with recurrence.plan null: send
the series id, the original start, the scope and the new times to the server, and plan the change
there with planSeriesEdit, which computes the same plan the browser would (or null when the
rule does not allow the scope):
'use server'
import type { EditScope } from '@hapio/calendar-react'
import { planSeriesEdit } from '@hapio/calendar-recurrence'
import { loadSeries, saveSeries } from '@/lib/series'
export interface OccurrenceEdit {
readonly seriesId: string
readonly originalStartIso: string
readonly scope: EditScope
readonly start: string
readonly end: string
readonly allDay: boolean
}
/** Plans the edit where the series lives; `false` when its rule does not allow the scope. */
export async function editOccurrence(edit: OccurrenceEdit): Promise<boolean> {
const series = (await loadSeries()).find((item) => item.id === edit.seriesId)
if (series === undefined) return false
const plan = planSeriesEdit(
series,
{
originalStartIso: edit.originalStartIso,
start: edit.start,
end: edit.end,
allDay: edit.allDay,
},
edit.scope,
{ timeZone: 'Europe/Stockholm' },
)
if (plan === null) return false
await saveSeries(plan)
return true
}'use client'
import type { CalendarEventInput, EventChange } from '@hapio/calendar-react'
import { useRouter } from 'next/navigation'
import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { editOccurrence } from './server-actions'
export function ServerTeamCalendar({ events }: { events: readonly CalendarEventInput[] }) {
const router = useRouter()
const onEventChange = async (change: EventChange<undefined>) => {
const { recurrence, next } = change
if (recurrence === null) return false // this calendar has no one-off events
// `recurrence.plan` is null for server-expanded occurrences: the server plans the change.
const saved = await editOccurrence({
seriesId: recurrence.seriesId,
originalStartIso: recurrence.originalStartIso,
scope: recurrence.scope,
start: next.startIso,
end: next.endIso,
allDay: next.allDay,
})
if (saved) router.refresh() // the page expands the series again: the echo
return saved // `false` reverts the change
}
return (
<CalendarEditing>
<HapioCalendar
events={events}
timeZone="Europe/Stockholm"
editable
onEventChange={onEventChange}
className="h-[36rem]"
/>
</CalendarEditing>
)
}A real page expands the range the calendar shows (from onRangeChange or the URL) rather than a
fixed month. expandRecurrences throws for timeZone: 'local', which a server cannot know.
Describing a rule
describeRule('FREQ=WEEKLY;BYDAY=MO,WE', 'en') is "Every week on Monday and Wednesday", and
describeRule(rule, 'sv-SE') is Swedish ("Varje vecka på måndagar och onsdagar"); other languages
get English. The engine's describe(series, locale) reads UNTIL in the series' zone.
Your own engine
recurrence takes any RecurrenceEngine: compile, expand, allowedScopes and plan, plus
series: seriesSupport from @hapio/calendar-core/series for a calendar to show series (an
engine used only on a server needs none). See the API reference.
Floating series on DST days
A floating series (its start without an offset) repeats at wall times, which a repeated
(fall-back) hour makes ambiguous. A 'this' change that lands an occurrence on the second pass of
that hour writes its time with the offset ('2026-10-25T02:30:00+01:00'), so the override keeps
its instant: after a change of the calendar's zone it stays at that instant instead of following
the wall time like the rest of the series.
A 'following' change that lands there keeps the series floating: the created series starts at
the wall time ('2026-10-25T02:30', which names the first pass), and an override with the
offset pins its first occurrence to the second pass, so its later occurrences stay at 02:30 wall
time, in summer time too, and follow the wall time after a zone change. A series whose start is
a Temporal.PlainDateTime cannot carry that offset, so such a change is cancelled.
Limits in 1.0
- The preview of a
'following'or'all'change covers the range it began in; in other ranges the series shows as your data has it until the change is saved. - The rules judge the occurrence you change, not the plan's other occurrences at their new places.
- Sub-daily rules with
BYDAY(FREQ=HOURLY;BYDAY=MO) plan'all'changes approximately: the occurrences are not an exact shift. - When a floating series is split exactly inside a repeated hour, the series before the split can still include the occurrence it was split at.
- Rules are described in English and Swedish only (Languages).
Dragging in from outside
Drop or place items from a palette, files from the desktop, or another drag-and-drop library's items onto the calendar.
Languages and right-to-left
Locales through Intl, the built-in English and Swedish strings, your own translations, rule descriptions and right-to-left layouts.