ChronousDocumentation

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
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.

typescript
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
More rules
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 start is the anchor. An anchor the rule does not match is not an instance: FREQ=WEEKLY;BYDAY=MO on 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.

typescript
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.

typescript
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.

typescript
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