# 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 typed `RecurrenceRule` - `formatRRule` — serialise `RecurrenceRule` back to RRULE string (round-trippable) - `nextOccurrence` — compute the next single occurrence after a given date - `expandRecurrence` — expand all occurrences with `count` or `until` bound (required) - Cooperative cancellation via `AbortSignal` (checked every 64 occurrences) - OTel histogram integration via `recordExpandRecurrence` - `clock` injection for deterministic tests ## Usage: Basic Example ```ts 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 ```ts 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 | YEARLY` - `INTERVAL`: positive integer, defaults to 1 - `COUNT`: positive integer - `UNTIL`: `YYYYMMDD` or `YYYYMMDDTHHmmssZ` - `BYDAY`: comma-separated `SU|MO|TU|WE|TH|FR|SA` - `BYMONTHDAY`: 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` — injectable `Clock` to set the expansion start point (default: `Date.now`) - `opts.signal` — `AbortSignal` checked 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`.