Initial Commit
This commit is contained in:
102
packages/foundation/timing/datetime/src/recurrence/recurrence.md
Normal file
102
packages/foundation/timing/datetime/src/recurrence/recurrence.md
Normal 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`.
|
||||
Reference in New Issue
Block a user