Files
2026-05-27 23:47:53 +01:00

103 lines
4.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# recurrence
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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`.