# @schedulespark/rrule

Small TypeScript utilities for parsing and expanding a focused subset of iCalendar RRULE strings.

This package is used by `@schedulespark/calendar`, but it can also be used directly anywhere you need deterministic recurrence expansion.

Full documentation: https://docs.schedulespark.com/docs/rrule

## Screenshot

![RRULE expansion diagram showing Monday, Wednesday, and Friday occurrences within a bounded date window](https://cdn.jsdelivr.net/npm/@schedulespark/rrule/docs/screenshots/rrule-expansion.jpg)

## Core Concepts

- **Focused RRULE subset:** supports the recurrence fields used by ScheduleSpark scheduling flows.
- **Deterministic expansion:** expansion uses JavaScript `Date` values. UTC is the default, and callers can pass an IANA timezone for local wall-clock recurrence.
- **Bounded generation:** callers provide a start and end window so unbounded recurrence rules do not materialize infinite occurrences.
- **Small API surface:** parse a rule once with `parseRRule`, then expand occurrences with `expandRRuleOccurrences`.

## Install

```sh
npm install @schedulespark/rrule
```

## Usage

```ts
import { expandRRuleOccurrences, parseRRule } from "@schedulespark/rrule";

const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR");
const starts = expandRRuleOccurrences(
  new Date("2026-07-06T09:00:00.000Z"),
  rule,
  new Date("2026-07-06T00:00:00.000Z"),
  new Date("2026-07-13T00:00:00.000Z")
);
```

For site-local schedules, pass an IANA timezone in the range options. The local wall-clock time is preserved across DST:

```ts
const starts = expandRRuleOccurrences(
  new Date("2026-03-07T14:00:00.000Z"), // 09:00 in America/New_York
  parseRRule("FREQ=DAILY"),
  new Date("2026-03-07T00:00:00.000Z"),
  {
    rangeEnd: new Date("2026-03-10T00:00:00.000Z"),
    timeZone: "America/New_York"
  }
);
```

## Supported RRULE Fields

- `FREQ` — all seven RFC 5545 values: `YEARLY`, `MONTHLY`, `WEEKLY`, `DAILY`, `HOURLY`, `MINUTELY`, `SECONDLY`.
- `INTERVAL`
- `BYDAY`
- `COUNT`
- `UNTIL`

This is not a full RFC 5545 implementation yet. Unsupported fields (e.g. `BYMONTH`, `BYMONTHDAY`, `BYSETPOS`, `WKST`) are ignored by design in the current beta — parsing never throws on an unsupported field, it just doesn't apply it.

## API Reference

### `parseRRule(rule: string): ParsedRRule`

Parses an iCalendar RRULE string (without the `RRULE:` prefix) into a `ParsedRRule`:

```ts
interface ParsedRRule {
  freq: "YEARLY" | "MONTHLY" | "WEEKLY" | "DAILY" | "HOURLY" | "MINUTELY" | "SECONDLY";
  interval: number;
  byDay?: number[]; // 0 = Sunday .. 6 = Saturday
  count?: number;
  until?: Date;
}
```

`interval` defaults to `1` when `INTERVAL` is absent. Malformed `FREQ`, `COUNT`, or `UNTIL` values throw a descriptive `Error` at parse time rather than silently producing an invalid rule.

### `expandRRuleOccurrences(dtStart, rule, rangeStart, rangeEndOrOptions): Date[]`

Expands a parsed rule into concrete occurrence start times within `[rangeStart, rangeEnd)`.

| Parameter | Type | Notes |
|---|---|---|
| `dtStart` | `Date` | The first occurrence's start time. |
| `rule` | `ParsedRRule` | Typically the output of `parseRRule`. |
| `rangeStart` | `Date` | Inclusive lower bound of the expansion window. |
| `rangeEndOrOptions` | `Date \| { rangeEnd: Date; timeZone?: string }` | Pass a `Date` for a UTC expansion, or an options object to also supply an IANA `timeZone` for local wall-clock recurrence (see below). |

Returns occurrence start `Date`s in ascending order. An expansion that would exceed 10,000 occurrences throws `RecurrenceExpansionError` (exported from the package) instead of hanging or exhausting memory — this protects against unbounded rules (no `COUNT`/`UNTIL`) combined with a very large range or a fine-grained `FREQ` like `SECONDLY`.

### `RecurrenceExpansionError`

```ts
class RecurrenceExpansionError extends Error {
  readonly limit: number; // 10_000
  readonly steps: number; // how many occurrences had been generated so far
}
```

Catch this specifically if you want to distinguish "the rule is fine but the range is too wide" from a genuine parsing bug:

```ts
import { RecurrenceExpansionError, expandRRuleOccurrences, parseRRule } from "@schedulespark/rrule";

try {
  const occurrences = expandRRuleOccurrences(dtStart, parseRRule("FREQ=SECONDLY"), rangeStart, rangeEnd);
} catch (error) {
  if (error instanceof RecurrenceExpansionError) {
    // Narrow the requested range or add a COUNT/UNTIL to the rule.
  } else {
    throw error;
  }
}
```

### `Frequencies` / `Weekdays`

Runtime constant maps, useful when building rule strings programmatically instead of hand-writing them:

```ts
import { Frequencies, Weekdays } from "@schedulespark/rrule";

Frequencies.WEEKLY; // "WEEKLY"
Weekdays.MO;         // 1 (aligned with Date#getUTCDay())
```

### Timezone helpers

Lower-level utilities used internally for timezone-aware expansion, exported for consumers who need to convert between instants and local wall-clock fields directly:

| Function | Signature | Notes |
|---|---|---|
| `getZonedParts` | `(date: Date, timeZone: string) => ZonedDateTimeParts` | Returns `{ year, month, day, hour, minute, second, millisecond }` for an instant in a given IANA timezone. |
| `zonedPartsToDate` | `(parts: ZonedDateTimeParts, timeZone: string) => Date` | Inverse of `getZonedParts`. During a spring-forward DST gap, the requested local time may not exist; this low-level function throws rather than silently returning a wrong time (the internal error class isn't exported, so catch `Error` and check `error.name === "ZonedTimeResolutionError"` if you need to detect it). `expandRRuleOccurrences` calls this internally and resolves the gap automatically — you only encounter this if you call `zonedPartsToDate`/`localDateToZonedMidnight` directly. |
| `localDateToZonedMidnight` | `(date: string, timeZone: string) => Date` | Converts a `"YYYY-MM-DD"` calendar date to the instant representing local midnight in `timeZone`. |

## Timezone-Aware Expansion

Pass `timeZone` in the options form of `expandRRuleOccurrences` to keep occurrences pinned to the same local wall-clock time across Daylight Saving Time transitions, instead of a fixed UTC offset:

```ts
const starts = expandRRuleOccurrences(
  new Date("2026-03-07T14:00:00.000Z"), // 09:00 local time in America/New_York (EST, UTC-5)
  parseRRule("FREQ=DAILY"),
  new Date("2026-03-07T00:00:00.000Z"),
  {
    rangeEnd: new Date("2026-03-10T00:00:00.000Z"),
    timeZone: "America/New_York"
  }
);
// Each occurrence stays at 09:00 America/New_York, even after the clocks
// spring forward on 2026-03-08 — the UTC offset of later occurrences shifts
// from -05:00 to -04:00 automatically.
```

Omitting `timeZone` (or passing a bare `Date` as the fourth argument) expands in UTC, where each occurrence is always exactly `interval` apart with no DST adjustment.

## Error Handling Summary

| Situation | Behavior |
|---|---|
| Malformed `FREQ`/`COUNT`/`UNTIL` in `parseRRule` | Throws `Error` with a descriptive message. |
| Unsupported field (e.g. `BYMONTH`) | Silently ignored — parsing still succeeds. |
| Expansion would exceed 10,000 occurrences | Throws `RecurrenceExpansionError`. |
| Invalid IANA `timeZone` | Throws `Error` (`Invalid timeZone "..."`). |
| Requested local time falls in a DST spring-forward gap | Resolves to the nearest valid instant instead of throwing, for `expandRRuleOccurrences`. |

## Beta Status

This is an early beta package. APIs may change before a stable `1.0.0` release.

Before publishing a new beta, review [CHANGELOG.md](CHANGELOG.md) and [docs/release.md](docs/release.md). Version changes are intentional release decisions and are not made automatically by documentation or CI updates.

## License

MIT
