Recurrence
RFC 5545 recurrence rule parsing, expansion, and modified instance handling.
Overview
forceCalendar provides three classes for recurrence handling:
| Class | API Style | Purpose |
|---|---|---|
RRuleParser | Static | Parse/build RRULE strings, validate rules |
RecurrenceEngine | Static | Expand recurring events into occurrences |
RecurrenceEngineV2 | Instance | Enhanced expansion with modified instances, DST, caching |
These implement RFC 5545 recurrence features with bounded expansion. Supported syntax is not a claim that every RFC combination or host timezone is fully conformant.
RRuleParser
Parses RRULE strings into structured rule objects and generates RRULE strings from rule objects.
parse(rrule)
Parse an RRULE string or validate a rule object.
import { RRuleParser } from '@forcecalendar/core';
const rule = RRuleParser.parse('FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12');
// {
// freq: 'WEEKLY',
// interval: 1,
// count: 12,
// until: null,
// byDay: ['MO', 'WE', 'FR'],
// byWeekNo: [],
// byMonth: [],
// byMonthDay: [],
// byYearDay: [],
// bySetPos: [],
// byHour: [],
// byMinute: [],
// bySecond: [],
// wkst: 'MO',
// exceptions: [],
// tzid: null,
// }Parsed Rule Properties
| Property | Type | Description |
|---|---|---|
freq | string | Frequency: SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY |
interval | number | Repeat every N periods (minimum 1) |
count | number|null | Maximum number of occurrences |
until | Date|null | End date (mutually exclusive with count) |
byDay | string[] | Days of week: 'MO', '2TU' (2nd Tuesday), '-1FR' (last Friday) |
byWeekNo | number[] | ISO week numbers (1-53, or -53 to -1) |
byMonth | number[] | Months (1-12) |
byMonthDay | number[] | Days of month (1-31, or -31 to -1) |
byYearDay | number[] | Days of year (1-366, or -366 to -1) |
bySetPos | number[] | Position within frequency period |
byHour | number[] | Hours (0-23) |
byMinute | number[] | Minutes (0-59) |
bySecond | number[] | Seconds (0-59) |
wkst | string | Week start day (default 'MO') |
exceptions | Date[] | Exception dates (EXDATE) |
tzid | string|null | Timezone identifier |
buildRRule(rule)
Generate an RRULE string from a rule object.
const rruleStr = RRuleParser.buildRRule({
freq: 'MONTHLY',
byDay: ['-1FR'],
count: 6,
});
// 'FREQ=MONTHLY;COUNT=6;BYDAY=-1FR'getDescription(rule)
Generate a human-readable description.
RRuleParser.getDescription(rule);
// 'Every week on Monday, Wednesday, Friday, 12 times'Validation
The parser validates rules on parse:
RRULEcannot have bothCOUNTandUNTILintervalminimum is 1byMonthvalues are clamped to 1-12byMonthDayvalues are clamped to -31 to 31 (excluding 0)byWeekNovalues are clamped to -53 to 53 (excluding 0)byYearDayvalues are clamped to -366 to 366 (excluding 0)
RecurrenceEngine
Static methods for expanding recurring events into individual occurrences within a date range.
expandEvent(event, rangeStart, rangeEnd, maxOccurrences?, timezone?)
Expand a recurring event into occurrences within a date range.
import { RecurrenceEngine } from '@forcecalendar/core';
const event = {
id: 'weekly', title: 'Weekly review', recurring: true,
start: new Date('2026-01-05T09:00:00'),
end: new Date('2026-01-05T10:00:00'),
recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO;COUNT=12',
};
const occurrences = RecurrenceEngine.expandEvent(
event,
new Date('2026-03-01'),
new Date('2026-03-31'),
365, // maxOccurrences safety limit (default: 365)
'America/New_York' // timezone
);
// Returns array of Event-like objects, each with adjusted start/end datesThe engine returns occurrence data with adjusted start/end and recurrence metadata such as recurringEventId. EventStore wraps rendered occurrences as Event objects carrying isOccurrence, recurringEventId and occurrenceStart. Do not conflate the raw engine result with a stored master or its rendered occurrence ID.
Regardless of the maxOccurrences parameter, expansion is capped by a MAX_OCCURRENCES_HARD_LIMIT of 10,000 to prevent runaway expansion from malformed or unbounded rules.
Supported Frequencies
| Frequency | BYDAY | BYMONTHDAY | BYSETPOS |
|---|---|---|---|
DAILY | Filters to matching weekdays | - | - |
WEEKLY | Selects specific weekdays | - | - |
MONTHLY | Nth weekday (2TU, -1FR) | Specific days of month | Position filter |
YEARLY | Nth weekday in month | Day of month | Position filter |
Exception Handling
The engine respects EXDATE exceptions stored in the rule:
const rule = RRuleParser.parse(
'FREQ=WEEKLY;BYDAY=MO;EXDATE=20260309T090000Z'
);
// March 9 occurrence will be skippedaddExceptions(rule, exceptions, options?)
Add exception dates to an existing rule.
RecurrenceEngine.addExceptions(rule, [
new Date('2026-03-16'),
new Date('2026-03-23'),
]);getNextOccurrence(currentDate, rule, timezone?)
Get the next occurrence after a given date.
const next = RecurrenceEngine.getNextOccurrence(
new Date(),
rule,
'America/New_York'
);RecurrenceEngineV2
Instance-based recurrence engine with three capabilities beyond the static engine:
- Modified instances -- Edit individual occurrences of a recurring event
- DST-aware expansion -- Pre-calculates DST transitions for accurate timing
- Occurrence caching -- LRU cache (100 entries) for repeated expansions
import { RecurrenceEngineV2 } from '@forcecalendar/core';
const engine = new RecurrenceEngineV2();expandEvent(event, rangeStart, rangeEnd, options?)
const occurrences = engine.expandEvent(event, rangeStart, rangeEnd, {
maxOccurrences: 365,
timezone: 'America/New_York',
useCache: true,
});Modified Instances
Edit a single occurrence without changing the recurring rule:
// Change the March 10 occurrence
engine.addModifiedInstance('evt_weekly', new Date('2026-03-10'), {
title: 'Special Standup',
location: 'Room 202',
start: new Date('2026-03-10T09:30:00'),
});
// Retrieve modification
const mod = engine.getModifiedInstance('evt_weekly', new Date('2026-03-10'));Exception Management
// Cancel an occurrence
engine.addException('evt_weekly', new Date('2026-03-17'), 'Holiday');
// Check if a date is an exception
engine.isException('evt_weekly', new Date('2026-03-17')); // true
// Get the reason
engine.getExceptionReason('evt_weekly', new Date('2026-03-17')); // 'Holiday'DST Handling
RecurrenceEngineV2 pre-calculates DST transitions for the expansion range using findDSTTransitions(). When an occurrence falls on a DST boundary, adjustForDST() corrects the time to maintain the intended wall-clock time.
For example, a 9:00 AM weekly meeting stays at 9:00 AM even when clocks change, rather than shifting to 8:00 AM or 10:00 AM.
Common RRULE Examples
FREQ=DAILY Every day
FREQ=DAILY;INTERVAL=2 Every other day
FREQ=WEEKLY;BYDAY=MO,WE,FR Mon/Wed/Fri weekly
FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH Every other Tue/Thu
FREQ=MONTHLY;BYMONTHDAY=1 First of every month
FREQ=MONTHLY;BYMONTHDAY=-1 Last day of every month
FREQ=MONTHLY;BYDAY=2MO Second Monday monthly
FREQ=MONTHLY;BYDAY=-1FR Last Friday monthly
FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15 March 15 yearly
FREQ=YEARLY;BYMONTH=11;BYDAY=4TH 4th Thursday in November
FREQ=WEEKLY;BYDAY=MO;COUNT=10 10 Mondays
FREQ=DAILY;UNTIL=20261231T235959Z Daily until end of 2026Lazy occurrence queries
Both engines expose iterateOccurrences(event, options), nextOccurrence(event, after, options) and takeOccurrences(event, count, options). Use static methods on RecurrenceEngine or instance methods on RecurrenceEngineV2.
import { RecurrenceEngine } from '@forcecalendar/core';
const event = {
id: 'daily', title: 'Daily check', recurring: true,
start: new Date('2026-10-05T09:00:00Z'), end: new Date('2026-10-05T09:15:00Z'),
timeZone: 'UTC', recurrenceRule: 'FREQ=DAILY;COUNT=3',
};
const nextTwo = RecurrenceEngine.takeOccurrences(event, 2, {
after: event.start, inclusive: true, timezone: 'UTC',
});after and before are exclusive by default; inclusive: true includes both bounds. Bounds are valid Dates or timestamps. A generator is single-use. takeOccurrences caps the requested count at the 10,000 hard limit; bound consumers of an open-ended iterator explicitly.
nextOccurrence(event, after, options) honors the complete event's COUNT/UNTIL/exceptions. The older getNextOccurrence(currentDate, rule, timezone) helper only steps a parsed rule and does not provide those series-query guarantees.
EventStore exposes iterateOccurrences(eventId, options), getNextOccurrence(eventId, after, options) and takeOccurrences(eventId, count, options) against stored masters, using V2 by default.
V2 timezone contract
Core 2.5.7 corrects V2's cross-host DST stepping. options.timezone, then event.timeZone, then UTC determines the calendar-step zone; event.start anchors the series. Generated start and end are occurrence instants. This covers V2, the default engine used by EventStore, and V2-based EnhancedCalendar expansion.
import { RecurrenceEngineV2 } from '@forcecalendar/core';
const event = {
id: 'ny-daily', title: 'Daily review', recurring: true,
start: new Date('2026-03-06T09:00:00-05:00'),
end: new Date('2026-03-06T09:30:00-05:00'),
timeZone: 'America/New_York', recurrenceRule: 'FREQ=DAILY;COUNT=5',
};
const occurrences = new RecurrenceEngineV2().takeOccurrences(event, 5, {
after: event.start, inclusive: true, timezone: 'America/New_York',
});
console.log(occurrences.map(({ start }) => start.toISOString()));
// March 6–7 at 14:00Z, March 8–10 at 13:00Z: 09:00 in New York.- Daily, weekly, monthly, yearly and subdaily calendar steps resolve in the recurrence zone. A nonexistent local time advances through the DST gap; that adjusted clock time carries forward. An overlap chooses the earlier instant. Subdaily calendar increments are not constant elapsed-duration increments.
- Before instance overrides, duration remains the original
end - startmilliseconds. An override can change duration. - Floating RRULE
UNTIL/EXDATEstrings use the recurrence zone;Zvalues remain UTC. Date-based exception and modification matching uses that zone's calendar date. handleDSTis retained for compatibility;trueandfalsenow yield the same resolved instants. The option never selected elapsed-duration stepping.- Explicit invalid zones are rejected by helpers such as
getNextDate; omitted zones retain local Date behavior in those helpers.
The release was checked with 696 fixture/host combinations, plus the published package's runtime verification. That is scoped regression evidence, not complete RFC conformance. See the released contract.
Remaining limits
startUTC/endUTC metadata still uses legacy conversions that may depend on the host timezone. Use start/end for the occurrence instants covered above. The legacy static RecurrenceEngine and every timezone API have not been redesigned.
Year 0000/BCE offset handling and UTC-suffixed RRULE years 0000–0099 retain known limitations. Month-end selection and supported RRULE fields are unchanged. Interface still disables built-in recurring edit/delete/drag/resize until an explicit series/occurrence workflow is supported.
Modified-instance query boundary
V2 range filtering applies to the generated recurrence start before a stored start/end override is applied. A moved instance can therefore appear outside the requested window or change the returned ordering/duration. This is existing behavior: hosts requiring final-time filtering or ordering must apply that policy explicitly. Do not infer that every modified result remains bounded and sorted from the base iterator contract.