@remit/calendar-service 0.0.18 → 0.0.19

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.
Files changed (73) hide show
  1. package/dist/accept.d.ts +71 -0
  2. package/dist/accept.js +159 -0
  3. package/dist/build.d.ts +79 -0
  4. package/dist/build.js +196 -0
  5. package/dist/correct.d.ts +4 -0
  6. package/dist/correct.js +22 -0
  7. package/dist/errors.d.ts +56 -0
  8. package/dist/errors.js +1 -0
  9. package/dist/etag.d.ts +9 -0
  10. package/{src/etag.ts → dist/etag.js} +1 -3
  11. package/dist/expand.d.ts +73 -0
  12. package/dist/expand.js +188 -0
  13. package/dist/feed.d.ts +81 -0
  14. package/dist/feed.js +157 -0
  15. package/dist/fixtures.d.ts +19 -0
  16. package/dist/fixtures.js +37 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +15 -0
  19. package/dist/memory-store.d.ts +105 -0
  20. package/dist/memory-store.js +319 -0
  21. package/dist/parse.d.ts +39 -0
  22. package/dist/parse.js +87 -0
  23. package/dist/project.d.ts +30 -0
  24. package/dist/project.js +57 -0
  25. package/dist/put.d.ts +52 -0
  26. package/dist/put.js +78 -0
  27. package/dist/reopen.d.ts +14 -0
  28. package/dist/reopen.js +92 -0
  29. package/dist/scope.d.ts +70 -0
  30. package/dist/scope.js +687 -0
  31. package/dist/subscribe.d.ts +96 -0
  32. package/dist/subscribe.js +298 -0
  33. package/dist/suggest.d.ts +74 -0
  34. package/dist/suggest.js +138 -0
  35. package/dist/time.d.ts +76 -0
  36. package/dist/time.js +164 -0
  37. package/dist/window.d.ts +85 -0
  38. package/dist/window.js +161 -0
  39. package/package.json +40 -35
  40. package/src/accept.test.ts +0 -403
  41. package/src/accept.ts +0 -250
  42. package/src/build.test.ts +0 -113
  43. package/src/build.ts +0 -311
  44. package/src/correct.test.ts +0 -173
  45. package/src/correct.ts +0 -40
  46. package/src/errors.ts +0 -62
  47. package/src/etag.test.ts +0 -54
  48. package/src/expand.test.ts +0 -361
  49. package/src/expand.ts +0 -303
  50. package/src/feed.test.ts +0 -334
  51. package/src/feed.ts +0 -203
  52. package/src/feeds/google-secret-address.ics +0 -99
  53. package/src/fixtures.ts +0 -52
  54. package/src/index.ts +0 -128
  55. package/src/memory-store.ts +0 -485
  56. package/src/parse.test.ts +0 -264
  57. package/src/parse.ts +0 -138
  58. package/src/project.test.ts +0 -247
  59. package/src/project.ts +0 -122
  60. package/src/put.test.ts +0 -318
  61. package/src/put.ts +0 -133
  62. package/src/reopen.test.ts +0 -529
  63. package/src/reopen.ts +0 -199
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -582
  67. package/src/subscribe.ts +0 -433
  68. package/src/suggest.test.ts +0 -376
  69. package/src/suggest.ts +0 -244
  70. package/src/time.ts +0 -260
  71. package/src/window.test.ts +0 -648
  72. package/src/window.ts +0 -281
  73. package/tsconfig.json +0 -8
