forceCalendar/Docs
Core Package

Recurrence

RFC 5545 recurrence rule parsing, expansion, and modified instance handling.

Overview

forceCalendar provides three classes for recurrence handling:

ClassAPI StylePurpose
RRuleParserStaticParse/build RRULE strings, validate rules
RecurrenceEngineStaticExpand recurring events into occurrences
RecurrenceEngineV2InstanceEnhanced 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

PropertyTypeDescription
freqstringFrequency: SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY
intervalnumberRepeat every N periods (minimum 1)
countnumber|nullMaximum number of occurrences
untilDate|nullEnd date (mutually exclusive with count)
byDaystring[]Days of week: 'MO', '2TU' (2nd Tuesday), '-1FR' (last Friday)
byWeekNonumber[]ISO week numbers (1-53, or -53 to -1)
byMonthnumber[]Months (1-12)
byMonthDaynumber[]Days of month (1-31, or -31 to -1)
byYearDaynumber[]Days of year (1-366, or -366 to -1)
bySetPosnumber[]Position within frequency period
byHournumber[]Hours (0-23)
byMinutenumber[]Minutes (0-59)
bySecondnumber[]Seconds (0-59)
wkststringWeek start day (default 'MO')
exceptionsDate[]Exception dates (EXDATE)
tzidstring|nullTimezone 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:

  • RRULE cannot have both COUNT and UNTIL
  • interval minimum is 1
  • byMonth values are clamped to 1-12
  • byMonthDay values are clamped to -31 to 31 (excluding 0)
  • byWeekNo values are clamped to -53 to 53 (excluding 0)
  • byYearDay values 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 dates

The 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

FrequencyBYDAYBYMONTHDAYBYSETPOS
DAILYFilters to matching weekdays--
WEEKLYSelects specific weekdays--
MONTHLYNth weekday (2TU, -1FR)Specific days of monthPosition filter
YEARLYNth weekday in monthDay of monthPosition 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 skipped

addExceptions(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:

  1. Modified instances -- Edit individual occurrences of a recurring event
  2. DST-aware expansion -- Pre-calculates DST transitions for accurate timing
  3. 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 2026

Lazy 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 - start milliseconds. An override can change duration.
  • Floating RRULE UNTIL/EXDATE strings use the recurrence zone; Z values remain UTC. Date-based exception and modification matching uses that zone's calendar date.
  • handleDST is retained for compatibility; true and false now 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.