rrule-temporal-polyfill 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ggaabe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,376 @@
1
+ # RRule Temporal
2
+
3
+ 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.
4
+ The library accepts the familiar `RRULE` format and returns
5
+ `Temporal.ZonedDateTime` instances for easy time‑zone aware scheduling.
6
+
7
+ See the [demo site](https://ggaabe.github.io/rrule-temporal/) for an interactive playground.
8
+
9
+ > RRule-temporal was created to advance the JS RRule ecosystem to use Temporal instead of Date, and to properly support cross-timezone and calendar aware recurrence rules, as per the suggestion of rrule.js contributors.
10
+ >https://github.com/jkbrzt/rrule/issues/450#issuecomment-1055853095
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install rrule-temporal
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ Parse an ICS snippet and enumerate the occurrences:
21
+
22
+ ```typescript
23
+ import { RRuleTemporal } from "rrule-temporal";
24
+
25
+ const rule = new RRuleTemporal({
26
+ rruleString: `DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3`
27
+ });
28
+
29
+ rule.all().forEach(dt => console.log(dt.toString()));
30
+ // 2025-01-01T09:00:00[UTC]
31
+ // 2025-01-02T09:00:00[UTC]
32
+ // 2025-01-03T09:00:00[UTC]
33
+
34
+ // Only the first 10 events
35
+ const firstTen = rule.all((_, i) => i < 10);
36
+ ```
37
+
38
+ ## Separating DTSTART and RRULE
39
+
40
+ Per RFC 5545, DTSTART and RRULE are separate properties. You can provide them separately:
41
+
42
+ ```typescript
43
+ import { Temporal } from "temporal-polyfill";
44
+
45
+ const rule = new RRuleTemporal({
46
+ rruleString: 'FREQ=DAILY;COUNT=5',
47
+ dtstart: Temporal.ZonedDateTime.from('2025-01-01T09:00:00[UTC]')
48
+ });
49
+
50
+ const occurrences = rule.all();
51
+ ```
52
+
53
+ This is useful when:
54
+ - Parsing iCalendar files where DTSTART and RRULE are on different lines
55
+ - Storing recurrence patterns separately from start dates in databases
56
+ - Building rules programmatically from user input
57
+
58
+ ## Creating a rule with options
59
+
60
+ Instead of a full ICS string you can supply the recurrence parameters directly:
61
+
62
+ ```typescript
63
+ import { Temporal } from "temporal-polyfill";
64
+
65
+ const rule = new RRuleTemporal({
66
+ freq: "DAILY",
67
+ interval: 2,
68
+ count: 3,
69
+ byHour: [9],
70
+ byMinute: [15],
71
+ tzid: "America/Chicago",
72
+ dtstart: Temporal.ZonedDateTime.from({
73
+ year: 2025, month: 4, day: 20,
74
+ hour: 8, minute: 30,
75
+ timeZone: "America/Chicago"
76
+ })
77
+ });
78
+
79
+ rule.all().forEach(dt => console.log(dt.toString()));
80
+ ```
81
+
82
+ ### Manual options
83
+
84
+ When creating a rule with individual fields you can specify any of the options
85
+ below. These correspond to the recurrence rule parts defined in RFC&nbsp;5545:
86
+
87
+ | Option | Description |
88
+ | ------ | ----------- |
89
+ | `freq` | Recurrence frequency (`"YEARLY"`, `"MONTHLY"`, `"WEEKLY"`, `"DAILY"`, `"HOURLY"`, `"MINUTELY"`, `"SECONDLY"`). |
90
+ | `interval` | Interval between each occurrence of `freq`. |
91
+ | `count` | Total number of occurrences. |
92
+ | `until` | Last possible occurrence as `Temporal.ZonedDateTime`. |
93
+ | `byHour` | Hours to include (0&ndash;23). |
94
+ | `byMinute` | Minutes to include (0&ndash;59). |
95
+ | `bySecond` | Seconds to include (0&ndash;59). |
96
+ | `byDay` | List of weekday codes, e.g. `["MO", "WE", "FR"]`. |
97
+ | `byMonth` | Months of the year (1&ndash;12). |
98
+ | `byMonthDay` | Days of the month (1&ndash;31 or negative from end). |
99
+ | `byYearDay` | Days of the year (1&ndash;366 or negative from end). |
100
+ | `byWeekNo` | ISO week numbers (1&ndash;53 or negative from end). |
101
+ | `bySetPos` | Select n-th occurrence(s) after other filters. |
102
+ | `wkst` | Weekday on which the week starts (`"MO"`..`"SU"`). |
103
+ | `rDate` | Additional dates to include. |
104
+ | `exDate` | Exception dates to exclude. |
105
+ | `tzid` | Time zone identifier for interpreting dates. |
106
+ | `maxIterations` | Safety cap when generating occurrences. |
107
+ | `includeDtstart` | Include `DTSTART` even if it does not match the pattern. |
108
+ | `dtstart` | First occurrence as `Temporal.ZonedDateTime`. |
109
+
110
+ ## Querying occurrences
111
+
112
+ Use the provided methods to enumerate or search for occurrences:
113
+
114
+ ```typescript
115
+ // Get all events within a window
116
+ const start = new Date(Date.UTC(2025, 3, 2, 0, 0));
117
+ const end = new Date(Date.UTC(2025, 3, 4, 5, 0));
118
+ const hits = rule.between(start, end, true);
119
+
120
+ // Next and previous occurrences
121
+ const next = rule.next();
122
+ const prev = rule.previous(new Date("2025-05-01T00:00Z"));
123
+ ```
124
+
125
+ ## Converting to human-readable text
126
+
127
+ The `toText` helper converts a rule into a human readable description.
128
+
129
+ ```typescript
130
+ import { Temporal } from "temporal-polyfill";
131
+ import { RRuleTemporal } from "rrule-temporal";
132
+ import { toText } from "rrule-temporal/totext";
133
+
134
+ const rule = new RRuleTemporal({
135
+ rruleString: `DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3`
136
+ });
137
+
138
+ rule.toString();
139
+ // "DTSTART;TZID=UTC:20250101T090000\nRRULE:FREQ=DAILY;COUNT=3"
140
+ toText(rule); // uses the runtime locale, defaults to English
141
+ toText(rule, "es"); // Spanish description
142
+ toText(rule);
143
+ // "every day for 3 times"
144
+
145
+ const weekly = new RRuleTemporal({
146
+ freq: "WEEKLY",
147
+ byDay: ["SU"],
148
+ byHour: [10],
149
+ dtstart: Temporal.ZonedDateTime.from({
150
+ year: 2025, month: 1, day: 1, hour: 10, timeZone: "UTC"
151
+ })
152
+ });
153
+
154
+ toText(weekly);
155
+ // "every week on Sunday at 10 AM UTC"
156
+ toText(weekly, "es");
157
+ // "cada semana en domingo a las 10 AM UTC"
158
+ ```
159
+
160
+ ### `toText` supported languages
161
+
162
+ `toText()` currently ships translations for the following languages:
163
+
164
+ | Code | Language |
165
+ | ---- | -------- |
166
+ | en | English |
167
+ | es | Spanish |
168
+ | hi | Hindi |
169
+ | yue | Cantonese |
170
+ | ar | Arabic |
171
+ | he | Hebrew |
172
+ | zh | Mandarin |
173
+ | de | German |
174
+ | fr | French |
175
+
176
+ **NOTE:** At build time you can reduce bundle size by
177
+ defining the `TOTEXT_LANGS` environment variable (read from `process.env`),
178
+ e.g. `TOTEXT_LANGS=en,es,ar`. When this environment variable is unavailable
179
+ (such as in browser builds where `process` is undefined) all languages are
180
+ included by default.
181
+
182
+ ### RFC 7529 (RSCALE / SKIP)
183
+
184
+ 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.
185
+
186
+ ### Supported Calendars
187
+
188
+ | Calendar | Description |
189
+ |--------------------|----------------------------------|
190
+ | GREGORIAN | Gregorian calendar (default) |
191
+ | CHINESE | Chinese calendar |
192
+ | HEBREW | Hebrew calendar |
193
+ | INDIAN | Saka/Indian National Calendar |
194
+
195
+ - Spec: RFC 7529 — Non‑Gregorian Recurrence Rules in iCalendar
196
+ https://www.rfc-editor.org/rfc/rfc7529.html
197
+
198
+ What RSCALE does:
199
+ - Extends `RRULE` with `RSCALE=<calendar>` to choose the calendar used for recurrence generation while keeping DTSTART/RECURRENCE‑ID/RDATE/EXDATE in Gregorian.
200
+ - 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.
201
+
202
+ What SKIP does:
203
+ - Extends `RRULE` with `SKIP=OMIT|BACKWARD|FORWARD` (only when `RSCALE` is present).
204
+ - 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):
205
+ - `OMIT` (default): drop the invalid occurrence.
206
+ - `BACKWARD`: move to the previous valid day/month (e.g., Feb 28).
207
+ - `FORWARD`: move to the next valid day/month (e.g., Mar 1).
208
+ - 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.
209
+
210
+ Leap months and BYMONTH:
211
+ - `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`).
212
+ - Example tokens:
213
+ - Chinese: `5L` matches `monthCode=M05L` (leap 5th) or `M06L` depending on calendar system; we match by the numeric part + `L` via `monthCode`.
214
+ - Hebrew: `5L` typically corresponds to Adar I (`monthCode=M05L`).
215
+ - Numeric months without `L` (e.g., `5`) match the regular month (e.g., `monthCode=M05`).
216
+
217
+ Supported RSCALE coverage in this library:
218
+ - Frequencies: `YEARLY`, `MONTHLY`, `WEEKLY` with Chinese/Hebrew calendars.
219
+ - Constraints: `BYMONTH` (including leap tokens), `BYMONTHDAY`, `BYDAY` (weekday tokens; ordinal support at monthly/yearly levels), `BYYEARDAY`, `BYWEEKNO`, `BYSETPOS`.
220
+ - Sub‑daily (`DAILY`, `HOURLY`, `MINUTELY`) behavior:
221
+ - 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`.
222
+ - 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`.
223
+ - Ordinal `BYDAY` (e.g., `1MO`, `-1SU`) is not interpreted at sub‑daily RSCALE levels; use `MONTHLY`/`YEARLY` for these.
224
+
225
+ Examples
226
+
227
+ Chinese New Year (1st day of 1st Chinese month), year over year from a Gregorian DTSTART:
228
+
229
+ ```ics
230
+ DTSTART;VALUE=DATE:20130210
231
+ RRULE:RSCALE=CHINESE;FREQ=YEARLY
232
+ ```
233
+
234
+ Hebrew New Year (Tishrei 1) — using BYYEARDAY=1 in Hebrew calendar:
235
+
236
+ ```ics
237
+ DTSTART;TZID=UTC:20230916T090000
238
+ RRULE:RSCALE=HEBREW;FREQ=YEARLY;BYYEARDAY=1;BYHOUR=9
239
+ ```
240
+
241
+ Feb 29 birthday — SKIP strategies:
242
+
243
+ ```ics
244
+ DTSTART;TZID=UTC:20160229T120000
245
+ RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=OMIT
246
+ ```
247
+
248
+ ```ics
249
+ DTSTART;TZID=UTC:20160229T120000
250
+ RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=BACKWARD
251
+ ```
252
+
253
+ ```ics
254
+ DTSTART;TZID=UTC:20160229T120000
255
+ RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;SKIP=FORWARD
256
+ ```
257
+
258
+ Notes
259
+ - SKIP MUST NOT be present unless RSCALE is present (per RFC 7529).
260
+ - Default SKIP is `OMIT` when RSCALE is present.
261
+ - This library surfaces `RSCALE`/`SKIP` in `toText()` at the end of the description: e.g., `(RSCALE=HEBREW;SKIP=OMIT)`.
262
+
263
+ ## API
264
+
265
+ | Method | Description |
266
+ | ------ | ----------- |
267
+ | `new RRuleTemporal(opts)` | Create a rule from an ICS snippet or manual options. |
268
+ | `all(iterator?)` | Return every occurrence. When the rule has no end the optional iterator is required. |
269
+ | `between(after, before, inclusive?)` | Occurrences within a time range. |
270
+ | `next(after?, inclusive?)` | Next occurrence after a given date. |
271
+ | `previous(before?, inclusive?)` | Previous occurrence before a date. |
272
+ | `toString()` | Convert the rule back into `DTSTART` and `RRULE` lines. |
273
+ | `toText(rule, locale?)` | Human readable description (`en`, `es`, `hi`, `yue`, `ar`, `he`, `zh`, `fr`). |
274
+ | `options()` | Return the normalized options object. |
275
+
276
+ ## Further examples
277
+
278
+ Enumerating weekdays within a month or rotating through months can be achieved
279
+ with the more advanced RFC&nbsp;5545 fields:
280
+
281
+ ```typescript
282
+ // 2nd & 4th Fridays each month at midnight CT, first 6 occurrences
283
+ const ruleA = new RRuleTemporal({
284
+ rruleString: `DTSTART;TZID=America/Chicago:20250325T000000\nRRULE:FREQ=MONTHLY;BYDAY=2FR,4FR;BYHOUR=0;BYMINUTE=0;COUNT=6`
285
+ });
286
+ ruleA.all().forEach(dt => console.log(dt.toString()));
287
+
288
+ // Rotate yearly through Jan, Jun and Dec at 09:00 UTC
289
+ const dtstart = Temporal.ZonedDateTime.from({
290
+ year: 2025, month: 1, day: 10, hour: 9, minute: 0, timeZone: "UTC"
291
+ });
292
+ const ruleB = new RRuleTemporal({
293
+ freq: "YEARLY",
294
+ interval: 1,
295
+ count: 4,
296
+ byMonth: [1, 6, 12],
297
+ byHour: [9],
298
+ byMinute: [0],
299
+ dtstart
300
+ });
301
+ ruleB.all().forEach(dt => console.log(dt.toString()));
302
+ ```
303
+
304
+ ### Working with extra and excluded dates
305
+
306
+ ```typescript
307
+ import { Temporal } from "temporal-polyfill";
308
+
309
+ const start = Temporal.ZonedDateTime.from({
310
+ year: 2025, month: 1, day: 1, hour: 12, timeZone: "UTC"
311
+ });
312
+ const ruleC = new RRuleTemporal({
313
+ freq: "WEEKLY",
314
+ count: 5,
315
+ rDate: [start.add({ days: 1 })], // add one extra day
316
+ exDate: [start.add({ weeks: 2 })], // skip the third week
317
+ dtstart: start
318
+ });
319
+
320
+ // First five occurrences (with rDate/exDate accounted for)
321
+ ruleC.all((_, i) => i < 5).forEach(dt => console.log(dt.toString()));
322
+
323
+ // Occurrences within a window
324
+ const hits = ruleC.between(
325
+ new Date("2025-01-01T00:00Z"),
326
+ new Date("2025-02-01T00:00Z"),
327
+ true
328
+ );
329
+ ```
330
+
331
+ ### Converting between **temporal-polyfill** and **temporal-polyfill**
332
+
333
+ `rrule-temporal` ships with **`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.
334
+ This snippet shows how to re-hydrate each recurrence result into the polyfill your project expects.
335
+
336
+ ```ts
337
+ // rrule-temporal (and its internals) use temporal-polyfill
338
+ import { Temporal as RRTTemporal } from "temporal-polyfill";
339
+ // your application using temporal-polyfill or native Temporal
340
+ import { Temporal as AppTemporal } from "temporal-polyfill";
341
+
342
+ import { RRuleTemporal } from "rrule-temporal";
343
+
344
+ /** Weekly rule that fires 4 times starting 5 May 2025, 10 AM America/Chicago. */
345
+ const rule = new RRuleTemporal({
346
+ freq: "WEEKLY",
347
+ count: 4,
348
+ dtstart: RRTTemporal.ZonedDateTime.from(
349
+ "2025-05-05T10:00[America/Chicago]"
350
+ ),
351
+ });
352
+
353
+ // Occurrences are ZonedDateTime instances from temporal-polyfill
354
+ const rawOccurrences = rule.all();
355
+
356
+ /** Convert each ZonedDateTime to temporal-polyfill ZonedDateTime. */
357
+ const appOccurrences = rawOccurrences.map((zdt) =>
358
+ AppTemporal.ZonedDateTime.from(zdt.toString())
359
+ );
360
+
361
+ // …now `appOccurrences` can be passed anywhere that expects temporal-polyfill in your app
362
+ ```
363
+
364
+ #### Why `.toString()`?
365
+
366
+ `Temporal.*.from()` accepts ISO 8601 strings (including bracketed time-zone annotations), so calling `toString()` sidesteps the internal-slot branding that makes polyfill objects incompatible.
367
+
368
+ #### Nanosecond precision variant
369
+
370
+ ```ts
371
+ const appOccurrences = rawOccurrences.map((zdt) =>
372
+ AppTemporal.ZonedDateTime.fromEpochNanoseconds(zdt.epochNanoseconds)
373
+ );
374
+ ```
375
+
376
+ Both approaches preserve the original calendar, time-zone and nanosecond accuracy.