4.3 KiB
recurrence
(no submodules)
Purpose
The recurrence module implements a simplified iCalendar-compatible recurrence rule engine for @suaveplan/datetime. It provides parsing, formatting, and expansion of RRULE-style recurrence rules covering the most common scheduling patterns: daily, weekly, monthly, and yearly recurrences with interval, count, until, by-day-of-week, and by-day-of-month filters.
The design prioritises correctness and safety over exhaustiveness: it deliberately excludes the more exotic RFC 5545 features (BYSETPOS, BYHOUR, EXDATE, etc.) in order to maintain a clear, auditable code path that cannot produce runaway expansions.
Features
parseRRule— parse RRULE string into a typedRecurrenceRuleformatRRule— serialiseRecurrenceRuleback to RRULE string (round-trippable)nextOccurrence— compute the next single occurrence after a given dateexpandRecurrence— expand all occurrences withcountoruntilbound (required)- Cooperative cancellation via
AbortSignal(checked every 64 occurrences) - OTel histogram integration via
recordExpandRecurrence clockinjection for deterministic tests
Usage: Basic Example
import { parseRRule, expandRecurrence } from "@suaveplan/datetime/recurrence";
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6");
const dates = expandRecurrence(rule);
console.log(dates.length); // 6
console.log(dates[0].toDateString()); // next Monday/Wednesday/Friday
Usage: Advanced Example
import {
parseRRule,
formatRRule,
nextOccurrence,
expandRecurrence,
} from "@suaveplan/datetime/recurrence";
// Round-trip an RRULE string
const raw = "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3";
const rule = parseRRule(raw);
const formatted = formatRRule(rule);
console.log(formatted === raw); // true
// Deterministic expansion with a fake clock and abort signal
const clock = () => new Date("2024-01-01T00:00:00Z").getTime();
const controller = new AbortController();
const dates = expandRecurrence(
parseRRule("FREQ=DAILY;COUNT=365"),
{ clock, signal: controller.signal }
);
console.log(dates.length); // 365
// Next weekly occurrence on Mondays
const next = nextOccurrence(
parseRRule("FREQ=WEEKLY;BYDAY=MO"),
new Date("2024-03-13") // Wednesday
);
console.log(next?.toDateString()); // Mon Mar 18 2024
API Reference
parseRRule(input: string): RecurrenceRule
Parse a semicolon-delimited RRULE string. Supported fields:
FREQ(required):DAILY | WEEKLY | MONTHLY | YEARLYINTERVAL: positive integer, defaults to 1COUNT: positive integerUNTIL:YYYYMMDDorYYYYMMDDTHHmmssZBYDAY: comma-separatedSU|MO|TU|WE|TH|FR|SABYMONTHDAY: comma-separated integers 1–31
Throws DatetimeError("DATETIME.PARSE.INVALID_INPUT") when FREQ is missing or unrecognised.
formatRRule(rule: RecurrenceRule): string
Serialise a RecurrenceRule to an RRULE string. Output is round-trippable through parseRRule.
nextOccurrence(rule: RecurrenceRule, from: DateInput): Date | null
Returns the first occurrence strictly after from that matches all filters. Returns null when the rule terminates (until exceeded or count exhausted) before generating a match.
expandRecurrence(rule: RecurrenceRule, opts?: RecurrenceOptions): Date[]
Expand all occurrences into a Date[]. Requires rule.count or rule.until; throws DatetimeError("DATETIME.RECURRENCE.INFINITE_EXPANSION") when neither is set.
opts.clock— injectableClockto set the expansion start point (default:Date.now)opts.signal—AbortSignalchecked every 64 occurrences
Implementation Notes
The expansion loop uses an internal safety limit of count * 10 + 10000 to guard against degenerate filter combinations (e.g. FREQ=MONTHLY;BYMONTHDAY=31;COUNT=100 in months shorter than 31 days). In practice this limit is never reached for well-formed rules.
Month arithmetic uses the same clamping strategy as calendar.addDuration, so FREQ=MONTHLY;BYMONTHDAY=31 in February produces no occurrence for that month rather than overflowing into March.
nextOccurrence does not require count or until since it only generates a single result, but it applies a 3,650-iteration safety cap (~10 years of daily occurrences) before returning null.