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/rrule

Usage

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

FieldSupportNotes
FREQSupportedYEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY.
INTERVALSupportedDefaults to 1.
BYDAYSupportedWeekday codes SU, MO, TU, WE, TH, FR, SA.
COUNTSupportedMaximum number of occurrences.
UNTILSupportedParsed as a Date.
BYMONTH, BYMONTHDAY, BYSETPOS, WKSTNot appliedIgnored 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)

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)

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"
  }
);
ParameterTypeNotes
dtStartDateFirst occurrence start.
ruleParsedRRuleUsually the output of parseRRule.
rangeStartDateInclusive lower bound.
rangeEndOrOptionsDate | { 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

Expansion 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; // 1

Use these when building rule strings programmatically.

Timezone Helpers

FunctionNotes
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

SituationBehavior
Invalid FREQ, COUNT, or UNTILThrows a descriptive Error.
Unsupported RRULE fieldIgnored.
More than 10,000 occurrencesThrows RecurrenceExpansionError.
Invalid IANA timezoneThrows Error.
DST spring-forward gap during expansionResolves 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

SymptomCheck
Too many occurrencesAdd COUNT/UNTIL or narrow rangeEnd.
Local time shifts around DSTUse the options object with timeZone.
Unsupported fields have no effectThe current beta ignores unsupported RFC 5545 fields.
Weekly dates are missingEnsure BYDAY weekday codes match the intended days.