rrule-temporal
Version:
Recurrence rule (rrule) processing using Temporal PlainDate/PlainDateTime, with cross-timezone and cross-calendar rrule support
347 lines (271 loc) • 13.4 kB
Markdown
# rrule-temporal
The first and only fully compliant Recurrence rule ([RFC-5545](https://www.rfc-editor.org/rfc/rfc5545.html)) processing JS/TS library built on the Temporal API, now with support for [RFC-7529](https://www.rfc-editor.org/rfc/rfc7529.html) (RSCALE / SKIP) for non-Gregorian calendars.
The library accepts the familiar `RRULE` format and returns
`Temporal.ZonedDateTime` instances for easy time‑zone aware scheduling.
See the [demo site](https://ggaabe.github.io/rrule-temporal/) for an interactive playground.
> This library was created to advance the rrule library to use Temporal, and to provide a more modern API, as the original rrule.js library is [not maintained anymore](https://github.com/jkbrzt/rrule/issues/615) and based on Date. Its maintainers suggested a new library based on Temporal instead of Date should be created:
>https://github.com/jkbrzt/rrule/issues/450#issuecomment-1055853095
## Installation
```bash
npm install rrule-temporal
```
## Quick start
Parse an ICS snippet and enumerate the occurrences:
```typescript
import { RRuleTemporal } from "rrule-temporal";
const rule = new RRuleTemporal({
rruleString: `DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3`
});
rule.all().forEach(dt => console.log(dt.toString()));
// 2025-01-01T09:00:00[UTC]
// 2025-01-02T09:00:00[UTC]
// 2025-01-03T09:00:00[UTC]
// Only the first 10 events
const firstTen = rule.all((_, i) => i < 10);
```
## Creating a rule with options
Instead of a full ICS string you can supply the recurrence parameters directly:
```typescript
import { Temporal } from "@js-temporal/polyfill";
const rule = new RRuleTemporal({
freq: "DAILY",
interval: 2,
count: 3,
byHour: [9],
byMinute: [15],
tzid: "America/Chicago",
dtstart: Temporal.ZonedDateTime.from({
year: 2025, month: 4, day: 20,
hour: 8, minute: 30,
timeZone: "America/Chicago"
})
});
rule.all().forEach(dt => console.log(dt.toString()));
```
### Manual options
When creating a rule with individual fields you can specify any of the options
below. These correspond to the recurrence rule parts defined in RFC 5545:
| Option | Description |
| ------ | ----------- |
| `freq` | Recurrence frequency (`"YEARLY"`, `"MONTHLY"`, `"WEEKLY"`, `"DAILY"`, `"HOURLY"`, `"MINUTELY"`, `"SECONDLY"`). |
| `interval` | Interval between each occurrence of `freq`. |
| `count` | Total number of occurrences. |
| `until` | Last possible occurrence as `Temporal.ZonedDateTime`. |
| `byHour` | Hours to include (0–23). |
| `byMinute` | Minutes to include (0–59). |
| `bySecond` | Seconds to include (0–59). |
| `byDay` | List of weekday codes, e.g. `["MO", "WE", "FR"]`. |
| `byMonth` | Months of the year (1–12). |
| `byMonthDay` | Days of the month (1–31 or negative from end). |
| `byYearDay` | Days of the year (1–366 or negative from end). |
| `byWeekNo` | ISO week numbers (1–53 or negative from end). |
| `bySetPos` | Select n-th occurrence(s) after other filters. |
| `wkst` | Weekday on which the week starts (`"MO"`..`"SU"`). |
| `rDate` | Additional dates to include. |
| `exDate` | Exception dates to exclude. |
| `tzid` | Time zone identifier for interpreting dates. |
| `maxIterations` | Safety cap when generating occurrences. |
| `includeDtstart` | Include `DTSTART` even if it does not match the pattern. |
| `dtstart` | First occurrence as `Temporal.ZonedDateTime`. |
## Querying occurrences
Use the provided methods to enumerate or search for occurrences:
```typescript
// Get all events within a window
const start = new Date(Date.UTC(2025, 3, 2, 0, 0));
const end = new Date(Date.UTC(2025, 3, 4, 5, 0));
const hits = rule.between(start, end, true);
// Next and previous occurrences
const next = rule.next();
const prev = rule.previous(new Date("2025-05-01T00:00Z"));
```
## Converting to human-readable text
The `toText` helper converts a rule into a human readable description.
```typescript
import { Temporal } from "@js-temporal/polyfill";
import { RRuleTemporal } from "rrule-temporal";
import { toText } from "rrule-temporal/totext";
const rule = new RRuleTemporal({
rruleString: `DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3`
});
rule.toString();
// "DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3"
toText(rule); // uses the runtime locale, defaults to English
toText(rule, "es"); // Spanish description
toText(rule);
// "every day for 3 times"
const weekly = new RRuleTemporal({
freq: "WEEKLY",
byDay: ["SU"],
byHour: [10],
dtstart: Temporal.ZonedDateTime.from({
year: 2025, month: 1, day: 1, hour: 10, timeZone: "UTC"
})
});
toText(weekly);
// "every week on Sunday at 10 AM UTC"
toText(weekly, "es");
// "cada semana en domingo a las 10 AM UTC"
```
### `toText` supported languages
`toText()` currently ships translations for the following languages:
| Code | Language |
| ---- | -------- |
| en | English |
| es | Spanish |
| hi | Hindi |
| yue | Cantonese |
| ar | Arabic |
| he | Hebrew |
| zh | Mandarin |
**NOTE:** At build time you can reduce bundle size by
defining the `TOTEXT_LANGS` environment variable (read from `process.env`),
e.g. `TOTEXT_LANGS=en,es,ar`. When this environment variable is unavailable
(such as in browser builds where `process` is undefined) all languages are
included by default.
### RFC 7529 (RSCALE / SKIP)
This library implements the iCalendar RSCALE and SKIP extensions described in RFC 7529 for defining recurrence rules in non‑Gregorian calendars and for controlling how invalid dates are handled.
- Spec: RFC 7529 — Non‑Gregorian Recurrence Rules in iCalendar
https://www.rfc-editor.org/rfc/rfc7529.html
What RSCALE does:
- Extends `RRULE` with `RSCALE=<calendar>` to choose the calendar used for recurrence generation while keeping DTSTART/RECURRENCE‑ID/RDATE/EXDATE in Gregorian.
- Supported calendars: `GREGORIAN`, `CHINESE`, `HEBREW`, `INDIAN` (Saka/Indian National Calendar).
- Interprets `BY*` parts (month, day, week, etc.) in the specified calendar when expanding occurrences, then converts the generated dates back to the requested time zone.
What SKIP does:
- Extends `RRULE` with `SKIP=OMIT|BACKWARD|FORWARD` (only when `RSCALE` is present).
- Controls how invalid dates produced by the rule are handled (e.g., Feb 29 in non‑leap years, or months that don’t have the desired day):
- `OMIT` (default): drop the invalid occurrence.
- `BACKWARD`: move to the previous valid day/month (e.g., Feb 28).
- `FORWARD`: move to the next valid day/month (e.g., Mar 1).
- RFC 7529 defines the evaluation order; notably, SKIP may apply after `BYMONTH` (invalid month) and after `BYMONTHDAY` (invalid day). If SKIP changes the month and that leads to an invalid day‑of‑month, SKIP is re‑applied for the day step.
Leap months and BYMONTH:
- `BYMONTH` accepts leap‑month tokens with an `L` suffix (e.g., `5L`) under RSCALE. These are matched against the target calendar’s `monthCode` (e.g., Chinese `M06L`, Hebrew `M05L`).
- Example tokens:
- Chinese: `5L` matches `monthCode=M05L` (leap 5th) or `M06L` depending on calendar system; we match by the numeric part + `L` via `monthCode`.
- Hebrew: `5L` typically corresponds to Adar I (`monthCode=M05L`).
- Numeric months without `L` (e.g., `5`) match the regular month (e.g., `monthCode=M05`).
Supported RSCALE coverage in this library:
- Frequencies: `YEARLY`, `MONTHLY`, `WEEKLY` with Chinese/Hebrew calendars.
- Constraints: `BYMONTH` (including leap tokens), `BYMONTHDAY`, `BYDAY` (weekday tokens; ordinal support at monthly/yearly levels), `BYYEARDAY`, `BYWEEKNO`, `BYSETPOS`.
- Sub‑daily (`DAILY`, `HOURLY`, `MINUTELY`) behavior:
- The engine first filters eligible calendar days using `BYWEEKNO`, `BYYEARDAY`, `BYMONTH`, `BYMONTHDAY`, and simple `BYDAY` (weekday codes). Then it expands times via `BYHOUR`/`BYMINUTE`/`BYSECOND`.
- For `HOURLY`/`MINUTELY`, INTERVAL alignment is based on elapsed real hours/minutes since `DTSTART`. Occurrences are kept when the elapsed units are multiples of `INTERVAL`.
- Ordinal `BYDAY` (e.g., `1MO`, `-1SU`) is not interpreted at sub‑daily RSCALE levels; use `MONTHLY`/`YEARLY` for these.
Examples
Chinese New Year (1st day of 1st Chinese month), year over year from a Gregorian DTSTART:
```ics
DTSTART;VALUE=DATE:20130210
RRULE:RSCALE=CHINESE;FREQ=YEARLY
```
Hebrew New Year (Tishrei 1) — using BYYEARDAY=1 in Hebrew calendar:
```ics
DTSTART;TZID=UTC:20230916T090000
RRULE:RSCALE=HEBREW;FREQ=YEARLY;BYYEARDAY=1;BYHOUR=9
```
Feb 29 birthday — SKIP strategies:
```ics
DTSTART;TZID=UTC:20160229T120000
RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=OMIT
```
```ics
DTSTART;TZID=UTC:20160229T120000
RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=BACKWARD
```
```ics
DTSTART;TZID=UTC:20160229T120000
RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=FORWARD
```
Notes
- SKIP MUST NOT be present unless RSCALE is present (per RFC 7529).
- Default SKIP is `OMIT` when RSCALE is present.
- This library surfaces `RSCALE`/`SKIP` in `toText()` at the end of the description: e.g., `(RSCALE=HEBREW;SKIP=OMIT)`.
## API
| Method | Description |
| ------ | ----------- |
| `new RRuleTemporal(opts)` | Create a rule from an ICS snippet or manual options. |
| `all(iterator?)` | Return every occurrence. When the rule has no end the optional iterator is required. |
| `between(after, before, inclusive?)` | Occurrences within a time range. |
| `next(after?, inclusive?)` | Next occurrence after a given date. |
| `previous(before?, inclusive?)` | Previous occurrence before a date. |
| `toString()` | Convert the rule back into `DTSTART` and `RRULE` lines. |
| `toText(rule, locale?)` | Human readable description (`en`, `es`, `hi`, `yue`, `ar`, `he`, `zh`). |
| `options()` | Return the normalized options object. |
## Further examples
Enumerating weekdays within a month or rotating through months can be achieved
with the more advanced RFC 5545 fields:
```typescript
// 2nd & 4th Fridays each month at midnight CT, first 6 occurrences
const ruleA = new RRuleTemporal({
rruleString: `DTSTART;TZID=America/Chicago:20250325T000000\nRRULE:FREQ=MONTHLY;BYDAY=2FR,4FR;BYHOUR=0;BYMINUTE=0;COUNT=6`
});
ruleA.all().forEach(dt => console.log(dt.toString()));
// Rotate yearly through Jan, Jun and Dec at 09:00 UTC
const dtstart = Temporal.ZonedDateTime.from({
year: 2025, month: 1, day: 10, hour: 9, minute: 0, timeZone: "UTC"
});
const ruleB = new RRuleTemporal({
freq: "YEARLY",
interval: 1,
count: 4,
byMonth: [1, 6, 12],
byHour: [9],
byMinute: [0],
dtstart
});
ruleB.all().forEach(dt => console.log(dt.toString()));
```
### Working with extra and excluded dates
```typescript
import { Temporal } from "@js-temporal/polyfill";
const start = Temporal.ZonedDateTime.from({
year: 2025, month: 1, day: 1, hour: 12, timeZone: "UTC"
});
const ruleC = new RRuleTemporal({
freq: "WEEKLY",
count: 5,
rDate: [start.add({ days: 1 })], // add one extra day
exDate: [start.add({ weeks: 2 })], // skip the third week
dtstart: start
});
// First five occurrences (with rDate/exDate accounted for)
ruleC.all((_, i) => i < 5).forEach(dt => console.log(dt.toString()));
// Occurrences within a window
const hits = ruleC.between(
new Date("2025-01-01T00:00Z"),
new Date("2025-02-01T00:00Z"),
true
);
```
### Converting between **@js-temporal/polyfill** and **temporal-polyfill**
`rrule-temporal` ships with **`@js-temporal/polyfill`** and therefore returns `Temporal` objects that are from that implementation. If the rest of your codebase (or a third-party package) relies on the lighter **`temporal-polyfill`** package or a native Temporal implementation, those objects will **not** satisfy `instanceof` checks in your app.
This snippet shows how to re-hydrate each recurrence result into the polyfill your project expects.
```ts
// rrule-temporal (and its internals) use @js-temporal/polyfill
import { Temporal as RRTTemporal } from "@js-temporal/polyfill";
// your application using temporal-polyfill or native Temporal
import { Temporal as AppTemporal } from "temporal-polyfill";
import { RRuleTemporal } from "rrule-temporal";
/** Weekly rule that fires 4 times starting 5 May 2025, 10 AM America/Chicago. */
const rule = new RRuleTemporal({
freq: "WEEKLY",
count: 4,
dtstart: RRTTemporal.ZonedDateTime.from(
"2025-05-05T10:00[America/Chicago]"
),
});
// Occurrences are ZonedDateTime instances from @js-temporal/polyfill
const rawOccurrences = rule.all();
/** Convert each ZonedDateTime to temporal-polyfill ZonedDateTime. */
const appOccurrences = rawOccurrences.map((zdt) =>
AppTemporal.ZonedDateTime.from(zdt.toString())
);
// …now `appOccurrences` can be passed anywhere that expects temporal-polyfill in your app
```
#### Why `.toString()`?
`Temporal.*.from()` accepts ISO 8601 strings (including bracketed time-zone annotations), so calling `toString()` sidesteps the internal-slot branding that makes polyfill objects incompatible.
#### Nanosecond precision variant
```ts
const appOccurrences = rawOccurrences.map((zdt) =>
AppTemporal.ZonedDateTime.fromEpochNanoseconds(zdt.epochNanoseconds)
);
```
Both approaches preserve the original calendar, time-zone and nanosecond accuracy.