@remit/calendar-service 0.0.8 → 0.0.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
 
package/src/scope.ts CHANGED
@@ -29,7 +29,7 @@ export type ScopedWrite =
29
29
 
30
30
  export interface ScopedWriteInput {
31
31
  scope: RecurrenceScopeValue;
32
- /** ISO 8601 UTC instant naming the occurrence; `""` outside `This`/`Following`. */
32
+ /** ISO 8601 UTC instant naming the occurrence the write was made from, or `""`. */
33
33
  recurrenceId: string;
34
34
  /** UID the resource a `Following` split creates is written under. */
35
35
  followingUid: string;
@@ -286,6 +286,138 @@ const applyToMaster = async (
286
286
  return { ok: true, value: replaceWith(calendar) };
287
287
  };
288
288
 
289
+ const touchesTime = (patch: Partial<CalendarEventFields>): boolean =>
290
+ patch.start !== undefined ||
291
+ patch.end !== undefined ||
292
+ patch.allDay !== undefined ||
293
+ patch.timeZone !== undefined;
294
+
295
+ const civilDay = (time: ICAL.Time): number =>
296
+ Date.UTC(time.year, time.month - 1, time.day) / 86_400_000;
297
+
298
+ const shiftProperty = (
299
+ component: ICAL.Component,
300
+ name: string,
301
+ days: number,
302
+ ): void => {
303
+ const property = component.getFirstProperty(name);
304
+ const value = property?.getFirstValue();
305
+ if (!property || !(value instanceof ICAL.Time)) return;
306
+ const shifted = value.clone();
307
+ shifted.adjust(days, 0, 0, 0);
308
+ property.setValue(shifted);
309
+ };
310
+
311
+ /** Where an occurrence is drawn, which a moved one no longer shares with its slot. */
312
+ const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
313
+ if (occurrence.details) return occurrence.details.startDate;
314
+ const start = occurrence.override?.getFirstPropertyValue("dtstart");
315
+ return start instanceof ICAL.Time ? start : null;
316
+ };
317
+
318
+ /**
319
+ * The slot a value named under the old DTSTART, named under the new one: the
320
+ * same day of the series, at the series' new time and in its new zone.
321
+ */
322
+ const reslot = (
323
+ value: ICAL.Time,
324
+ before: ICAL.Time,
325
+ after: ICAL.Time,
326
+ ): ICAL.Time => {
327
+ const moved = after.clone();
328
+ moved.adjust(civilDay(value) - civilDay(before), 0, 0, 0);
329
+ return moved;
330
+ };
331
+
332
+ /**
333
+ * Keeps every override and excluded date on the slot it was written for once
334
+ * the series' own start has moved. A RECURRENCE-ID or an EXDATE matches a slot
335
+ * by its exact time, so leaving them behind would detach a moved occurrence
336
+ * from the one it replaces and bring a deleted one back.
337
+ */
338
+ const followSlots = (
339
+ calendar: ParsedCalendar,
340
+ before: ICAL.Time,
341
+ after: ICAL.Time,
342
+ ): void => {
343
+ if (before.isDate !== after.isDate) return;
344
+ const tzid = dtStartTzid(calendar.master);
345
+ for (const override of calendar.overrides) {
346
+ const recurrenceId = override.getFirstPropertyValue("recurrence-id");
347
+ if (!(recurrenceId instanceof ICAL.Time)) continue;
348
+ override.removeAllProperties("recurrence-id");
349
+ override.addProperty(
350
+ timeProperty("recurrence-id", reslot(recurrenceId, before, after), tzid),
351
+ );
352
+ }
353
+ for (const name of ["exdate", "rdate"]) {
354
+ for (const property of calendar.master.getAllProperties(name)) {
355
+ const values = property.getValues();
356
+ if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
357
+ continue;
358
+ }
359
+ calendar.master.removeProperty(property);
360
+ for (const value of values as ICAL.Time[]) {
361
+ calendar.master.addProperty(
362
+ timeProperty(name, reslot(value, before, after), tzid),
363
+ );
364
+ }
365
+ }
366
+ }
367
+ };
368
+
369
+ /**
370
+ * A whole-series edit made from one occurrence of it.
371
+ *
372
+ * A start or end in the patch is that occurrence's new time, so each is moved
373
+ * back by the days between where that occurrence is drawn and where the series
374
+ * begins. The series keeps its first day and its weekday, and only what was
375
+ * edited changes.
376
+ */
377
+ const applyToSeriesFrom = async (
378
+ calendar: ParsedCalendar,
379
+ collectionTimezone: string,
380
+ recurrenceId: string,
381
+ patch: Partial<CalendarEventFields>,
382
+ ): Promise<CalendarResult<ScopedWrite>> => {
383
+ const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
384
+ if (!found.ok) return found;
385
+ const seriesStart = calendar.master.getFirstPropertyValue("dtstart");
386
+ const shown = shownStart(found.value);
387
+ if (!(seriesStart instanceof ICAL.Time) || !shown) {
388
+ return calendarFailure(
389
+ "UnknownOccurrence",
390
+ `${recurrenceId} has no start to move the series from`,
391
+ );
392
+ }
393
+ const before = seriesStart.clone();
394
+ const beforeTzid = dtStartTzid(calendar.master);
395
+ const daysIn = civilDay(shown) - civilDay(before);
396
+
397
+ const applied = await applyEventFields(
398
+ calendar.master,
399
+ patch,
400
+ collectionTimezone,
401
+ );
402
+ if (!applied.ok) return applied;
403
+ if (patch.start !== undefined) {
404
+ shiftProperty(calendar.master, "dtstart", -daysIn);
405
+ }
406
+ if (patch.end !== undefined) {
407
+ shiftProperty(calendar.master, "dtend", -daysIn);
408
+ }
409
+
410
+ const after = calendar.master.getFirstPropertyValue("dtstart");
411
+ if (
412
+ after instanceof ICAL.Time &&
413
+ (after.toString() !== before.toString() ||
414
+ dtStartTzid(calendar.master) !== beforeTzid)
415
+ ) {
416
+ followSlots(calendar, before, after);
417
+ }
418
+ return { ok: true, value: replaceWith(calendar) };
419
+ };
420
+
289
421
  /**
290
422
  * The occurrence a `This` or `Following` write names, or `null` when the scope
291
423
  * collapses to the whole series — which is what "everything from the first
@@ -362,7 +494,8 @@ const overrideFor = async (
362
494
  /**
363
495
  * Turns an edit of one drawing of a series into the resource writes it means.
364
496
  *
365
- * `All` rewrites the master. `This` writes a RECURRENCE-ID override, which is
497
+ * `All` rewrites the master, moved from the occurrence it names when it names
498
+ * one. `This` writes a RECURRENCE-ID override, which is
366
499
  * the only thing iCalendar has for "this one is different". `Following` splits,
367
500
  * because a rule cannot change halfway through.
368
501
  */
@@ -373,7 +506,19 @@ export const applyScopedUpdate = async (
373
506
  patch: Partial<CalendarEventFields>,
374
507
  ): Promise<CalendarResult<ScopedWrite>> => {
375
508
  if (input.scope === RecurrenceScope.All) {
376
- return applyToMaster(calendar, collectionTimezone, patch);
509
+ if (
510
+ input.recurrenceId === "" ||
511
+ !hasRecurrence(calendar) ||
512
+ !touchesTime(patch)
513
+ ) {
514
+ return applyToMaster(calendar, collectionTimezone, patch);
515
+ }
516
+ return applyToSeriesFrom(
517
+ calendar,
518
+ collectionTimezone,
519
+ input.recurrenceId,
520
+ patch,
521
+ );
377
522
  }
378
523
 
379
524
  const anchored = anchorOf(calendar, collectionTimezone, input);