calendar-tsx

Recurrence

Native daily/weekly schedules and advanced RRule series with Temporal dates.

Two features expand recurring definitions in the visible range. recurrenceFeature from @calendar-tsx/core handles daily and weekly rules with Temporal alone. rruleFeature from @calendar-tsx/rrule handles full RRULE strings and options through rrule-temporal 2.2.8, keeping the core free of that dependency.

pnpm add @calendar-tsx/core temporal-polyfill

Register recurrenceFeature, create definitions with createRecurringEvent, and pass them in events alongside one-off events. start sets the seed date, wall time and recurrence zone; duration sets each occurrence's length.

export const review = createRecurringEvent({
  id: 'review',
  kind: 'timed',
  start: Temporal.ZonedDateTime.from('2026-01-05T09:00[America/New_York]'),
  duration: Temporal.Duration.from({ minutes: 30 }),
  rrule: { freq: 'WEEKLY', interval: 2, byDay: ['MO', 'WE'], count: 20 },
  data: { title: 'Review' },
});

const features = calendarFeatures({ recurrence: recurrenceFeature() });
const views = calendarViews({ features, views: { month: listView({ period: 'month' }) } });
export const calendar = createCalendar({
  timeZone: 'America/New_York',
  views,
  features,
  view: 'month',
  date: Temporal.PlainDate.from('2026-01-01'),
  events: [review],
  onNavigate: next => console.log(next),
});

The factory adds the feature's ~source discriminator and infers data. Importing the factory or its types does not register the feature. With explicit feature types, the calendar rejects recurring input when the feature is missing from features; untyped input without a registered resolver throws at runtime. Both rules apply to rruleFeature too.

Pass the same features and events options to useCalendar. Existing event parts render each occurrence.

Native rules

Native rules are plain objects. Strings, unsupported options and selectors that would broaden the schedule throw.

OptionDailyWeekly
freq'DAILY''WEEKLY'
intervalPositive integer days, default 1Positive integer weeks, default 1
countNonnegative integer occurrencesNonnegative integer occurrences
untilInclusive Temporal boundInclusive Temporal bound
byDayUnsupportedNonempty weekday array, default seed weekday
wkstUnsupportedWeek start, default 'MO'
  • Weekdays are 'MO' through 'SU', without ordinals. Weekly dates are sorted and deduplicated.
  • wkst anchors interval weeks, independently of the calendar's firstDayOfWeek.
  • Dates before the seed are omitted. The seed is an occurrence only if it matches the rule.
  • COUNT counts matching nominal starts, including those before the queried range, before exclusions apply. count: 0 produces no rule occurrences. COUNT and UNTIL may be combined.
  • until must match the event kind: ZonedDateTime for timed events, PlainDate for all-day events. Timed bounds compare instants to the nanosecond and may use another zone.

Exceptions

export const adjusted = createRecurringEvent({
  ...review,
  rdate: [Temporal.ZonedDateTime.from('2026-01-09T09:00[America/New_York]')],
  exdate: [Temporal.ZonedDateTime.from('2026-01-07T09:00[America/New_York]')],
  exrule: {
    freq: 'DAILY',
    start: Temporal.ZonedDateTime.from('2026-02-02T09:00[America/New_York]'),
    until: Temporal.ZonedDateTime.from('2026-02-06T09:00[America/New_York]'),
  },
});

rdate adds starts and deduplicates them. exdate and exrule remove starts, including RDATE additions. Exclusions do not replenish COUNT or trim durations. Timed exceptions identify instants; all-day exceptions identify dates.

exrule takes one rule or an array, in the same daily/weekly subset. Each exclusion may set its own start (clock time and zone included), interval, COUNT and inclusive UNTIL; without start it inherits the parent seed. Starts and bounds must match the parent's date kind. An exclusion whose UNTIL precedes its seed excludes nothing.

Switching to RRule

Native definitions are structurally compatible with createRRuleEvent. Swap the factory and the registered feature: an unchanged definition produces the same starts, ends, exceptions, occurrence metadata and IDs. Both features can also run in one calendar.

export const rruleReview = createRRuleEvent(adjusted);
export const advanced = createRRuleEvent({
  ...review,
  id: 'monthly-review',
  rrule: { freq: 'MONTHLY', byDay: ['2FR'] },
});
const advancedFeatures = calendarFeatures({ recurrence: rruleFeature() });
const advancedViews = calendarViews({ features: advancedFeatures, views: { month: listView({ period: 'month' }) } });
export const advancedCalendar = createCalendar({
  ...calendar.options,
  features: advancedFeatures,
  views: advancedViews,
  events: [rruleReview, advanced],
});

RRule

pnpm add @calendar-tsx/rrule

Register rruleFeature and create definitions with createRRuleEvent.

export const review = createRRuleEvent({
  id: 'review',
  kind: 'timed',
  start: Temporal.ZonedDateTime.from('2026-01-01T09:00[America/New_York]'),
  duration: Temporal.Duration.from({ minutes: 30 }),
  rrule: 'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
  data: { title: 'Month-end review' },
});

const features = calendarFeatures({ recurrence: rruleFeature() });
const views = calendarViews({ features, views: { month: listView({ period: 'month' }) } });
export const calendar = createCalendar({
  timeZone: 'America/New_York',
  views,
  features,
  view: 'month',
  date: Temporal.PlainDate.from('2026-01-01'),
  events: [review],
  onNavigate: next => console.log(next),
});

