Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
# 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`.