103 lines
4.3 KiB
Markdown
103 lines
4.3 KiB
Markdown
# 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`.
|