@remit/calendar-service 0.0.8 → 0.0.10
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/package.json +1 -1
- package/src/errors.ts +2 -1
- package/src/scope.test.ts +949 -0
- package/src/scope.ts +509 -4
package/src/scope.test.ts
CHANGED
|
@@ -98,6 +98,222 @@ describe("applyScopedUpdate", () => {
|
|
|
98
98
|
assert.ok(master?.includes("DTSTART:20260907T090000Z"));
|
|
99
99
|
});
|
|
100
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
|
+
|
|
101
317
|
it("writes a RECURRENCE-ID override for one occurrence under scope=This", async () => {
|
|
102
318
|
const calendar = await read(WEEKLY);
|
|
103
319
|
|
|
@@ -470,3 +686,736 @@ describe("splitting a series whose DTSTART is not a UTC instant", () => {
|
|
|
470
686
|
assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260608T085959Z");
|
|
471
687
|
});
|
|
472
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
|
+
});
|