Recurrence rules
parseRRule converts a supported RFC 5545 recurrence rule to a Quando rule.
You can query it, combine it with other rules, and explain its results.
Read a recurrence
Section titled “Read a recurrence”import { parseRRule } from "@kensio/quando";
const standup = parseRRule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", { start: "2026-03-30T09:30",});The required start option supplies the recurrence’s DTSTART. It determines:
- The time of day the recurrence runs at.
- The day the pattern repeats on when no
BYDAYorBYMONTHDAYnames one. - The point the recurrence begins. Nothing before it is covered.
Skip holidays
Section titled “Skip holidays”A recurrence is a rule, so .except works on it:
import { dates, parseRRule } from "@kensio/quando";
const running = standup.except(dates("2026-04-03", "2026-04-06"));The next five occurrences from 30 March 2026 are the Monday, Tuesday, Wednesday and Thursday of that week, then the Tuesday after Easter.
What an occurrence covers
Section titled “What an occurrence covers”By default, an occurrence with a time of day covers one minute from its start. This matches the interpretation of cron expressions.
A date-only start creates all-day occurrences:
parseRRule("FREQ=DAILY", { start: "2026-03-11T09:30" });// 09:30 until 09:31, every day
parseRRule("FREQ=DAILY", { start: "2026-03-11" });// whole daysThe parts
Section titled “The parts”| Part | Read as |
|---|---|
FREQ |
DAILY, WEEKLY, MONTHLY or YEARLY |
INTERVAL |
Every nth period |
UNTIL |
Date-only inclusive bound; timestamps are rejected |
WKST |
The day a week is counted from, Monday by default |
BYDAY |
Weekdays, with an optional count within the month |
BYMONTHDAY |
Days of the month, negative counting from the end |
BYMONTH |
Months |
BYHOUR |
Hours of the day |
BYMINUTE |
Minutes of the hour |
BYDAY takes a count under FREQ=MONTHLY. BYDAY=1MO is the first Monday of
the month and BYDAY=-1FR is the last Friday. Counted and bare entries mix, so
BYDAY=1MO,FR is the first Monday and every Friday.
For FREQ=YEARLY, an ordinal weekday requires BYMONTH to specify the month:
parseRRule("FREQ=YEARLY;BYMONTH=11;BYDAY=4TH", { start: "2026-01-01" });This selects the fourth Thursday of November. An ordinal weekday without
BYMONTH would count within the whole year, which Quando does not support.
parseRRule("FREQ=MONTHLY;BYDAY=-1FR;UNTIL=20260630", { start: "2026-01-01" });That runs on the last Friday of each month and stops after June.
The test suite checks recurrence expansion against worked examples from
RFC 5545 section 3.8.5.3, including daylight saving transitions. In those
tests, timestamp UNTIL bounds are replaced with equivalent final local dates
for the example start times. Unsupported forms are tested separately.
Pass the entire successful export to parseRRule(written) to preserve its
start, duration, and zone. Passing just written.rrule loses the duration.
Explicit durations use wall-clock time and must be positive and at most one day.
A full-day duration must start at midnight. Starts must have whole-minute precision.
Limits
Section titled “Limits”Timestamp UNTIL values throw ParseError. Quando supports date-only
UNTIL bounds. Exporting a timed rule with an upper date bound returns
ok: false because the timestamp bound required for a faithful round trip is
unsupported.
The following parts are unsupported. The parser rejects them by name:
| Part | Why |
|---|---|
COUNT |
Counting occurrences needs the occurrences counted, which a rule does not do. Use UNTIL, or take what you need from the interval stream |
BYSETPOS |
The nth occurrence within a period, for the same reason |
BYWEEKNO |
Week numbers have no rule to map onto |
BYYEARDAY |
Days of the year have no rule to map onto |
BYSECOND |
Quando reads recurrences down to the minute |
The parser also rejects FREQ=SECONDLY, MINUTELY, and HOURLY. Supported
frequencies step through calendar periods of a day or longer.
Write a rule out
Section titled “Write a rule out”toRRule exports a supported rule as an RRULE with a start and duration:
import { timeOfDayRange, toRRule, weekdays } from "@kensio/quando";
const written = toRRule(weekdays().and(timeOfDayRange("09:00", "17:00")), { start: "2026-03-30",});if (written.ok) { written.rrule; // FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR written.start; // 2026-03-30T09:00 written.duration; // PT8H}start supplies DTSTART in the form accepted by parseRRule. duration
specifies the length of each occurrence. In a calendar entry, represent that
length with DTEND or DURATION, separately from the RRULE.
A rule naming a zone carries it on the result as zone, for the TZID
parameter on DTSTART.
Choose the recurrence start
Section titled “Choose the recurrence start”An exported recurrence requires DTSTART, even when the Quando rule has no
start date. The exporter uses:
- The rule’s own lower bound, from
onOrAfterordatesBetween. - The
startoption, for a rule that has no bound of its own.
If neither is supplied, the exporter returns ok: false.
The exporter searches from that date and sets DTSTART to the first covered
date. RFC 5545 requires the recurrence pattern to agree with DTSTART. In
this example, a Monday rule bounded from Tuesday starts on the next Monday:
const fromTuesday = toRRule(onOrAfter("2026-03-03").and(daysOfWeek("monday")));
if (fromTuesday.ok) { fromTuesday.rrule; // FREQ=WEEKLY;BYDAY=MO fromTuesday.start; // 2026-03-09}If the rule has no occurrence from the search start onward, the exporter
returns ok: false.
Export whole calendar periods
Section titled “Export whole calendar periods”everyNthPeriod covers each selected period in full. The exporter lists all
covered days when the rule has no narrower day selection:
const fortnight = everyNthPeriod(2, "weeks", { anchor: "2026-03-02" });
const whole = toRRule(fortnight, { start: "2026-03-02" });const mondays = toRRule(fortnight.and(daysOfWeek("monday")), { start: "2026-03-02",});
if (whole.ok && mondays.ok) { whole.rrule; // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR,SA,SU mondays.rrule; // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO}WKST is written when a cycle of weeks turns over on a day other than Monday,
which is the day RFC 5545 assumes.
Unsupported conversions
Section titled “Unsupported conversions”The exporter returns ok: false with a reason when the rule cannot be
represented as a recurrence:
| The rule | Why a recurrence has no form for it |
|---|---|
.except(…) |
A recurrence selects times and never removes them |
dates |
Those are RDATE properties beside the RRULE |
| Windows of different lengths in a day | One recurrence carries one duration |
| Start times such as 09:00 and 14:30 | BYHOUR and BYMINUTE select every combination |
| An ordinal inside a daily cycle | Counting within a month needs FREQ=MONTHLY |
Time zones
Section titled “Time zones”Pass zone to evaluate the recurrence in a fixed time zone, regardless of
the query context’s zone:
const tokyoStandup = parseRRule("FREQ=WEEKLY;BYDAY=MO", { start: "2026-03-09T09:00", zone: "Asia/Tokyo",});Without a zone the rule follows the query context, the same as any other rule.
Use date-only UNTIL values such as 20261231. The final date is included
in the recurrence’s effective time zone. Timestamp forms such as
20261231T235959 and 20261231T235959Z are rejected.
Errors
Section titled “Errors”A malformed recurrence throws ParseError, a subclass of TypeError, naming
the part at fault:
parseRRule("FREQ=HOURLY", { start: "2026-03-09" });// ParseError: FREQ: HOURLY recurs faster than a day, and a rule steps through// calendar periods
parseRRule("FREQ=WEEKLY;BYDAY=1MO", { start: "2026-03-09" });// ParseError: BYDAY: an ordinal counts a weekday within a month, so it needs// FREQ=MONTHLY or FREQ=YEARLY with BYMONTH