@schedulespark/rrule
v0.0.2-beta-1-0-1
Published
Small TypeScript utilities for parsing and expanding a focused subset of iCalendar RRULE strings.
Downloads
520
Maintainers
Readme
@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

Core Concepts
- Focused RRULE subset: supports the recurrence fields used by ScheduleSpark scheduling flows.
- Deterministic expansion: expansion uses JavaScript
Datevalues. 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 withexpandRRuleOccurrences.
Install
npm install @schedulespark/rruleUsage
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:
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.INTERVALBYDAYCOUNTUNTIL
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:
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 Dates 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
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:
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:
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:
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 and docs/release.md. Version changes are intentional release decisions and are not made automatically by documentation or CI updates.
License
MIT