rrule takes an RRULE string or a plain options object. Both support monthly and yearly rules, ordinal weekdays and BYSETPOS. Options use string frequencies and camel-case arrays such as byDay: ['2FR'] and bySetPos: [-1], a Temporal until, and no dtstart or tzid. Instantiated rule objects are not accepted, and no constants import is needed.

export const optionsReview = createRRuleEvent({
  ...review,
  rrule: {
    freq: 'MONTHLY',
    byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
    bySetPos: [-1],
    until: Temporal.ZonedDateTime.from('2027-01-01T00:00[America/New_York]'),
  },
  rdate: [Temporal.ZonedDateTime.from('2026-02-26T09:00[America/New_York]')],
  exdate: [Temporal.ZonedDateTime.from('2026-02-27T09:00[America/New_York]')],
});

A string may contain DTSTART, several RRULE lines, RDATE, EXDATE and EXRULE, with folded lines. Values use RFC basic notation, for example EXDATE;TZID=America/New_York:20260227T090000 or EXDATE:20260227T140000Z. An embedded DTSTART must agree with start; a conflicting date or TZID throws. Whole VCALENDAR or VEVENT documents and RDATE periods are not accepted.

UNTIL is inclusive. In a string it may be a local DATE-TIME or a UTC DATE-TIME ending in Z; a UTC bound compares the resolved instant.

rdate inclusions are merged and deduplicated. exdate and exrule win over RRULE and RDATE starts and do not replenish COUNT. Timed exception dates identify instants and may use another zone; all-day exception dates and UNTIL use Temporal.PlainDate.

An options-object exrule may set its own Temporal start; COUNT and INTERVAL then run from that seed. A timed seed keeps its own zone and clock time; an all-day seed is a PlainDate. Starts and bounds must match the parent's date kind. String exclusions and inline EXRULE lines inherit the parent seed.

export const boundedReview = createRRuleEvent({
  id: 'weekly-review',
  kind: 'allDay',
  start: Temporal.PlainDate.from('2026-01-05'),
  rrule: { freq: 'WEEKLY', count: 4 },
  exrule: {
    freq: 'WEEKLY',
    start: Temporal.PlainDate.from('2026-01-19'),
    until: Temporal.PlainDate.from('2026-01-26'),
  },
  data: { title: 'Review' },
});

This leaves January 5 and 12. An exclusion array can mix seeds and bounds. Timed exclusions match instants across zones, and an exclusion that starts after its UNTIL excludes nothing.

Time zones and duration

These rules apply to both features.

  • Timed occurrences keep the seed zone's wall-clock schedule. Changing the calendar's timeZone converts them after expansion without moving the schedule.
  • Temporal's compatible disambiguation moves a DST-gap time forward and picks the earlier offset in a fold. An explicitly offset seed keeps its instant. Shifted gap candidates still count toward COUNT, and candidates that resolve to the same instant are deduplicated.
  • Timed duration is required and positive. Days and weeks are elapsed multiples of 24 hours; years and months throw. Subsecond precision from start is kept, and timed ends are exclusive.
  • All-day series take a PlainDate start and an optional positive whole-day or whole-week duration, default one day. Ends are inclusive: a two-day duration ends on the following date. Use daily or coarser frequencies.
  • Seeds and exception dates must use the ISO 8601 calendar, with seed years 1 through 9999.
export const holiday = createRRuleEvent({
  id: 'holiday',
  kind: 'allDay',
  start: Temporal.PlainDate.from('2026-01-01'),
  duration: Temporal.Duration.from({ days: 2 }),
  rrule: 'FREQ=YEARLY',
  data: { title: 'Holiday' },
});

Occurrences and editing

Expansion runs before normalization, visibility filtering, background splitting, ordering and feature event stages, and includes starts before the view whose duration overlaps it. Each generated event:

  • has a stable ID built from the series ID and its scheduled start, independent of the visible range and display zone. Both features use the same scheme. One-off and generated IDs share a namespace, so duplicates trigger the core's development warning.
  • carries occurrence: { seriesId, originalStart }, with originalStart in the recurrence zone before display conversion.
  • copies resourceIds, display and data from its definition. Background series follow the usual background layout.
  • uses this library's temporal-polyfill namespace.

Event stores hold definitions: update or remove a series by its ID. To replace one occurrence, exclude its original start and add a one-off event. Definitions and arrays are compared by reference, so replace them when editing. Type an initially empty store that will hold series as Calendar_EventDefinition<EventData>[].

Limits and caching

Clock ticks reuse the current expansion, and each definition caches up to four range and zone queries.

  • Native expansion seeks straight to the queried range, finite series included, without replaying earlier starts. Each rule query allows at most 10,000 daily or weekly periods after seeking and clipping COUNT and UNTIL.
  • rrule-temporal seeks into eligible range queries. Its full-result cache is disabled. It allows 10,000 outer iterations and 1,000,000 candidate evaluations by default; options objects can change them with maxIterations and maxCandidateEvaluations. Large or complex queries can still be expensive.
  • The RRule engine receives nominal wall times as UTC ZonedDateTime values, and the calendar resolves them in the seed zone under the DST policy above. Native expansion resolves dates in that zone directly.

Exceeding a limit throws; neither feature returns partial results.