package/src/scope.test.ts DELETED
@@ -1,1421 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { RecurrenceScope } from "@remit/domain-enums";
4
- import { expandCalendar } from "./expand.js";
5
- import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
6
- import { parseCalendar } from "./parse.js";
7
- import { applyScopedDelete, applyScopedUpdate } from "./scope.js";
8
-
9
- const WEEKLY = ical(
10
- "BEGIN:VCALENDAR",
11
- "VERSION:2.0",
12
- "BEGIN:VEVENT",
13
- "UID:weekly@example.com",
14
- "DTSTART:20260907T090000Z",
15
- "DTEND:20260907T100000Z",
16
- "SUMMARY:Stand-up",
17
- "RRULE:FREQ=WEEKLY;COUNT=5",
18
- "END:VEVENT",
19
- "END:VCALENDAR",
20
- );
21
-
22
- const OPEN_ENDED = ical(
23
- "BEGIN:VCALENDAR",
24
- "VERSION:2.0",
25
- "BEGIN:VEVENT",
26
- "UID:open@example.com",
27
- "DTSTART:20260907T090000Z",
28
- "DTEND:20260907T100000Z",
29
- "SUMMARY:Stand-up",
30
- "RRULE:FREQ=WEEKLY",
31
- "END:VEVENT",
32
- "END:VCALENDAR",
33
- );
34
-
35
- const AMSTERDAM_WEEKLY = ical(
36
- "BEGIN:VCALENDAR",
37
- "VERSION:2.0",
38
- ...AMSTERDAM_VTIMEZONE,
39
- "BEGIN:VEVENT",
40
- "UID:amsterdam@example.com",
41
- "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
42
- "DTEND;TZID=Europe/Amsterdam:20261015T100000",
43
- "SUMMARY:Stand-up",
44
- "RRULE:FREQ=WEEKLY;COUNT=4",
45
- "END:VEVENT",
46
- "END:VCALENDAR",
47
- );
48
-
49
- const read = async (icalData: string) => {
50
- const parsed = await parseCalendar(icalData);
51
- assert.ok(parsed.ok, `expected a parse, got ${JSON.stringify(parsed)}`);
52
- return parsed.value;
53
- };
54
-
55
- const input = (
56
- scope: (typeof RecurrenceScope)[keyof typeof RecurrenceScope],
57
- recurrenceId = "",
58
- ) => ({ scope, recurrenceId, followingUid: "split@example.com" });
59
-
60
- /** The VEVENT blocks of a resource, so an assertion can name one of them. */
61
- const events = (icalData: string): string[][] => {
62
- const lines = icalData.split("\r\n");
63
- const blocks: string[][] = [];
64
- let current: string[] | null = null;
65
- for (const line of lines) {
66
- if (line === "BEGIN:VEVENT") {
67
- current = [];
68
- continue;
69
- }
70
- if (line === "END:VEVENT" && current) {
71
- blocks.push(current);
72
- current = null;
73
- continue;
74
- }
75
- current?.push(line);
76
- }
77
- return blocks;
78
- };
79
-
80
- describe("applyScopedUpdate", () => {
81
- it("rewrites the master and nothing else under scope=All", async () => {
82
- const calendar = await read(WEEKLY);
83
-
84
- const write = await applyScopedUpdate(
85
- calendar,
86
- "",
87
- input(RecurrenceScope.All),
88
- { summary: "Stand-up (renamed)" },
89
- );
90
-
91
- assert.ok(write.ok);
92
- assert.equal(write.value.kind, "Replace");
93
- assert.ok(write.value.kind === "Replace");
94
- const [master, ...rest] = events(write.value.icalData);
95
- assert.deepEqual(rest, []);
96
- assert.ok(master?.includes("SUMMARY:Stand-up (renamed)"));
97
- assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
98
- assert.ok(master?.includes("DTSTART:20260907T090000Z"));
99
- });
100
-
101
- it("moves the series by what changed on the occurrence a scope=All edit was made from", async () => {
102
- const calendar = await read(WEEKLY);
103
-
104
- const write = await applyScopedUpdate(
105
- calendar,
106
- "",
107
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
108
- { start: "2026-09-21T14:00:00Z", end: "2026-09-21T15:30:00Z" },
109
- );
110
-
111
- assert.ok(write.ok);
112
- assert.ok(write.value.kind === "Replace");
113
- const [master, ...rest] = events(write.value.icalData);
114
- assert.deepEqual(rest, []);
115
- assert.ok(
116
- master?.includes("DTSTART:20260907T140000Z"),
117
- `the series still starts on its first occurrence: ${master?.join(" | ")}`,
118
- );
119
- assert.ok(master?.includes("DTEND:20260907T153000Z"));
120
- assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
121
- });
122
-
123
- it("keeps a zoned series on its own first day when a scope=All edit crosses a clock change", async () => {
124
- const calendar = await read(AMSTERDAM_WEEKLY);
125
-
126
- const write = await applyScopedUpdate(
127
- calendar,
128
- "Europe/Amsterdam",
129
- input(RecurrenceScope.All, "2026-10-29T08:00:00Z"),
130
- {
131
- start: "2026-10-29T10:00:00+01:00",
132
- end: "2026-10-29T11:00:00+01:00",
133
- timeZone: "Europe/Amsterdam",
134
- },
135
- );
136
-
137
- assert.ok(write.ok);
138
- assert.ok(write.value.kind === "Replace");
139
- const [master] = events(write.value.icalData);
140
- assert.ok(
141
- master?.includes("DTSTART;TZID=Europe/Amsterdam:20261015T100000"),
142
- `the series moves on its own wall clock: ${master?.join(" | ")}`,
143
- );
144
- assert.ok(master?.includes("DTEND;TZID=Europe/Amsterdam:20261015T110000"));
145
- });
146
-
147
- it("keeps a series on its weekday when a scope=All edit is made from an occurrence moved on its own", async () => {
148
- const calendar = await read(
149
- ical(
150
- "BEGIN:VCALENDAR",
151
- "VERSION:2.0",
152
- "BEGIN:VEVENT",
153
- "UID:weekly@example.com",
154
- "DTSTART:20260907T090000Z",
155
- "DTEND:20260907T100000Z",
156
- "SUMMARY:Stand-up",
157
- "RRULE:FREQ=WEEKLY;COUNT=5",
158
- "END:VEVENT",
159
- "BEGIN:VEVENT",
160
- "UID:weekly@example.com",
161
- "RECURRENCE-ID:20260921T090000Z",
162
- "DTSTART:20260923T090000Z",
163
- "DTEND:20260923T100000Z",
164
- "SUMMARY:Stand-up",
165
- "END:VEVENT",
166
- "END:VCALENDAR",
167
- ),
168
- );
169
-
170
- const write = await applyScopedUpdate(
171
- calendar,
172
- "",
173
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
174
- { start: "2026-09-23T10:00:00Z", end: "2026-09-23T11:00:00Z" },
175
- );
176
-
177
- assert.ok(write.ok);
178
- assert.ok(write.value.kind === "Replace");
179
- const [master, override] = events(write.value.icalData);
180
- assert.ok(
181
- master?.includes("DTSTART:20260907T100000Z"),
182
- `the series stays on Mondays: ${master?.join(" | ")}`,
183
- );
184
- assert.ok(master?.includes("DTEND:20260907T110000Z"));
185
- assert.ok(
186
- override?.includes("RECURRENCE-ID:20260921T100000Z"),
187
- `the moved occurrence still replaces its own slot: ${override?.join(" | ")}`,
188
- );
189
- assert.ok(override?.includes("DTSTART:20260923T090000Z"));
190
-
191
- const expanded = expandCalendar(await read(write.value.icalData), "");
192
- assert.deepEqual(
193
- expanded.occurrences.map((occurrence) => occurrence.startAt),
194
- [
195
- "2026-09-07T10:00:00Z",
196
- "2026-09-14T10:00:00Z",
197
- "2026-09-23T09:00:00Z",
198
- "2026-09-28T10:00:00Z",
199
- "2026-10-05T10:00:00Z",
200
- ],
201
- );
202
- });
203
-
204
- it("keeps an excluded occurrence excluded when a scope=All edit moves the series' time", async () => {
205
- const calendar = await read(
206
- ical(
207
- "BEGIN:VCALENDAR",
208
- "VERSION:2.0",
209
- "BEGIN:VEVENT",
210
- "UID:weekly@example.com",
211
- "DTSTART:20260907T090000Z",
212
- "DTEND:20260907T100000Z",
213
- "SUMMARY:Stand-up",
214
- "RRULE:FREQ=WEEKLY;COUNT=5",
215
- "EXDATE:20260914T090000Z",
216
- "END:VEVENT",
217
- "END:VCALENDAR",
218
- ),
219
- );
220
-
221
- const write = await applyScopedUpdate(
222
- calendar,
223
- "",
224
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
225
- { start: "2026-09-21T10:00:00Z", end: "2026-09-21T11:00:00Z" },
226
- );
227
-
228
- assert.ok(write.ok);
229
- assert.ok(write.value.kind === "Replace");
230
- const expanded = expandCalendar(await read(write.value.icalData), "");
231
- assert.deepEqual(
232
- expanded.occurrences.map((occurrence) => occurrence.startAt),
233
- [
234
- "2026-09-07T10:00:00Z",
235
- "2026-09-21T10:00:00Z",
236
- "2026-09-28T10:00:00Z",
237
- "2026-10-05T10:00:00Z",
238
- ],
239
- );
240
- });
241
-
242
- it("moves only the end when a scope=All edit changes only the end", async () => {
243
- const calendar = await read(WEEKLY);
244
-
245
- const write = await applyScopedUpdate(
246
- calendar,
247
- "",
248
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
249
- { end: "2026-09-21T11:00:00Z" },
250
- );
251
-
252
- assert.ok(write.ok);
253
- assert.ok(write.value.kind === "Replace");
254
- const [master] = events(write.value.icalData);
255
- assert.ok(
256
- master?.includes("DTSTART:20260907T090000Z"),
257
- `the start is untouched: ${master?.join(" | ")}`,
258
- );
259
- assert.ok(
260
- master?.includes("DTEND:20260907T110000Z"),
261
- `the end lands on the series' first day: ${master?.join(" | ")}`,
262
- );
263
- });
264
-
265
- it("keeps the series' first day when a scope=All edit changes only the zone", async () => {
266
- const calendar = await read(WEEKLY);
267
-
268
- const write = await applyScopedUpdate(
269
- calendar,
270
- "",
271
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
272
- { timeZone: "Europe/Amsterdam" },
273
- );
274
-
275
- assert.ok(write.ok);
276
- assert.ok(write.value.kind === "Replace");
277
- const [master] = events(write.value.icalData);
278
- assert.ok(
279
- master?.includes("DTSTART;TZID=Europe/Amsterdam:20260907T110000"),
280
- `the same instant, read in the new zone: ${master?.join(" | ")}`,
281
- );
282
- assert.ok(master?.includes("DTEND;TZID=Europe/Amsterdam:20260907T120000"));
283
- });
284
-
285
- it("keeps the series' first day when a scope=All edit changes only all-day", async () => {
286
- const calendar = await read(WEEKLY);
287
-
288
- const write = await applyScopedUpdate(
289
- calendar,
290
- "",
291
- input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
292
- { allDay: true },
293
- );
294
-
295
- assert.ok(write.ok);
296
- assert.ok(write.value.kind === "Replace");
297
- const [master] = events(write.value.icalData);
298
- assert.ok(
299
- master?.includes("DTSTART;VALUE=DATE:20260907"),
300
- `the series still starts on its first day: ${master?.join(" | ")}`,
301
- );
302
- });
303
-
304
- it("refuses a scope=All time change made from an occurrence the series does not have", async () => {
305
- const calendar = await read(WEEKLY);
306
-
307
- const write = await applyScopedUpdate(
308
- calendar,
309
- "",
310
- input(RecurrenceScope.All, "2026-09-22T09:00:00Z"),
311
- { start: "2026-09-22T14:00:00Z", end: "2026-09-22T15:00:00Z" },
312
- );
313
-
314
- assert.equal(write.ok, false);
315
- });
316
-
317
- it("writes a RECURRENCE-ID override for one occurrence under scope=This", async () => {
318
- const calendar = await read(WEEKLY);
319
-
320
- const write = await applyScopedUpdate(
321
- calendar,
322
- "",
323
- input(RecurrenceScope.This, "2026-09-21T09:00:00Z"),
324
- { summary: "Stand-up (this week only)", start: "2026-09-21T10:00:00Z" },
325
- );
326
-
327
- assert.ok(write.ok);
328
- assert.ok(write.value.kind === "Replace");
329
- const [master, override] = events(write.value.icalData);
330
- assert.ok(master?.includes("SUMMARY:Stand-up"));
331
- assert.ok(
332
- master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"),
333
- "the series keeps its own rule",
334
- );
335
- assert.ok(override?.includes("RECURRENCE-ID:20260921T090000Z"));
336
- assert.ok(override?.includes("SUMMARY:Stand-up (this week only)"));
337
- assert.ok(override?.includes("DTSTART:20260921T100000Z"));
338
- assert.equal(
339
- override?.some((line) => line.startsWith("RRULE")),
340
- false,
341
- "an override is one occurrence and carries no rule",
342
- );
343
- });
344
-
345
- it("edits the override a resource already carries rather than adding a second", async () => {
346
- const calendar = await read(
347
- ical(
348
- "BEGIN:VCALENDAR",
349
- "VERSION:2.0",
350
- "BEGIN:VEVENT",
351
- "UID:weekly@example.com",
352
- "DTSTART:20260907T090000Z",
353
- "DTEND:20260907T100000Z",
354
- "SUMMARY:Stand-up",
355
- "RRULE:FREQ=WEEKLY;COUNT=5",
356
- "END:VEVENT",
357
- "BEGIN:VEVENT",
358
- "UID:weekly@example.com",
359
- "RECURRENCE-ID:20260914T090000Z",
360
- "DTSTART:20260914T110000Z",
361
- "DTEND:20260914T120000Z",
362
- "SUMMARY:Stand-up (moved)",
363
- "END:VEVENT",
364
- "END:VCALENDAR",
365
- ),
366
- );
367
-
368
- const write = await applyScopedUpdate(
369
- calendar,
370
- "",
371
- input(RecurrenceScope.This, "2026-09-14T09:00:00Z"),
372
- { summary: "Stand-up (moved again)" },
373
- );
374
-
375
- assert.ok(write.ok);
376
- assert.ok(write.value.kind === "Replace");
377
- const blocks = events(write.value.icalData);
378
- assert.equal(blocks.length, 2);
379
- assert.ok(blocks[1]?.includes("SUMMARY:Stand-up (moved again)"));
380
- assert.ok(
381
- blocks[1]?.includes("DTSTART:20260914T110000Z"),
382
- "the instance keeps where it was moved to",
383
- );
384
- });
385
-
386
- it("splits a counted series into a truncated head and a remainder under scope=Following", async () => {
387
- const calendar = await read(WEEKLY);
388
-
389
- const write = await applyScopedUpdate(
390
- calendar,
391
- "",
392
- input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
393
- { summary: "Stand-up (new format)" },
394
- );
395
-
396
- assert.ok(write.ok);
397
- assert.equal(write.value.kind, "Split");
398
- assert.ok(write.value.kind === "Split");
399
-
400
- const [head] = events(write.value.icalData);
401
- assert.ok(
402
- head?.includes("RRULE:FREQ=WEEKLY;COUNT=2"),
403
- "the head keeps the two occurrences before the split",
404
- );
405
- assert.ok(head?.includes("SUMMARY:Stand-up"));
406
- assert.ok(head?.includes("DTSTART:20260907T090000Z"));
407
-
408
- const [tail] = events(write.value.following);
409
- assert.ok(tail?.includes("UID:split@example.com"));
410
- assert.ok(tail?.includes("DTSTART:20260921T090000Z"));
411
- assert.ok(tail?.includes("DTEND:20260921T100000Z"));
412
- assert.ok(tail?.includes("RRULE:FREQ=WEEKLY;COUNT=3"));
413
- assert.ok(tail?.includes("SUMMARY:Stand-up (new format)"));
414
- });
415
-
416
- it("ends an open series with UNTIL just before the split", async () => {
417
- const calendar = await read(OPEN_ENDED);
418
-
419
- const write = await applyScopedUpdate(
420
- calendar,
421
- "",
422
- input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
423
- { summary: "Stand-up (new format)" },
424
- );
425
-
426
- assert.ok(write.ok);
427
- assert.ok(write.value.kind === "Split");
428
- const [head] = events(write.value.icalData);
429
- assert.ok(head?.includes("RRULE:FREQ=WEEKLY;UNTIL=20260921T085959Z"));
430
- const [tail] = events(write.value.following);
431
- assert.ok(
432
- tail?.includes("RRULE:FREQ=WEEKLY"),
433
- "the remainder stays open-ended",
434
- );
435
- });
436
-
437
- it("keeps the anchoring zone when the split series is written with a TZID", async () => {
438
- const calendar = await read(AMSTERDAM_WEEKLY);
439
-
440
- const write = await applyScopedUpdate(
441
- calendar,
442
- "Europe/Amsterdam",
443
- // The fourth occurrence. The clocks went back on 25 October, so the
444
- // same 09:00 local is an hour earlier in UTC than the first three.
445
- input(RecurrenceScope.Following, "2026-11-05T08:00:00Z"),
446
- { summary: "Stand-up (new format)" },
447
- );
448
-
449
- assert.ok(write.ok);
450
- assert.ok(write.value.kind === "Split");
451
- const [tail] = events(write.value.following);
452
- assert.ok(
453
- tail?.includes("DTSTART;TZID=Europe/Amsterdam:20261105T090000"),
454
- `the remainder is anchored in the zone, not the offset: ${tail?.join(" | ")}`,
455
- );
456
- });
457
-
458
- it("treats a split at the first occurrence as the whole series", async () => {
459
- const calendar = await read(WEEKLY);
460
-
461
- const write = await applyScopedUpdate(
462
- calendar,
463
- "",
464
- input(RecurrenceScope.Following, "2026-09-07T09:00:00Z"),
465
- { summary: "Stand-up (renamed)" },
466
- );
467
-
468
- assert.ok(write.ok);
469
- assert.equal(write.value.kind, "Replace");
470
- });
471
-
472
- it("refuses a per-occurrence scope on an event that happens once", async () => {
473
- const calendar = await read(
474
- singleEvent("DTSTART:20260907T090000Z", "DTEND:20260907T100000Z"),
475
- );
476
-
477
- const write = await applyScopedUpdate(
478
- calendar,
479
- "",
480
- input(RecurrenceScope.This, "2026-09-07T09:00:00Z"),
481
- { summary: "Renamed" },
482
- );
483
-
484
- assert.ok(!write.ok);
485
- assert.equal(write.error.code, "NotRecurring");
486
- });
487
-
488
- it("refuses a scope that names no occurrence", async () => {
489
- const calendar = await read(WEEKLY);
490
-
491
- const missing = await applyScopedUpdate(
492
- calendar,
493
- "",
494
- input(RecurrenceScope.This, ""),
495
- {},
496
- );
497
- assert.ok(!missing.ok);
498
- assert.equal(missing.error.code, "MissingRecurrenceId");
499
-
500
- const unknown = await applyScopedUpdate(
501
- calendar,
502
- "",
503
- input(RecurrenceScope.This, "2026-09-22T09:00:00Z"),
504
- {},
505
- );
506
- assert.ok(!unknown.ok);
507
- assert.equal(unknown.error.code, "UnknownOccurrence");
508
- });
509
- });
510
-
511
- describe("applyScopedDelete", () => {
512
- it("removes the resource under scope=All", async () => {
513
- const calendar = await read(WEEKLY);
514
-
515
- const write = await applyScopedDelete(
516
- calendar,
517
- "",
518
- input(RecurrenceScope.All),
519
- );
520
-
521
- assert.ok(write.ok);
522
- assert.equal(write.value.kind, "Delete");
523
- });
524
-
525
- it("writes an EXDATE for one occurrence under scope=This", async () => {
526
- const calendar = await read(WEEKLY);
527
-
528
- const write = await applyScopedDelete(
529
- calendar,
530
- "",
531
- input(RecurrenceScope.This, "2026-09-21T09:00:00Z"),
532
- );
533
-
534
- assert.ok(write.ok);
535
- assert.ok(write.value.kind === "Replace");
536
- const [master] = events(write.value.icalData);
537
- assert.ok(master?.includes("EXDATE:20260921T090000Z"));
538
- assert.ok(
539
- master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"),
540
- "the rest of the series stays",
541
- );
542
- });
543
-
544
- it("drops the override an EXDATEd occurrence carried", async () => {
545
- const calendar = await read(
546
- ical(
547
- "BEGIN:VCALENDAR",
548
- "VERSION:2.0",
549
- "BEGIN:VEVENT",
550
- "UID:weekly@example.com",
551
- "DTSTART:20260907T090000Z",
552
- "DTEND:20260907T100000Z",
553
- "RRULE:FREQ=WEEKLY;COUNT=5",
554
- "END:VEVENT",
555
- "BEGIN:VEVENT",
556
- "UID:weekly@example.com",
557
- "RECURRENCE-ID:20260914T090000Z",
558
- "DTSTART:20260914T110000Z",
559
- "DTEND:20260914T120000Z",
560
- "END:VEVENT",
561
- "END:VCALENDAR",
562
- ),
563
- );
564
-
565
- const write = await applyScopedDelete(
566
- calendar,
567
- "",
568
- input(RecurrenceScope.This, "2026-09-14T09:00:00Z"),
569
- );
570
-
571
- assert.ok(write.ok);
572
- assert.ok(write.value.kind === "Replace");
573
- assert.equal(events(write.value.icalData).length, 1);
574
- assert.ok(
575
- events(write.value.icalData)[0]?.includes("EXDATE:20260914T090000Z"),
576
- );
577
- });
578
-
579
- it("truncates the series under scope=Following without removing it", async () => {
580
- const calendar = await read(WEEKLY);
581
-
582
- const write = await applyScopedDelete(
583
- calendar,
584
- "",
585
- input(RecurrenceScope.Following, "2026-09-21T09:00:00Z"),
586
- );
587
-
588
- assert.ok(write.ok);
589
- assert.ok(write.value.kind === "Replace");
590
- assert.ok(
591
- events(write.value.icalData)[0]?.includes("RRULE:FREQ=WEEKLY;COUNT=2"),
592
- );
593
- });
594
-
595
- it("removes the resource when the split would take the first occurrence", async () => {
596
- const calendar = await read(WEEKLY);
597
-
598
- const write = await applyScopedDelete(
599
- calendar,
600
- "",
601
- input(RecurrenceScope.Following, "2026-09-07T09:00:00Z"),
602
- );
603
-
604
- assert.ok(write.ok);
605
- assert.equal(write.value.kind, "Delete");
606
- });
607
- });
608
-
609
- describe("splitting a series whose DTSTART is not a UTC instant", () => {
610
- const openEnded = (...dtLines: string[]) =>
611
- ical(
612
- "BEGIN:VCALENDAR",
613
- "VERSION:2.0",
614
- "BEGIN:VEVENT",
615
- "UID:series@example.com",
616
- ...dtLines,
617
- "SUMMARY:Series",
618
- "RRULE:FREQ=WEEKLY",
619
- "END:VEVENT",
620
- "END:VCALENDAR",
621
- );
622
-
623
- // A zone behind UTC is what exposes it: a UTC UNTIL derived from the split
624
- // instant lands after the raw value the expander compares it to, so the head
625
- // keeps the occurrence the tail already starts with.
626
- const BEHIND_UTC = "America/New_York";
627
-
628
- const splitAtSecondSlot = async (icalData: string) => {
629
- const before = expandCalendar(await read(icalData), BEHIND_UTC);
630
- const at = before.occurrences[1]?.startAt as string;
631
- const write = await applyScopedUpdate(
632
- await read(icalData),
633
- BEHIND_UTC,
634
- input(RecurrenceScope.Following, at),
635
- { summary: "Series (changed)" },
636
- );
637
- assert.ok(write.ok, JSON.stringify(write));
638
- assert.ok(write.value.kind === "Split");
639
- return {
640
- at,
641
- head: expandCalendar(await read(write.value.icalData), BEHIND_UTC),
642
- tail: expandCalendar(await read(write.value.following), BEHIND_UTC),
643
- rule: write.value.icalData
644
- .split("\r\n")
645
- .find((line) => line.startsWith("RRULE")),
646
- };
647
- };
648
-
649
- for (const [frame, dtLines] of Object.entries({
650
- "an all-day series": [
651
- "DTSTART;VALUE=DATE:20260601",
652
- "DTEND;VALUE=DATE:20260602",
653
- ],
654
- "a floating series": ["DTSTART:20260601T090000", "DTEND:20260601T100000"],
655
- "a series naming a zone the resource does not define": [
656
- "DTSTART;TZID=Europe/Amsterdam:20260601T090000",
657
- "DTEND;TZID=Europe/Amsterdam:20260601T100000",
658
- ],
659
- "a UTC series": ["DTSTART:20260601T090000Z", "DTEND:20260601T100000Z"],
660
- })) {
661
- it(`leaves the split occurrence to the remainder for ${frame}`, async () => {
662
- const { at, head, tail } = await splitAtSecondSlot(openEnded(...dtLines));
663
-
664
- assert.equal(
665
- head.occurrences.some((occurrence) => occurrence.startAt === at),
666
- false,
667
- "the truncated head still produced the occurrence it was split at",
668
- );
669
- assert.equal(tail.occurrences[0]?.startAt, at);
670
- });
671
- }
672
-
673
- it("writes UNTIL as a date for an all-day series, as RFC 5545 3.3.10 requires", async () => {
674
- const { rule } = await splitAtSecondSlot(
675
- openEnded("DTSTART;VALUE=DATE:20260601", "DTEND;VALUE=DATE:20260602"),
676
- );
677
-
678
- assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260607");
679
- });
680
-
681
- it("writes UNTIL in UTC for a series that names an instant", async () => {
682
- const { rule } = await splitAtSecondSlot(
683
- openEnded("DTSTART:20260601T090000Z", "DTEND:20260601T100000Z"),
684
- );
685
-
686
- assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260608T085959Z");
687
- });
688
- });
689
-
690
- const series = (...eventLines: string[]) =>
691
- ical(
692
- "BEGIN:VCALENDAR",
693
- "VERSION:2.0",
694
- "BEGIN:VEVENT",
695
- "UID:series@example.com",
696
- "SUMMARY:Stand-up",
697
- ...eventLines,
698
- "END:VEVENT",
699
- "END:VCALENDAR",
700
- );
701
-
702
- const withOverride = (masterLines: string[], overrideLines: string[]) =>
703
- ical(
704
- "BEGIN:VCALENDAR",
705
- "VERSION:2.0",
706
- ...AMSTERDAM_VTIMEZONE,
707
- "BEGIN:VEVENT",
708
- "UID:series@example.com",
709
- "SUMMARY:Stand-up",
710
- ...masterLines,
711
- "END:VEVENT",
712
- "BEGIN:VEVENT",
713
- "UID:series@example.com",
714
- ...overrideLines,
715
- "END:VEVENT",
716
- "END:VCALENDAR",
717
- );
718
-
719
- const starts = async (icalData: string, collectionTimezone = "") =>
720
- expandCalendar(await read(icalData), collectionTimezone).occurrences.map(
721
- (occurrence) => occurrence.startAt,
722
- );
723
-
724
- const replaced = (
725
- write: Awaited<ReturnType<typeof applyScopedUpdate>>,
726
- ): string => {
727
- assert.ok(write.ok, `expected a write, got ${JSON.stringify(write)}`);
728
- assert.ok(write.value.kind === "Replace");
729
- return write.value.icalData;
730
- };
731
-
732
- describe("a whole-series edit to another day", () => {
733
- it("moves a weekly rule's weekday with a Monday series moved to Tuesday", async () => {
734
- const calendar = await read(
735
- series(
736
- "DTSTART:20260907T090000Z",
737
- "DTEND:20260907T100000Z",
738
- "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4",
739
- ),
740
- );
741
-
742
- const icalData = replaced(
743
- await applyScopedUpdate(
744
- calendar,
745
- "",
746
- input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
747
- { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
748
- ),
749
- );
750
-
751
- const [master] = events(icalData);
752
- assert.ok(
753
- master?.includes("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=TU"),
754
- `the rule names the new day: ${master?.join(" | ")}`,
755
- );
756
- assert.deepEqual(await starts(icalData), [
757
- "2026-09-08T09:00:00Z",
758
- "2026-09-15T09:00:00Z",
759
- "2026-09-22T09:00:00Z",
760
- "2026-09-29T09:00:00Z",
761
- ]);
762
- });
763
-
764
- it("moves a weekly rule's weekday when the edit names no occurrence", async () => {
765
- const calendar = await read(
766
- series(
767
- "DTSTART:20260907T090000Z",
768
- "DTEND:20260907T100000Z",
769
- "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
770
- ),
771
- );
772
-
773
- const icalData = replaced(
774
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
775
- start: "2026-09-08T09:00:00Z",
776
- end: "2026-09-08T10:00:00Z",
777
- }),
778
- );
779
-
780
- assert.deepEqual(await starts(icalData), [
781
- "2026-09-08T09:00:00Z",
782
- "2026-09-15T09:00:00Z",
783
- "2026-09-22T09:00:00Z",
784
- ]);
785
- });
786
-
787
- it("moves every weekday of a rule that names several", async () => {
788
- const calendar = await read(
789
- series(
790
- "DTSTART:20260907T090000Z",
791
- "DTEND:20260907T100000Z",
792
- "RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
793
- ),
794
- );
795
-
796
- const icalData = replaced(
797
- await applyScopedUpdate(
798
- calendar,
799
- "",
800
- input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
801
- { start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
802
- ),
803
- );
804
-
805
- assert.deepEqual(await starts(icalData), [
806
- "2026-09-08T09:00:00Z",
807
- "2026-09-10T09:00:00Z",
808
- "2026-09-15T09:00:00Z",
809
- "2026-09-17T09:00:00Z",
810
- ]);
811
- });
812
-
813
- it("names the new weekday and its place in the month for a monthly rule", async () => {
814
- const calendar = await read(
815
- series(
816
- "DTSTART:20260914T090000Z",
817
- "DTEND:20260914T100000Z",
818
- "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
819
- ),
820
- );
821
-
822
- const icalData = replaced(
823
- await applyScopedUpdate(
824
- calendar,
825
- "",
826
- input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
827
- { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
828
- ),
829
- );
830
-
831
- assert.deepEqual(await starts(icalData), [
832
- "2026-09-15T09:00:00Z",
833
- "2026-10-20T09:00:00Z",
834
- "2026-11-17T09:00:00Z",
835
- ]);
836
- });
837
-
838
- it("keeps a monthly rule counting from the end of the month", async () => {
839
- const calendar = await read(
840
- series(
841
- "DTSTART:20260925T090000Z",
842
- "DTEND:20260925T100000Z",
843
- "RRULE:FREQ=MONTHLY;BYDAY=-1FR;COUNT=3",
844
- ),
845
- );
846
-
847
- const icalData = replaced(
848
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
849
- start: "2026-09-24T09:00:00Z",
850
- end: "2026-09-24T10:00:00Z",
851
- }),
852
- );
853
-
854
- assert.deepEqual(await starts(icalData), [
855
- "2026-09-24T09:00:00Z",
856
- "2026-10-29T09:00:00Z",
857
- "2026-11-26T09:00:00Z",
858
- ]);
859
- });
860
-
861
- it("moves the month of a yearly rule pinned to a month and weekday", async () => {
862
- const calendar = await read(
863
- series(
864
- "DTSTART:20260930T090000Z",
865
- "DTEND:20260930T100000Z",
866
- "RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1WE;COUNT=2",
867
- ),
868
- );
869
-
870
- const icalData = replaced(
871
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
872
- start: "2026-10-01T09:00:00Z",
873
- end: "2026-10-01T10:00:00Z",
874
- }),
875
- );
876
-
877
- assert.deepEqual(await starts(icalData), [
878
- "2026-10-01T09:00:00Z",
879
- "2027-10-07T09:00:00Z",
880
- ]);
881
- });
882
-
883
- it("leaves a rule alone when the edit also sets the rule", async () => {
884
- const calendar = await read(
885
- series(
886
- "DTSTART:20260907T090000Z",
887
- "DTEND:20260907T100000Z",
888
- "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
889
- ),
890
- );
891
-
892
- const icalData = replaced(
893
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
894
- start: "2026-09-08T09:00:00Z",
895
- end: "2026-09-08T10:00:00Z",
896
- recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=3",
897
- }),
898
- );
899
-
900
- assert.deepEqual(await starts(icalData), [
901
- "2026-09-08T09:00:00Z",
902
- "2026-09-10T09:00:00Z",
903
- "2026-09-15T09:00:00Z",
904
- ]);
905
- });
906
-
907
- it("refuses a move a rule with two numbered weekdays cannot follow", async () => {
908
- const calendar = await read(
909
- series(
910
- "DTSTART:20260907T090000Z",
911
- "DTEND:20260907T100000Z",
912
- "RRULE:FREQ=MONTHLY;BYDAY=1MO,3MO;COUNT=4",
913
- ),
914
- );
915
-
916
- const write = await applyScopedUpdate(
917
- calendar,
918
- "",
919
- input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
920
- { start: "2026-09-08T09:00:00Z", end: "2026-09-08T10:00:00Z" },
921
- );
922
-
923
- assert.equal(write.ok, false);
924
- assert.ok(!write.ok);
925
- assert.equal(write.error.code, "UnmovableRecurrenceRule");
926
- assert.match(write.error.message, /Pick a new repeat/);
927
- assert.match(write.error.message, /move only this event/);
928
- assert.doesNotMatch(write.error.message, /recurrenceRule|scope=|BYDAY/);
929
- });
930
-
931
- it("refuses a move a rule pinned by BYSETPOS cannot follow", async () => {
932
- const calendar = await read(
933
- series(
934
- "DTSTART:20260930T090000Z",
935
- "DTEND:20260930T100000Z",
936
- "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3",
937
- ),
938
- );
939
-
940
- const write = await applyScopedUpdate(
941
- calendar,
942
- "",
943
- input(RecurrenceScope.All),
944
- { start: "2026-09-29T09:00:00Z", end: "2026-09-29T10:00:00Z" },
945
- );
946
-
947
- assert.ok(!write.ok);
948
- assert.equal(write.error.code, "UnmovableRecurrenceRule");
949
- });
950
-
951
- it("keeps a weekly rule without a weekday on the new start's day", async () => {
952
- const calendar = await read(WEEKLY);
953
-
954
- const icalData = replaced(
955
- await applyScopedUpdate(
956
- calendar,
957
- "",
958
- input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
959
- { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
960
- ),
961
- );
962
-
963
- const [master] = events(icalData);
964
- assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
965
- assert.equal((await starts(icalData))[0], "2026-09-08T09:00:00Z");
966
- });
967
- });
968
-
969
- describe("a whole-series time change with per-occurrence changes", () => {
970
- const deletedAndEdited = withOverride(
971
- [
972
- "DTSTART:20260907T090000Z",
973
- "DTEND:20260907T100000Z",
974
- "RRULE:FREQ=WEEKLY;COUNT=5",
975
- "EXDATE:20260914T090000Z",
976
- ],
977
- [
978
- "RECURRENCE-ID:20260921T090000Z",
979
- "DTSTART:20260921T130000Z",
980
- "DTEND:20260921T140000Z",
981
- "SUMMARY:Stand-up, moved",
982
- ],
983
- );
984
-
985
- it("keeps a deleted occurrence deleted and an edited one shown once", async () => {
986
- const calendar = await read(deletedAndEdited);
987
-
988
- const icalData = replaced(
989
- await applyScopedUpdate(
990
- calendar,
991
- "",
992
- input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
993
- { start: "2026-09-28T10:00:00Z", end: "2026-09-28T11:00:00Z" },
994
- ),
995
- );
996
-
997
- assert.deepEqual(await starts(icalData), [
998
- "2026-09-07T10:00:00Z",
999
- "2026-09-21T13:00:00Z",
1000
- "2026-09-28T10:00:00Z",
1001
- "2026-10-05T10:00:00Z",
1002
- ]);
1003
- });
1004
-
1005
- it("keeps them when the edit names no occurrence", async () => {
1006
- const calendar = await read(deletedAndEdited);
1007
-
1008
- const icalData = replaced(
1009
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1010
- start: "2026-09-07T10:00:00Z",
1011
- end: "2026-09-07T11:00:00Z",
1012
- }),
1013
- );
1014
-
1015
- assert.deepEqual(await starts(icalData), [
1016
- "2026-09-07T10:00:00Z",
1017
- "2026-09-21T13:00:00Z",
1018
- "2026-09-28T10:00:00Z",
1019
- "2026-10-05T10:00:00Z",
1020
- ]);
1021
- });
1022
-
1023
- it("keeps them when the series moves to another weekday", async () => {
1024
- const calendar = await read(
1025
- withOverride(
1026
- [
1027
- "DTSTART:20260907T090000Z",
1028
- "DTEND:20260907T100000Z",
1029
- "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5",
1030
- "EXDATE:20260914T090000Z",
1031
- ],
1032
- [
1033
- "RECURRENCE-ID:20260921T090000Z",
1034
- "DTSTART:20260921T130000Z",
1035
- "DTEND:20260921T140000Z",
1036
- "SUMMARY:Stand-up, moved",
1037
- ],
1038
- ),
1039
- );
1040
-
1041
- const icalData = replaced(
1042
- await applyScopedUpdate(
1043
- calendar,
1044
- "",
1045
- input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
1046
- { start: "2026-09-29T10:00:00Z", end: "2026-09-29T11:00:00Z" },
1047
- ),
1048
- );
1049
-
1050
- assert.deepEqual(await starts(icalData), [
1051
- "2026-09-08T10:00:00Z",
1052
- "2026-09-21T13:00:00Z",
1053
- "2026-09-29T10:00:00Z",
1054
- "2026-10-06T10:00:00Z",
1055
- ]);
1056
- });
1057
-
1058
- it("keeps them on a monthly rule whose occurrences do not move by the same days", async () => {
1059
- const calendar = await read(
1060
- series(
1061
- "DTSTART:20260914T090000Z",
1062
- "DTEND:20260914T100000Z",
1063
- "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
1064
- "EXDATE:20261012T090000Z",
1065
- ),
1066
- );
1067
-
1068
- const icalData = replaced(
1069
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1070
- start: "2026-09-15T09:00:00Z",
1071
- end: "2026-09-15T10:00:00Z",
1072
- }),
1073
- );
1074
-
1075
- assert.deepEqual(await starts(icalData), [
1076
- "2026-09-15T09:00:00Z",
1077
- "2026-11-17T09:00:00Z",
1078
- ]);
1079
- });
1080
-
1081
- it("moves an occurrence edited only in its title along with the series", async () => {
1082
- const calendar = await read(
1083
- withOverride(
1084
- [
1085
- "DTSTART:20260907T090000Z",
1086
- "DTEND:20260907T100000Z",
1087
- "RRULE:FREQ=WEEKLY;COUNT=3",
1088
- ],
1089
- [
1090
- "RECURRENCE-ID:20260914T090000Z",
1091
- "DTSTART:20260914T090000Z",
1092
- "DTEND:20260914T100000Z",
1093
- "SUMMARY:Stand-up, renamed",
1094
- ],
1095
- ),
1096
- );
1097
-
1098
- const icalData = replaced(
1099
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1100
- start: "2026-09-07T10:00:00Z",
1101
- end: "2026-09-07T11:30:00Z",
1102
- }),
1103
- );
1104
-
1105
- const expanded = expandCalendar(await read(icalData), "").occurrences;
1106
- assert.deepEqual(
1107
- expanded.map((occurrence) => [occurrence.startAt, occurrence.endAt]),
1108
- [
1109
- ["2026-09-07T10:00:00Z", "2026-09-07T11:30:00Z"],
1110
- ["2026-09-14T10:00:00Z", "2026-09-14T11:30:00Z"],
1111
- ["2026-09-21T10:00:00Z", "2026-09-21T11:30:00Z"],
1112
- ],
1113
- );
1114
- });
1115
-
1116
- it("keeps them on a zoned series across a clock change", async () => {
1117
- const calendar = await read(
1118
- withOverride(
1119
- [
1120
- "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
1121
- "DTEND;TZID=Europe/Amsterdam:20261015T100000",
1122
- "RRULE:FREQ=WEEKLY;COUNT=4",
1123
- "EXDATE;TZID=Europe/Amsterdam:20261022T090000",
1124
- ],
1125
- [
1126
- "RECURRENCE-ID;TZID=Europe/Amsterdam:20261029T090000",
1127
- "DTSTART;TZID=Europe/Amsterdam:20261029T150000",
1128
- "DTEND;TZID=Europe/Amsterdam:20261029T160000",
1129
- "SUMMARY:Stand-up, moved",
1130
- ],
1131
- ),
1132
- );
1133
-
1134
- const icalData = replaced(
1135
- await applyScopedUpdate(
1136
- calendar,
1137
- "Europe/Amsterdam",
1138
- input(RecurrenceScope.All, "2026-11-05T08:00:00Z"),
1139
- {
1140
- start: "2026-11-05T11:00:00+01:00",
1141
- end: "2026-11-05T12:00:00+01:00",
1142
- timeZone: "Europe/Amsterdam",
1143
- },
1144
- ),
1145
- );
1146
-
1147
- assert.deepEqual(await starts(icalData, "Europe/Amsterdam"), [
1148
- "2026-10-15T09:00:00Z",
1149
- "2026-10-29T14:00:00Z",
1150
- "2026-11-05T10:00:00Z",
1151
- ]);
1152
- });
1153
-
1154
- it("keeps them when the series becomes all-day", async () => {
1155
- const calendar = await read(deletedAndEdited);
1156
-
1157
- const icalData = replaced(
1158
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1159
- allDay: true,
1160
- start: "2026-09-07",
1161
- end: "2026-09-08",
1162
- }),
1163
- );
1164
-
1165
- const expanded = await starts(icalData);
1166
- assert.equal(expanded.length, 4);
1167
- assert.ok(
1168
- !expanded.some((start) => start.startsWith("2026-09-14")),
1169
- `the deleted occurrence stays deleted: ${expanded.join(", ")}`,
1170
- );
1171
- assert.equal(
1172
- expanded.filter((start) => start.startsWith("2026-09-21")).length,
1173
- 1,
1174
- `the edited occurrence shows once: ${expanded.join(", ")}`,
1175
- );
1176
- });
1177
- });
1178
-
1179
- describe("a whole-series move the review found", () => {
1180
- const firstMonday = () =>
1181
- read(
1182
- series(
1183
- "DTSTART:20260907T090000Z",
1184
- "DTEND:20260907T100000Z",
1185
- "RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=4",
1186
- ),
1187
- );
1188
-
1189
- it("takes a monthly weekday from the moved second occurrence", async () => {
1190
- const icalData = replaced(
1191
- await applyScopedUpdate(
1192
- await firstMonday(),
1193
- "",
1194
- input(RecurrenceScope.All, "2026-10-05T09:00:00Z"),
1195
- { start: "2026-10-06T09:00:00Z", end: "2026-10-06T10:00:00Z" },
1196
- ),
1197
- );
1198
-
1199
- const [master] = events(icalData);
1200
- assert.ok(
1201
- master?.includes("RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=1TU"),
1202
- `the rule is the first Tuesday: ${master?.join(" | ")}`,
1203
- );
1204
- assert.deepEqual(await starts(icalData), [
1205
- "2026-09-01T09:00:00Z",
1206
- "2026-10-06T09:00:00Z",
1207
- "2026-11-03T09:00:00Z",
1208
- "2026-12-01T09:00:00Z",
1209
- ]);
1210
- });
1211
-
1212
- it("takes a monthly weekday from the moved third occurrence", async () => {
1213
- const icalData = replaced(
1214
- await applyScopedUpdate(
1215
- await firstMonday(),
1216
- "",
1217
- input(RecurrenceScope.All, "2026-11-02T09:00:00Z"),
1218
- { start: "2026-11-03T09:00:00Z", end: "2026-11-03T10:00:00Z" },
1219
- ),
1220
- );
1221
-
1222
- assert.deepEqual(await starts(icalData), [
1223
- "2026-09-01T09:00:00Z",
1224
- "2026-10-06T09:00:00Z",
1225
- "2026-11-03T09:00:00Z",
1226
- "2026-12-01T09:00:00Z",
1227
- ]);
1228
- });
1229
-
1230
- it("keeps the moved occurrence's place in its month for a second weekday", async () => {
1231
- const calendar = await read(
1232
- series(
1233
- "DTSTART:20260914T090000Z",
1234
- "DTEND:20260914T100000Z",
1235
- "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
1236
- ),
1237
- );
1238
-
1239
- const icalData = replaced(
1240
- await applyScopedUpdate(
1241
- calendar,
1242
- "",
1243
- input(RecurrenceScope.All, "2026-10-12T09:00:00Z"),
1244
- { start: "2026-10-13T09:00:00Z", end: "2026-10-13T10:00:00Z" },
1245
- ),
1246
- );
1247
-
1248
- assert.deepEqual(await starts(icalData), [
1249
- "2026-09-08T09:00:00Z",
1250
- "2026-10-13T09:00:00Z",
1251
- "2026-11-10T09:00:00Z",
1252
- ]);
1253
- });
1254
-
1255
- it("keeps a fifth weekday a fifth weekday", async () => {
1256
- const calendar = await read(
1257
- series(
1258
- "DTSTART:20260330T090000Z",
1259
- "DTEND:20260330T100000Z",
1260
- "RRULE:FREQ=MONTHLY;BYDAY=5MO;COUNT=2",
1261
- ),
1262
- );
1263
-
1264
- const icalData = replaced(
1265
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1266
- start: "2026-03-31T09:00:00Z",
1267
- end: "2026-03-31T10:00:00Z",
1268
- }),
1269
- );
1270
-
1271
- const [master] = events(icalData);
1272
- assert.ok(
1273
- master?.includes("RRULE:FREQ=MONTHLY;COUNT=2;BYDAY=5TU"),
1274
- `the rule is the fifth Tuesday: ${master?.join(" | ")}`,
1275
- );
1276
- assert.deepEqual(await starts(icalData), [
1277
- "2026-03-31T09:00:00Z",
1278
- "2026-06-30T09:00:00Z",
1279
- ]);
1280
- });
1281
-
1282
- it("keeps a daily Monday-to-Friday rule when the move stays on a weekday", async () => {
1283
- const calendar = await read(
1284
- series(
1285
- "DTSTART:20260907T090000Z",
1286
- "DTEND:20260907T100000Z",
1287
- "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1288
- ),
1289
- );
1290
-
1291
- const icalData = replaced(
1292
- await applyScopedUpdate(
1293
- calendar,
1294
- "",
1295
- input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
1296
- { start: "2026-09-08T10:00:00Z", end: "2026-09-08T11:00:00Z" },
1297
- ),
1298
- );
1299
-
1300
- const [master] = events(icalData);
1301
- assert.ok(
1302
- master?.includes("RRULE:FREQ=DAILY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
1303
- `the weekday set is kept: ${master?.join(" | ")}`,
1304
- );
1305
- assert.deepEqual(await starts(icalData), [
1306
- "2026-09-07T10:00:00Z",
1307
- "2026-09-08T10:00:00Z",
1308
- "2026-09-09T10:00:00Z",
1309
- "2026-09-10T10:00:00Z",
1310
- "2026-09-11T10:00:00Z",
1311
- "2026-09-14T10:00:00Z",
1312
- ]);
1313
- });
1314
-
1315
- it("keeps the app's every-weekday rule when the move stays on a weekday", async () => {
1316
- const calendar = await read(
1317
- series(
1318
- "DTSTART:20260907T090000Z",
1319
- "DTEND:20260907T100000Z",
1320
- "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1321
- ),
1322
- );
1323
-
1324
- const icalData = replaced(
1325
- await applyScopedUpdate(
1326
- calendar,
1327
- "",
1328
- input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
1329
- { start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
1330
- ),
1331
- );
1332
-
1333
- const [master] = events(icalData);
1334
- assert.ok(
1335
- master?.includes("RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
1336
- );
1337
- assert.deepEqual(await starts(icalData), [
1338
- "2026-09-07T09:00:00Z",
1339
- "2026-09-08T09:00:00Z",
1340
- "2026-09-09T09:00:00Z",
1341
- "2026-09-10T09:00:00Z",
1342
- "2026-09-11T09:00:00Z",
1343
- "2026-09-14T09:00:00Z",
1344
- ]);
1345
- });
1346
-
1347
- it("refuses to move an every-weekday series onto a weekend", async () => {
1348
- const calendar = await read(
1349
- series(
1350
- "DTSTART:20260907T090000Z",
1351
- "DTEND:20260907T100000Z",
1352
- "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1353
- ),
1354
- );
1355
-
1356
- const write = await applyScopedUpdate(
1357
- calendar,
1358
- "",
1359
- input(RecurrenceScope.All, "2026-09-11T09:00:00Z"),
1360
- { start: "2026-09-12T09:00:00Z", end: "2026-09-12T10:00:00Z" },
1361
- );
1362
-
1363
- assert.ok(!write.ok);
1364
- assert.equal(write.error.code, "UnmovableRecurrenceRule");
1365
- });
1366
-
1367
- it("moves an end date with the series so the last occurrence stays", async () => {
1368
- const calendar = await read(
1369
- series(
1370
- "DTSTART:20260907T090000Z",
1371
- "DTEND:20260907T100000Z",
1372
- "RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260928T090000Z",
1373
- "EXDATE:20260928T090000Z",
1374
- ),
1375
- );
1376
-
1377
- const icalData = replaced(
1378
- await applyScopedUpdate(
1379
- calendar,
1380
- "",
1381
- input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
1382
- { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
1383
- ),
1384
- );
1385
-
1386
- const [master] = events(icalData);
1387
- assert.ok(
1388
- master?.includes("RRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20260929T090000Z"),
1389
- `the end date moves with the series: ${master?.join(" | ")}`,
1390
- );
1391
- assert.deepEqual(await starts(icalData), [
1392
- "2026-09-08T09:00:00Z",
1393
- "2026-09-15T09:00:00Z",
1394
- "2026-09-22T09:00:00Z",
1395
- ]);
1396
- });
1397
-
1398
- it("keeps the last occurrence of a series with an end date moved later in the day", async () => {
1399
- const calendar = await read(
1400
- series(
1401
- "DTSTART:20260907T090000Z",
1402
- "DTEND:20260907T100000Z",
1403
- "RRULE:FREQ=WEEKLY;UNTIL=20260928T090000Z",
1404
- "EXDATE:20260921T090000Z",
1405
- ),
1406
- );
1407
-
1408
- const icalData = replaced(
1409
- await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1410
- start: "2026-09-07T10:00:00Z",
1411
- end: "2026-09-07T11:00:00Z",
1412
- }),
1413
- );
1414
-
1415
- assert.deepEqual(await starts(icalData), [
1416
- "2026-09-07T10:00:00Z",
1417
- "2026-09-14T10:00:00Z",
1418
- "2026-09-28T10:00:00Z",
1419
- ]);
1420
- });
1421
- });