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

4.3 KiB
Raw Permalink Blame History

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

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

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.