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-polyfillRegister 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.
The example creates a core instance. Update its controlled options when navigation changes.
Native rules
Native rules are plain objects. Strings, unsupported options and selectors that would broaden the schedule throw.
| Option | Daily | Weekly |
|---|---|---|
freq | 'DAILY' | 'WEEKLY' |
interval | Positive integer days, default 1 | Positive integer weeks, default 1 |
count | Nonnegative integer occurrences | Nonnegative integer occurrences |
until | Inclusive Temporal bound | Inclusive Temporal bound |
byDay | Unsupported | Nonempty weekday array, default seed weekday |
wkst | Unsupported | Week start, default 'MO' |
- Weekdays are
'MO'through'SU', without ordinals. Weekly dates are sorted and deduplicated. wkstanchors interval weeks, independently of the calendar'sfirstDayOfWeek.- 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: 0produces no rule occurrences. COUNT and UNTIL may be combined. untilmust match the event kind:ZonedDateTimefor timed events,PlainDatefor 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/rruleRegister 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
timeZoneconverts them after expansion without moving the schedule. - Temporal's
compatibledisambiguation 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
durationis required and positive. Days and weeks are elapsed multiples of 24 hours; years and months throw. Subsecond precision fromstartis kept, and timed ends are exclusive. - All-day series take a
PlainDatestart and an optional positive whole-day or whole-weekduration, 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 }, withoriginalStartin the recurrence zone before display conversion. - copies
resourceIds,displayanddatafrom its definition. Background series follow the usual background layout. - uses this library's
temporal-polyfillnamespace.
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-temporalseeks 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 withmaxIterationsandmaxCandidateEvaluations. Large or complex queries can still be expensive.- The RRule engine receives nominal wall times as UTC
ZonedDateTimevalues, 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.