Hapio Calendar

Recurring events from your calendar

Your current calendar's simple recurrence and its rrule plugin's fields, as series of Hapio's recurrence engine.

The calendar you migrate from repeats events two ways: its built-in simple recurrence (daysOfWeek, startTime, …) and the rrule plugin (rrule, exdate, duration). The migration component reads both, as Hapio series, when you give it a recurrence engine. The engine replaces the plugin:

npm install @hapio/calendar-recurrence
components/team-calendar.tsx
'use client'

import type { EventChangeInfo, EventInput } from '@hapio/calendar-compat'
import { rrule } from '@hapio/calendar-recurrence'

import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { EventCalendar } from '@/components/event-calendar'

// The engine replaces the rrule plugin: once per app, at module scope.
const recurrence = rrule()

const EVENTS: readonly EventInput[] = [
  // Simple recurrence: weekdays at 09:00 in the calendar's zone.
  {
    id: 'standup',
    title: 'Stand-up',
    daysOfWeek: [1, 2, 3, 4, 5],
    startTime: '09:00',
    endTime: '09:15',
  },
  // The rrule plugin's fields: a rule with its own zone, and the dates it skips.
  {
    id: 'review',
    title: 'Monthly review',
    rrule: 'DTSTART;TZID=Europe/Stockholm:20261022T070000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=22',
    duration: '00:45',
    exdate: ['2026-12-22T07:00:00'],
  },
]

export function TeamCalendar({ onChange }: { onChange: (info: EventChangeInfo) => void }) {
  return (
    <CalendarEditing>
      <EventCalendar
        events={EVENTS}
        recurrence={recurrence}
        timeZone="Europe/Stockholm"
        editable
        // `info.recurrence` says which instances changed and how to save the series.
        eventChange={onChange}
      />
    </CalendarEditing>
  )
}

Simple recurrence

daysOfWeek (0 is Sunday; numbers or their strings, '4'), startTime, endTime, startRecur and endRecur make a weekly series on those days (a daily one without daysOfWeek) of wall times in the calendar's zone:

  • startRecur is the first day an occurrence can fall on; without it the series reaches back indefinitely. endRecur is exclusive: no occurrence on that day itself.
  • startTime and endTime set the times and the length (duration wins over endTime). Without either the occurrences are all-day, unless allDay says otherwise; without endTime they take the default duration and EventApi.end is null.
  • As in the source library, the event's start and end are ignored.

The rrule plugin's fields

  • rrule as a string, its DTSTART, RRULE, EXDATE and RDATE lines with TZID and VALUE=DATE ('DTSTART;TZID=Europe/Stockholm:20261022T070000\nRRULE:FREQ=MONTHLY;BYMONTHDAY=22'), or the object form (freq, interval, byweekday, bymonthday, bymonth, count, until, dtstart, tzid, wkst, …) with strings, numbers or the rrule library's constants (RRule.MO.nth(2)).
  • DTSTART (dtstart, else the event's start) starts the series. With a zone (Z, an offset, tzid or TZID) the rule repeats in that zone; without one it repeats in the calendar's wall time; a date makes the series all-day.
  • exdate removes occurrences, matched on their original start; offset-less date-times are wall times where the rule repeats. duration sets each occurrence's length.
  • A Date in dtstart, until or exdate is read as the rrule library reads it: its UTC fields are the wall time.
  • Without wkst the week starts on Monday, as RFC 5545 says (the plugin's default, the calendar's firstDay, is not applied).
  • allDay wins over the form of the rule's dates, as in the plugin.

Changing occurrences

Inside <CalendarEditing>, moving or resizing an occurrence changes every occurrence by default, as the source library does (recurrenceScope: 'all'). A change the rule cannot apply to every occurrence (a move between the all-day row and the times, say) changes that occurrence only, and development builds say so. Set recurrenceScope to 'this', 'following', 'prompt' (the calendar asks with its scope dialog) or a function of the change to decide otherwise.

eventDrop, eventResize and eventChange receive the occurrence after (event) and before (oldEvent) the change, relatedEvents empty, revert() (which reverts the whole change) and recurrence: the scope taken and Hapio's plan of how the series changes. The migration component keeps a one-off event's change over your events until they have it, but not an instance's: save the plan back into your events, or the instances move back after the settle timeout (15 seconds). The recurring event keeps its id, title, colors and extendedProps:

ScopeSave
'this'overrides: plan.series.overrides (the rule and everything else stay)
'all'the series as the plan writes it: start, rrule (a rule line without DTSTART: start is DTSTART), exdate, rdate, overrides and duration from plan.series, without the simple recurrence fields, which would win over rrule
'following'the event as for 'all' from plan.truncated, and a new recurring event the same way from plan.created (with the created series' id)

overrides and rdate are Hapio's additions to EventInput, so that every plan can be saved: the source library has no field for one changed instance (its apps add an exdate and a one-off event instead, which the migration component reads too).

See Recurring events for scopes, plans and server-side expansion.

Not supported

  • Recurring events without an engine: their fields are listed as unsupported, and an event with simple recurrence is skipped unless it has a start.
  • Recurring background events, and recurring events that an events function returns.
  • exrule.