Guides
Recurrence
RRULE, extra dates, exceptions and overrides — expanded only for the range on screen.
A series
An event that carries recurrence is a series. Building a calendar expands it into instances, and only for the range asked for — an unbounded rule is never walked past the last day on screen.
type RecurrenceInput<TData = unknown> = { rule?: string dates?: string[] exceptions?: string[] overrides?: RecurrenceOverride<TData>[] } type RecurrenceOverride<TData = unknown> = { recurrenceId: string cancelled?: boolean start?: string end?: string duration?: string data?: TData }
The rule
rule is an RFC 5545 RRULE, with or without the RRULE: prefix. Supported parts are FREQ, INTERVAL, COUNT, UNTIL, BYDAY (with ordinals such as -1FR), BYMONTHDAY, BYMONTH, BYSETPOS and WKST. Anything else throws InvalidRecurrenceError rather than being ignored.
import { buildCalendar } from '@midstem/chronous' import type { EventInput } from '@midstem/chronous' const standup: EventInput = { id: 'standup', start: '2026-03-16T09:00', duration: 'PT15M', recurrence: { rule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR' }, } // One event in, three instances out — only for the week on screen. const calendar = buildCalendar( { view: 'week', currentDate: '2026-03-18', timeZone: 'Europe/Kyiv' }, [standup], ) console.log(calendar.days.map(day => day.boxes.length)) // [ 1, 0, 1, 0, 1, 0, 0 ] — Monday, Wednesday, Friday
import type { RecurrenceInput } from '@midstem/chronous' // the last Friday of every month const monthly: RecurrenceInput = { rule: 'FREQ=MONTHLY;BYDAY=-1FR' } // every second week, twelve times const fortnightly: RecurrenceInput = { rule: 'FREQ=WEEKLY;INTERVAL=2;COUNT=12', } // every 1 September, until the rule runs out const yearly: RecurrenceInput = { rule: 'FREQ=YEARLY;BYMONTH=9;BYMONTHDAY=1;UNTIL=20300901T000000Z', } export const rules = [monthly, fortnightly, yearly]
- The event’s own
startis the anchor. An anchor the rule does not match is not an instance:FREQ=WEEKLY;BYDAY=MOon a Tuesday starts the following Monday. - Every instance keeps the wall clock and the wall length of the series, so a 09:00 meeting stays at 09:00 through a DST change.
Extra dates and exceptions
dates adds starts the rule does not produce, each with its own time of day. exceptions removes instances by their start — and COUNT is counted before they are removed, exactly as RFC 5545 asks.
import { buildCalendar } from '@midstem/chronous' import type { EventInput } from '@midstem/chronous' const standup: EventInput = { id: 'standup', start: '2026-03-16T09:00', duration: 'PT15M', recurrence: { rule: 'FREQ=WEEKLY;BYDAY=MO', // a start the rule does not produce, with its own time of day dates: ['2026-03-19T11:00'], // and one Monday dropped exceptions: ['2026-03-23T09:00'], }, } const calendar = buildCalendar( { view: 'days', dayCount: 14, currentDate: '2026-03-16', timeZone: 'Europe/Kyiv' }, [standup], ) console.log(calendar.days.filter(day => day.boxes.length > 0).map(day => day.date)) // [ '2026-03-16', '2026-03-19' ] — 23 March was removed
Overrides
overrides replaces one instance, or drops it with cancelled. An override with no end or duration keeps the length of the series, and one that moves an instance into the range brings it into view.
import { buildCalendar } from '@midstem/chronous' import type { EventInput } from '@midstem/chronous' const standup: EventInput = { id: 'standup', start: '2026-03-16T09:00', duration: 'PT15M', recurrence: { rule: 'FREQ=WEEKLY;BYDAY=MO', overrides: [ // moved, and an hour long instead of fifteen minutes { recurrenceId: '2026-03-30T09:00', start: '2026-03-30T10:00', duration: 'PT1H', }, // dropped { recurrenceId: '2026-04-06T09:00', cancelled: true }, ], }, } const calendar = buildCalendar( { view: 'days', dayCount: 28, currentDate: '2026-03-16', timeZone: 'Europe/Kyiv' }, [standup], ) console.log( calendar.days.flatMap(day => day.boxes).map(box => [box.start, box.minutes]), ) // [ [ '2026-03-16T09:00:00+02:00', 15 ], // [ '2026-03-23T09:00:00+02:00', 15 ], // [ '2026-03-30T10:00:00+03:00', 60 ] ] // 6 April was cancelled and is not there.
What an instance looks like
An instance is a full event. Its id is the series id, __ and its recurrenceId, and it carries seriesId and recurrenceId of its own. A plain event has neither.
import { buildCalendar } from '@midstem/chronous' import type { EventInput } from '@midstem/chronous' const events: EventInput[] = [ { id: 'standup', start: '2026-03-16T09:00', duration: 'PT15M', recurrence: { rule: 'FREQ=WEEKLY;BYDAY=MO' }, }, { id: 'review', start: '2026-03-17T14:00', duration: 'PT1H' }, ] const calendar = buildCalendar( { view: 'week', currentDate: '2026-03-18', timeZone: 'Europe/Kyiv' }, events, ) const [instance, plain] = calendar.days.flatMap(day => day.boxes) console.log(instance.event.id) // 'standup__2026-03-16T09:00:00+02:00' console.log(instance.event.seriesId) // 'standup' console.log(instance.event.recurrenceId) // '2026-03-16T09:00:00+02:00' console.log(plain.event.id) // 'review' console.log(plain.event.seriesId) // undefined