@remit/calendar-service 0.0.1

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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@remit/calendar-service",
3
+ "version": "0.0.1",
4
+ "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test:typecheck": "tsgo --noEmit",
16
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
17
+ },
18
+ "dependencies": {
19
+ "@remit/data-ports": "*",
20
+ "@remit/domain-enums": "*",
21
+ "ical.js": "^2.2.1"
22
+ },
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/remit-mail/reader.git",
30
+ "directory": "packages/calendar-service"
31
+ }
32
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Why a VCALENDAR was refused. Every value names something the writer can act
3
+ * on — a caller maps these to a 4xx and repeats the message.
4
+ */
5
+ export type CalendarValidationCode =
6
+ /** The bytes are not parseable iCalendar at all. */
7
+ | "MalformedIcalendar"
8
+ /** The document's root is not a VCALENDAR. */
9
+ | "NotACalendar"
10
+ /** A component this collection does not store, e.g. VTODO or VJOURNAL. */
11
+ | "UnsupportedComponent"
12
+ /** A VCALENDAR with no VEVENT in it. */
13
+ | "NoEvent"
14
+ /** No VEVENT without a RECURRENCE-ID: overrides with nothing to override. */
15
+ | "NoMasterEvent"
16
+ /** More than one VEVENT without a RECURRENCE-ID. */
17
+ | "MultipleMasterEvents"
18
+ /** A VEVENT with no UID, or an empty one. */
19
+ | "MissingUid"
20
+ /** VEVENTs in one resource declaring different UIDs. */
21
+ | "MismatchedUid"
22
+ /** A VEVENT with no DTSTART. */
23
+ | "MissingDtStart"
24
+ /** A VEVENT whose end precedes its start. */
25
+ | "BackwardsEnd";
26
+
27
+ export interface CalendarValidationError {
28
+ code: CalendarValidationCode;
29
+ message: string;
30
+ }
31
+
32
+ /**
33
+ * The outcome of reading a VCALENDAR. A refusal is a value, not a throw:
34
+ * malformed input from a client is an expected outcome of this boundary, and
35
+ * every caller has to render it rather than crash on it.
36
+ */
37
+ export type CalendarResult<T> =
38
+ | { ok: true; value: T }
39
+ | { ok: false; error: CalendarValidationError };
40
+
41
+ export const calendarFailure = <T>(
42
+ code: CalendarValidationCode,
43
+ message: string,
44
+ ): CalendarResult<T> => ({ ok: false, error: { code, message } });
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { computeEtag } from "./etag.js";
4
+ import { singleEvent } from "./fixtures.js";
5
+ import { parseCalendar, serializeCalendar } from "./parse.js";
6
+
7
+ const RESOURCE = singleEvent(
8
+ "DTSTART:20260826T090000Z",
9
+ "DTEND:20260826T100000Z",
10
+ "SUMMARY:Quarterly review",
11
+ "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
12
+ );
13
+
14
+ const reserialize = async (icalData: string): Promise<string> => {
15
+ const parsed = await parseCalendar(icalData);
16
+ assert.ok(parsed.ok);
17
+ return serializeCalendar(parsed.value.component);
18
+ };
19
+
20
+ describe("computeEtag", () => {
21
+ it("is a bare sha256 hex digest, unquoted", () => {
22
+ assert.match(computeEtag(RESOURCE), /^[0-9a-f]{64}$/);
23
+ });
24
+
25
+ it("is stable across a parse and serialize", async () => {
26
+ const once = await reserialize(RESOURCE);
27
+
28
+ assert.equal(computeEtag(await reserialize(once)), computeEtag(once));
29
+ });
30
+
31
+ it("moves when the event changes", () => {
32
+ assert.notEqual(
33
+ computeEtag(RESOURCE),
34
+ computeEtag(
35
+ singleEvent(
36
+ "DTSTART:20260826T090000Z",
37
+ "DTEND:20260826T110000Z",
38
+ "SUMMARY:Quarterly review",
39
+ "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
40
+ ),
41
+ ),
42
+ );
43
+ });
44
+
45
+ it("hashes the stored bytes, so line endings are never normalized away", () => {
46
+ // Two resources that differ only in line endings are two different
47
+ // resources: the store keeps what it was given, and a tag computed over a
48
+ // normalized copy would report them as the same bytes.
49
+ assert.notEqual(
50
+ computeEtag(RESOURCE),
51
+ computeEtag(RESOURCE.replace(/\r\n/g, "\n")),
52
+ );
53
+ });
54
+ });
package/src/etag.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Strong entity tag over the resource's stored bytes.
5
+ *
6
+ * Computed over the bytes exactly as they are stored — CRLF and all — rather
7
+ * than over a reparse of them, so the tag of an untouched resource never moves.
8
+ * Unquoted: HTTP's quoting is the transport's business, and a stored tag that
9
+ * carried the quotes would have to be stripped by everything that compares it.
10
+ */
11
+ export const computeEtag = (icalData: string): string =>
12
+ createHash("sha256").update(icalData, "utf8").digest("hex");
@@ -0,0 +1,333 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ CALENDAR_EXPANSION_HORIZON_DAYS,
5
+ CALENDAR_EXPANSION_MAX_OCCURRENCES,
6
+ expandCalendar,
7
+ } from "./expand.js";
8
+ import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
9
+ import { parseCalendar } from "./parse.js";
10
+
11
+ const expand = async (icalData: string, timezone = "") => {
12
+ const parsed = await parseCalendar(icalData);
13
+ assert.ok(parsed.ok, `expected a parse, got ${JSON.stringify(parsed)}`);
14
+ return expandCalendar(parsed.value, timezone);
15
+ };
16
+
17
+ const amsterdamSeries = (...eventLines: string[]): string =>
18
+ ical(
19
+ "BEGIN:VCALENDAR",
20
+ "VERSION:2.0",
21
+ ...AMSTERDAM_VTIMEZONE,
22
+ "BEGIN:VEVENT",
23
+ "UID:weekly@example.com",
24
+ "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
25
+ "DTEND;TZID=Europe/Amsterdam:20261015T100000",
26
+ ...eventLines,
27
+ "END:VEVENT",
28
+ "END:VCALENDAR",
29
+ );
30
+
31
+ describe("expandCalendar", () => {
32
+ it("writes a single occurrence under an empty recurrenceId", async () => {
33
+ const expansion = await expand(
34
+ singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
35
+ );
36
+
37
+ assert.deepEqual(expansion.occurrences, [
38
+ {
39
+ recurrenceId: "",
40
+ startAt: "2026-08-26T09:00:00Z",
41
+ endAt: "2026-08-26T10:00:00Z",
42
+ allDay: false,
43
+ },
44
+ ]);
45
+ assert.equal(expansion.expandedThrough, "");
46
+ });
47
+
48
+ it("keeps a weekly series at its local hour across the end of DST", async () => {
49
+ // 09:00 in Amsterdam is 07:00Z until the clocks go back on 2026-10-25 and
50
+ // 08:00Z after. An expansion that iterated instants instead of local times
51
+ // would move the meeting an hour for half the year.
52
+ const expansion = await expand(
53
+ amsterdamSeries("RRULE:FREQ=WEEKLY;COUNT=4"),
54
+ );
55
+
56
+ assert.deepEqual(
57
+ expansion.occurrences.map((occurrence) => occurrence.startAt),
58
+ [
59
+ "2026-10-15T07:00:00Z",
60
+ "2026-10-22T07:00:00Z",
61
+ "2026-10-29T08:00:00Z",
62
+ "2026-11-05T08:00:00Z",
63
+ ],
64
+ );
65
+ assert.equal(expansion.expandedThrough, "");
66
+ });
67
+
68
+ it("keeps the same hour when the zone is named but not defined in the resource", async () => {
69
+ const expansion = await expand(
70
+ ical(
71
+ "BEGIN:VCALENDAR",
72
+ "VERSION:2.0",
73
+ "BEGIN:VEVENT",
74
+ "UID:weekly@example.com",
75
+ "DTSTART;TZID=Europe/Berlin:20261015T090000",
76
+ "DTEND;TZID=Europe/Berlin:20261015T100000",
77
+ "RRULE:FREQ=WEEKLY;COUNT=4",
78
+ "END:VEVENT",
79
+ "END:VCALENDAR",
80
+ ),
81
+ );
82
+
83
+ assert.deepEqual(
84
+ expansion.occurrences.map((occurrence) => occurrence.startAt),
85
+ [
86
+ "2026-10-15T07:00:00Z",
87
+ "2026-10-22T07:00:00Z",
88
+ "2026-10-29T08:00:00Z",
89
+ "2026-11-05T08:00:00Z",
90
+ ],
91
+ );
92
+ });
93
+
94
+ it("carries each occurrence's own end", async () => {
95
+ const expansion = await expand(
96
+ amsterdamSeries("RRULE:FREQ=WEEKLY;COUNT=4"),
97
+ );
98
+
99
+ assert.deepEqual(
100
+ expansion.occurrences.map((occurrence) => occurrence.endAt),
101
+ [
102
+ "2026-10-15T08:00:00Z",
103
+ "2026-10-22T08:00:00Z",
104
+ "2026-10-29T09:00:00Z",
105
+ "2026-11-05T09:00:00Z",
106
+ ],
107
+ );
108
+ });
109
+
110
+ it("drops an EXDATEd occurrence and keeps the rest at their slots", async () => {
111
+ const expansion = await expand(
112
+ amsterdamSeries(
113
+ "RRULE:FREQ=WEEKLY;COUNT=4",
114
+ "EXDATE;TZID=Europe/Amsterdam:20261022T090000",
115
+ ),
116
+ );
117
+
118
+ assert.deepEqual(
119
+ expansion.occurrences.map((occurrence) => occurrence.recurrenceId),
120
+ ["2026-10-15T07:00:00Z", "2026-10-29T08:00:00Z", "2026-11-05T08:00:00Z"],
121
+ );
122
+ });
123
+
124
+ it("moves an overridden occurrence but keeps the slot it replaces", async () => {
125
+ const expansion = await expand(
126
+ ical(
127
+ "BEGIN:VCALENDAR",
128
+ "VERSION:2.0",
129
+ ...AMSTERDAM_VTIMEZONE,
130
+ "BEGIN:VEVENT",
131
+ "UID:weekly@example.com",
132
+ "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
133
+ "DTEND;TZID=Europe/Amsterdam:20261015T100000",
134
+ "RRULE:FREQ=WEEKLY;COUNT=3",
135
+ "END:VEVENT",
136
+ "BEGIN:VEVENT",
137
+ "UID:weekly@example.com",
138
+ "RECURRENCE-ID;TZID=Europe/Amsterdam:20261022T090000",
139
+ "DTSTART;TZID=Europe/Amsterdam:20261022T140000",
140
+ "DTEND;TZID=Europe/Amsterdam:20261022T150000",
141
+ "END:VEVENT",
142
+ "END:VCALENDAR",
143
+ ),
144
+ );
145
+
146
+ const moved = expansion.occurrences.find(
147
+ (occurrence) => occurrence.recurrenceId === "2026-10-22T07:00:00Z",
148
+ );
149
+ assert.equal(moved?.startAt, "2026-10-22T12:00:00Z");
150
+ assert.equal(moved?.endAt, "2026-10-22T13:00:00Z");
151
+ });
152
+
153
+ it("indexes an override whose slot the rule never produces", async () => {
154
+ // What a client writes after moving one instance and then editing the
155
+ // series: the override's RECURRENCE-ID names a slot the new rule does not
156
+ // generate. The iterator walks only the rule's slots, so nothing but an
157
+ // explicit pass over the VEVENTs reaches this event.
158
+ const expansion = await expand(
159
+ ical(
160
+ "BEGIN:VCALENDAR",
161
+ "VERSION:2.0",
162
+ ...AMSTERDAM_VTIMEZONE,
163
+ "BEGIN:VEVENT",
164
+ "UID:weekly@example.com",
165
+ "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
166
+ "DTEND;TZID=Europe/Amsterdam:20261015T100000",
167
+ "RRULE:FREQ=WEEKLY;COUNT=2",
168
+ "END:VEVENT",
169
+ "BEGIN:VEVENT",
170
+ "UID:weekly@example.com",
171
+ "RECURRENCE-ID;TZID=Europe/Amsterdam:20261112T090000",
172
+ "DTSTART;TZID=Europe/Amsterdam:20261112T140000",
173
+ "DTEND;TZID=Europe/Amsterdam:20261112T153000",
174
+ "END:VEVENT",
175
+ "END:VCALENDAR",
176
+ ),
177
+ );
178
+
179
+ assert.deepEqual(
180
+ expansion.occurrences.map((occurrence) => occurrence.recurrenceId),
181
+ ["2026-10-15T07:00:00Z", "2026-10-22T07:00:00Z", "2026-11-12T08:00:00Z"],
182
+ );
183
+ const stranded = expansion.occurrences.at(-1);
184
+ assert.equal(stranded?.startAt, "2026-11-12T13:00:00Z");
185
+ assert.equal(stranded?.endAt, "2026-11-12T14:30:00Z");
186
+ });
187
+
188
+ it("indexes every instance of a resource that has overrides but no rule", async () => {
189
+ const expansion = await expand(
190
+ ical(
191
+ "BEGIN:VCALENDAR",
192
+ "VERSION:2.0",
193
+ "BEGIN:VEVENT",
194
+ "UID:edited@example.com",
195
+ "DTSTART:20260826T090000Z",
196
+ "DTEND:20260826T100000Z",
197
+ "END:VEVENT",
198
+ "BEGIN:VEVENT",
199
+ "UID:edited@example.com",
200
+ "RECURRENCE-ID:20260902T090000Z",
201
+ "DTSTART:20260902T110000Z",
202
+ "DTEND:20260902T120000Z",
203
+ "END:VEVENT",
204
+ "END:VCALENDAR",
205
+ ),
206
+ );
207
+
208
+ assert.deepEqual(
209
+ expansion.occurrences.map((occurrence) => occurrence.startAt),
210
+ ["2026-08-26T09:00:00Z", "2026-09-02T11:00:00Z"],
211
+ );
212
+ });
213
+
214
+ it("reads an override's end in the override's own zone", async () => {
215
+ // The moved instance was rewritten in a different zone from the series,
216
+ // and its DTEND names a third. Resolving either with the master's TZID
217
+ // puts this instance an hour out and changes how long it lasts.
218
+ const expansion = await expand(
219
+ ical(
220
+ "BEGIN:VCALENDAR",
221
+ "VERSION:2.0",
222
+ "BEGIN:VEVENT",
223
+ "UID:crosszone@example.com",
224
+ "DTSTART;TZID=Europe/Berlin:20260826T090000",
225
+ "DTEND;TZID=Europe/Berlin:20260826T100000",
226
+ "RRULE:FREQ=WEEKLY;COUNT=2",
227
+ "END:VEVENT",
228
+ "BEGIN:VEVENT",
229
+ "UID:crosszone@example.com",
230
+ "RECURRENCE-ID;TZID=Europe/Berlin:20260902T090000",
231
+ "DTSTART;TZID=Europe/London:20260902T090000",
232
+ "DTEND;TZID=Europe/Lisbon:20260902T103000",
233
+ "END:VEVENT",
234
+ "END:VCALENDAR",
235
+ ),
236
+ );
237
+
238
+ assert.deepEqual(
239
+ expansion.occurrences.map((occurrence) => [
240
+ occurrence.recurrenceId,
241
+ occurrence.startAt,
242
+ occurrence.endAt,
243
+ ]),
244
+ [
245
+ [
246
+ "2026-08-26T07:00:00Z",
247
+ "2026-08-26T07:00:00Z",
248
+ "2026-08-26T08:00:00Z",
249
+ ],
250
+ [
251
+ "2026-09-02T07:00:00Z",
252
+ "2026-09-02T08:00:00Z",
253
+ "2026-09-02T09:30:00Z",
254
+ ],
255
+ ],
256
+ );
257
+ });
258
+
259
+ it("expands an all-day series as whole days", async () => {
260
+ const expansion = await expand(
261
+ singleEvent(
262
+ "DTSTART;VALUE=DATE:20260826",
263
+ "DTEND;VALUE=DATE:20260827",
264
+ "RRULE:FREQ=DAILY;COUNT=2",
265
+ ),
266
+ "Europe/Berlin",
267
+ );
268
+
269
+ assert.deepEqual(expansion.occurrences, [
270
+ {
271
+ recurrenceId: "2026-08-25T22:00:00Z",
272
+ startAt: "2026-08-25T22:00:00Z",
273
+ endAt: "2026-08-26T22:00:00Z",
274
+ allDay: true,
275
+ },
276
+ {
277
+ recurrenceId: "2026-08-26T22:00:00Z",
278
+ startAt: "2026-08-26T22:00:00Z",
279
+ endAt: "2026-08-27T22:00:00Z",
280
+ allDay: true,
281
+ },
282
+ ]);
283
+ });
284
+
285
+ it("stops an open-ended series at the horizon and says how far it got", async () => {
286
+ const expansion = await expand(
287
+ singleEvent(
288
+ "DTSTART:20260101T090000Z",
289
+ "DTEND:20260101T100000Z",
290
+ "RRULE:FREQ=DAILY",
291
+ ),
292
+ );
293
+
294
+ assert.notEqual(expansion.expandedThrough, "");
295
+ const last = expansion.occurrences[expansion.occurrences.length - 1];
296
+ assert.equal(expansion.expandedThrough, last?.startAt);
297
+ assert.ok(
298
+ Date.parse(expansion.expandedThrough) -
299
+ Date.parse("2026-01-01T09:00:00Z") <=
300
+ CALENDAR_EXPANSION_HORIZON_DAYS * 24 * 60 * 60 * 1000,
301
+ "the last written occurrence is inside the horizon",
302
+ );
303
+ });
304
+
305
+ it("stops a dense series at the occurrence ceiling", async () => {
306
+ const expansion = await expand(
307
+ singleEvent(
308
+ "DTSTART:20260101T090000Z",
309
+ "DTEND:20260101T090100Z",
310
+ "RRULE:FREQ=MINUTELY",
311
+ ),
312
+ );
313
+
314
+ assert.equal(
315
+ expansion.occurrences.length,
316
+ CALENDAR_EXPANSION_MAX_OCCURRENCES,
317
+ );
318
+ assert.notEqual(expansion.expandedThrough, "");
319
+ });
320
+
321
+ it("says nothing about a horizon for a series that ends inside it", async () => {
322
+ const expansion = await expand(
323
+ singleEvent(
324
+ "DTSTART:20260101T090000Z",
325
+ "DTEND:20260101T100000Z",
326
+ "RRULE:FREQ=WEEKLY;UNTIL=20260301T090000Z",
327
+ ),
328
+ );
329
+
330
+ assert.equal(expansion.expandedThrough, "");
331
+ assert.equal(expansion.occurrences.length, 9);
332
+ });
333
+ });
package/src/expand.ts ADDED
@@ -0,0 +1,186 @@
1
+ import type { CalendarOccurrenceInput } from "@remit/data-ports";
2
+ import ICAL from "ical.js";
3
+ import type { ParsedCalendar } from "./parse.js";
4
+ import { hasRecurrence } from "./project.js";
5
+ import { resolveTime, toUtcIso, tzidOf } from "./time.js";
6
+
7
+ /**
8
+ * How far past a series' own start its occurrences are written out.
9
+ *
10
+ * A recurring event may have no end at all, so something has to bound the
11
+ * write. Two years covers every view a client asks for today and keeps the
12
+ * index one bounded write rather than an unbounded one; a resource whose series
13
+ * runs past it is marked with `expandedThrough` and stays the business of the
14
+ * live expansion that reads it.
15
+ */
16
+ export const CALENDAR_EXPANSION_HORIZON_DAYS = 730;
17
+
18
+ /**
19
+ * Ceiling on occurrences written for one resource, independent of the horizon.
20
+ * A per-minute RRULE fits three quarters of a million instances inside two
21
+ * years; the ceiling turns that into a marked, truncated index instead of a
22
+ * write that takes the process down.
23
+ */
24
+ export const CALENDAR_EXPANSION_MAX_OCCURRENCES = 1000;
25
+
26
+ const HORIZON_MS = CALENDAR_EXPANSION_HORIZON_DAYS * 24 * 60 * 60 * 1000;
27
+
28
+ export interface CalendarExpansion {
29
+ occurrences: CalendarOccurrenceInput[];
30
+ /**
31
+ * The instant through which `occurrences` is complete, or `""` when it holds
32
+ * the whole series.
33
+ */
34
+ expandedThrough: string;
35
+ }
36
+
37
+ /**
38
+ * Flattens a resource into the occurrence rows a date-range read returns.
39
+ *
40
+ * A non-recurring resource is one row under an empty `recurrenceId`. A
41
+ * recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
42
+ * as a UTC instant — the same canonical form an override's own RECURRENCE-ID
43
+ * resolves to, so an override lands on the occurrence it replaces instead of
44
+ * beside it.
45
+ *
46
+ * EXDATEs are ical.js's business — `ICAL.Event` skips them. Overrides are not
47
+ * left entirely to it: the iterator yields the master's own RRULE and RDATE
48
+ * slots, so an override whose RECURRENCE-ID names a slot the rule never
49
+ * produces — the shape a client writes after moving one instance and then
50
+ * editing the rule — would never be reached. Those are walked explicitly, so
51
+ * every VEVENT in the resource is an occurrence somebody can find.
52
+ */
53
+ export const expandCalendar = (
54
+ calendar: ParsedCalendar,
55
+ collectionTimezone: string,
56
+ ): CalendarExpansion => {
57
+ const resolve = (time: ICAL.Time, tzid: string) =>
58
+ resolveTime(time, tzid, collectionTimezone);
59
+
60
+ const startTzidOf = (component: ICAL.Component): string =>
61
+ tzidOf(component.getFirstProperty("dtstart"));
62
+
63
+ // DTEND carries its own TZID and need not match DTSTART's, so an end read
64
+ // with the start's zone silently changes the event's length. Only a stated
65
+ // DTEND has a zone of its own: an end ical.js derived from a duration is
66
+ // already in the start's zone, and hinting it with anything else is wrong.
67
+ const endTzidOf = (component: ICAL.Component): string =>
68
+ component.hasProperty("dtend")
69
+ ? tzidOf(component.getFirstProperty("dtend"))
70
+ : startTzidOf(component);
71
+
72
+ const occurrenceOf = (
73
+ recurrenceId: string,
74
+ startDate: ICAL.Time,
75
+ endDate: ICAL.Time,
76
+ startTzid: string,
77
+ endTzid: string,
78
+ ): CalendarOccurrenceInput => {
79
+ const start = resolve(startDate, startTzid);
80
+ const end = resolve(endDate, endTzid);
81
+ return {
82
+ recurrenceId,
83
+ startAt: start.isoUtc,
84
+ endAt: end.isoUtc,
85
+ allDay: start.isDate,
86
+ };
87
+ };
88
+
89
+ const event = new ICAL.Event(calendar.master);
90
+ for (const override of calendar.overrides) {
91
+ event.relateException(override);
92
+ }
93
+
94
+ if (!hasRecurrence(calendar)) {
95
+ return {
96
+ occurrences: [
97
+ occurrenceOf(
98
+ "",
99
+ event.startDate,
100
+ event.endDate,
101
+ startTzidOf(calendar.master),
102
+ endTzidOf(calendar.master),
103
+ ),
104
+ ],
105
+ expandedThrough: "",
106
+ };
107
+ }
108
+
109
+ // The slot each override claims, canonicalized the same way the iterator's
110
+ // slots are, so the two can be compared at all.
111
+ const overrideBySlot = new Map<string, ICAL.Component>();
112
+ for (const override of calendar.overrides) {
113
+ const recurrenceId = override.getFirstPropertyValue("recurrence-id");
114
+ if (!(recurrenceId instanceof ICAL.Time)) continue;
115
+ overrideBySlot.set(
116
+ resolve(recurrenceId, tzidOf(override.getFirstProperty("recurrence-id")))
117
+ .isoUtc,
118
+ override,
119
+ );
120
+ }
121
+
122
+ const seriesStart = resolve(
123
+ event.startDate,
124
+ startTzidOf(calendar.master),
125
+ ).instantMs;
126
+ const horizonMs = seriesStart + HORIZON_MS;
127
+ const occurrences: CalendarOccurrenceInput[] = [];
128
+ const claimed = new Set<string>();
129
+ const iterator = event.iterator();
130
+ let truncated = false;
131
+
132
+ let next = iterator.next();
133
+ while (next) {
134
+ const slot = resolve(next, startTzidOf(calendar.master));
135
+ if (
136
+ slot.instantMs > horizonMs ||
137
+ occurrences.length >= CALENDAR_EXPANSION_MAX_OCCURRENCES
138
+ ) {
139
+ truncated = true;
140
+ break;
141
+ }
142
+
143
+ // An overridden slot hands back the override's own DTSTART and DTEND, so
144
+ // the zones to read them in are the override's. A plain slot hands back
145
+ // the rule's start and an end derived from the master's duration, both in
146
+ // the master's start zone.
147
+ const source = overrideBySlot.get(slot.isoUtc);
148
+ const details = event.getOccurrenceDetails(next);
149
+ claimed.add(slot.isoUtc);
150
+ occurrences.push(
151
+ occurrenceOf(
152
+ slot.isoUtc,
153
+ details.startDate,
154
+ details.endDate,
155
+ startTzidOf(source ?? calendar.master),
156
+ source ? endTzidOf(source) : startTzidOf(calendar.master),
157
+ ),
158
+ );
159
+ next = iterator.next();
160
+ }
161
+
162
+ const iterated = occurrences[occurrences.length - 1];
163
+ const expandedThrough =
164
+ truncated && iterated ? toUtcIso(Date.parse(iterated.startAt)) : "";
165
+
166
+ // Overrides the rule never reached. Written regardless of the horizon: they
167
+ // are a bounded, explicit list, and one moved instance is exactly the thing
168
+ // a user goes looking for.
169
+ for (const [slot, override] of overrideBySlot) {
170
+ if (claimed.has(slot)) continue;
171
+ const overrideEvent = new ICAL.Event(override);
172
+ occurrences.push(
173
+ occurrenceOf(
174
+ slot,
175
+ overrideEvent.startDate,
176
+ overrideEvent.endDate,
177
+ startTzidOf(override),
178
+ endTzidOf(override),
179
+ ),
180
+ );
181
+ }
182
+
183
+ occurrences.sort((left, right) => left.startAt.localeCompare(right.startAt));
184
+
185
+ return { occurrences, expandedThrough };
186
+ };