RRule
Parse and expand a focused subset of iCalendar RRULE strings.
RRule
@schedulespark/rrule parses and expands a focused subset of iCalendar RRULE strings for deterministic scheduling workflows.
Related terms: npm, ScheduleSpark, @schedulespark/rrule, RRULE, recurrence rule, iCalendar, iCal, recurrence, recurring events, recurring shifts, calendar, scheduling, timezone, BYDAY, weekly recurrence, monthly recurrence, yearly recurrence, parser, expander, TypeScript.
It is used by @schedulespark/calendar, but it can also be used directly anywhere you need bounded recurrence expansion.
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")
);starts is an array of JavaScript Date values in ascending order. Expansion is bounded by the range you pass; this prevents accidental infinite materialization for unbounded rules.
React
React consumers can import hooks and a render-prop helper from @schedulespark/rrule/react.
import { RRuleOccurrences } from "@schedulespark/rrule/react";
export function OccurrencePreview(): JSX.Element {
return (
<RRuleOccurrences
dtStart={new Date("2026-07-06T09:00:00.000Z")}
rangeStart={new Date("2026-07-06T00:00:00.000Z")}
rangeEnd={new Date("2026-07-13T00:00:00.000Z")}
ruleString="FREQ=WEEKLY;BYDAY=MO,WE,FR"
>
{({ error, occurrences }) =>
error ? (
<p>{error.message}</p>
) : (
<ul>
{occurrences.map((date) => (
<li key={date.toISOString()}>{date.toISOString()}</li>
))}
</ul>
)
}
</RRuleOccurrences>
);
}Use hooks when you only need parsed or expanded state:
import { useRRule, useRRuleOccurrences } from "@schedulespark/rrule/react";
const parsed = useRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR");
const expanded = useRRuleOccurrences({
dtStart,
rangeStart,
rangeEnd,
ruleString: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
timeZone: "America/New_York"
});These helpers are headless. They do not provide a visual recurrence-rule editor.
Timezone-Aware Expansion
const starts = expandRRuleOccurrences(
new Date("2026-03-07T14:00:00.000Z"),
parseRRule("FREQ=DAILY"),
new Date("2026-03-07T00:00:00.000Z"),
{
rangeEnd: new Date("2026-03-10T00:00:00.000Z"),
timeZone: "America/New_York"
}
);Pass timeZone when the recurrence should stay pinned to local wall-clock time across Daylight Saving Time transitions. Omitting timeZone, or passing a bare Date as the fourth argument, expands in UTC.
Supported Fields
| Field | Support | Notes |
|---|---|---|
FREQ | Supported | YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY. |
INTERVAL | Supported | Defaults to 1. |
BYDAY | Supported | Weekday codes SU, MO, TU, WE, TH, FR, SA. |
COUNT | Supported | Maximum number of occurrences. |
UNTIL | Supported | Parsed as a Date. |
BYMONTH, BYMONTHDAY, BYSETPOS, WKST | Not applied | Ignored in the current beta instead of throwing. |
This is not a full RFC 5545 implementation yet. It intentionally supports the recurrence fields used by ScheduleSpark scheduling flows.
API Reference
parseRRule(rule)
parseRRule(rule)import { parseRRule } from "@schedulespark/rrule";
const parsed = parseRRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=8");Returns:
interface ParsedRRule {
freq: "YEARLY" | "MONTHLY" | "WEEKLY" | "DAILY" | "HOURLY" | "MINUTELY" | "SECONDLY";
interval: number;
byDay?: number[];
count?: number;
until?: Date;
}byDay uses JavaScript weekday numbers: 0 Sunday through 6 Saturday. Malformed required values throw at parse time rather than producing a partially valid rule.
expandRRuleOccurrences(dtStart, rule, rangeStart, rangeEndOrOptions)
expandRRuleOccurrences(dtStart, rule, rangeStart, rangeEndOrOptions)const occurrences = expandRRuleOccurrences(
new Date("2026-07-06T09:00:00.000Z"),
parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR"),
new Date("2026-07-01T00:00:00.000Z"),
{
rangeEnd: new Date("2026-08-01T00:00:00.000Z"),
timeZone: "America/New_York"
}
);| Parameter | Type | Notes |
|---|---|---|
dtStart | Date | First occurrence start. |
rule | ParsedRRule | Usually the output of parseRRule. |
rangeStart | Date | Inclusive lower bound. |
rangeEndOrOptions | Date | { rangeEnd: Date; timeZone?: string } | Pass a Date for UTC expansion, or options for timezone-aware expansion. |
The returned dates are occurrence start times within [rangeStart, rangeEnd).
RecurrenceExpansionError
RecurrenceExpansionErrorExpansion is capped at 10,000 generated occurrences. If a rule/range combination would exceed that limit, the package throws RecurrenceExpansionError instead of hanging or exhausting memory.
import { RecurrenceExpansionError } from "@schedulespark/rrule";
try {
expandRRuleOccurrences(dtStart, parseRRule("FREQ=SECONDLY"), rangeStart, rangeEnd);
} catch (error) {
if (error instanceof RecurrenceExpansionError) {
// Narrow the range or add COUNT/UNTIL.
} else {
throw error;
}
}Constants
import { Frequencies, Weekdays } from "@schedulespark/rrule";
Frequencies.WEEKLY; // "WEEKLY"
Weekdays.MO; // 1Use these when building rule strings programmatically.
Timezone Helpers
| Function | Notes |
|---|---|
getZonedParts(date, timeZone) | Converts an instant into local date/time fields. |
zonedPartsToDate(parts, timeZone) | Converts local fields back to an instant. |
localDateToZonedMidnight(date, timeZone) | Converts a YYYY-MM-DD local date to the instant at local midnight. |
These helpers are lower-level than expandRRuleOccurrences. Most consumers should use the expansion API directly.
Error Handling
| Situation | Behavior |
|---|---|
Invalid FREQ, COUNT, or UNTIL | Throws a descriptive Error. |
| Unsupported RRULE field | Ignored. |
| More than 10,000 occurrences | Throws RecurrenceExpansionError. |
| Invalid IANA timezone | Throws Error. |
| DST spring-forward gap during expansion | Resolves to the nearest valid instant for recurrence expansion. |
Common Patterns
parseRRule("FREQ=DAILY;INTERVAL=2");
parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR");
parseRRule("FREQ=MONTHLY;COUNT=6");
parseRRule("FREQ=YEARLY;UNTIL=20271231T235959Z");Troubleshooting
| Symptom | Check |
|---|---|
| Too many occurrences | Add COUNT/UNTIL or narrow rangeEnd. |
| Local time shifts around DST | Use the options object with timeZone. |
| Unsupported fields have no effect | The current beta ignores unsupported RFC 5545 fields. |
| Weekly dates are missing | Ensure BYDAY weekday codes match the intended days. |