Hapio Calendar

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
components/team-calendar.tsx
'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 }) or end (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.
  • rdate and exdate: extra occurrences and removed ones (matched on their original start).
  • overrides: changes to single occurrences, keyed by originalStart: cancelled: true, or a patch of start, end, allDay, title, resourceIds, color, className or data.

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 asThe series repeats in
'2026-10-05T09:00[Europe/Stockholm]', a ZonedDateTimethat 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 InstantUTC
'2026-10-05', a PlainDateall-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, null to 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.scopeSave
'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:

app/team/page.tsx
// 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):

app/team/server-actions.ts
'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
}
app/team/server-team-calendar.tsx
'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).