@remit/calendar-service 0.0.1 → 0.0.3
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 +2 -2
- package/src/accept.test.ts +357 -0
- package/src/accept.ts +207 -0
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +53 -1
- package/src/memory-store.ts +303 -0
- package/src/parse.test.ts +11 -0
- package/src/parse.ts +7 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +1 -193
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/suggest.test.ts +329 -0
- package/src/suggest.ts +199 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
CalendarCollectionItem,
|
|
5
|
+
CalendarEventIndexItem,
|
|
6
|
+
CalendarObjectItem,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import { deriveCalendarObjectId } from "@remit/data-ports/id";
|
|
9
|
+
import {
|
|
10
|
+
CalendarColor,
|
|
11
|
+
CalendarComponentSet,
|
|
12
|
+
CalendarSource,
|
|
13
|
+
} from "@remit/domain-enums";
|
|
14
|
+
import { computeEtag } from "./etag.js";
|
|
15
|
+
import { expandCalendar } from "./expand.js";
|
|
16
|
+
import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
|
|
17
|
+
import { parseCalendar } from "./parse.js";
|
|
18
|
+
import { projectCalendar } from "./project.js";
|
|
19
|
+
import {
|
|
20
|
+
type CalendarWindowRepositories,
|
|
21
|
+
listBusySpans,
|
|
22
|
+
listCalendarInstances,
|
|
23
|
+
mergeBusySpans,
|
|
24
|
+
} from "./window.js";
|
|
25
|
+
|
|
26
|
+
const collection = (
|
|
27
|
+
overrides: Partial<CalendarCollectionItem> = {},
|
|
28
|
+
): CalendarCollectionItem => ({
|
|
29
|
+
calendarId: "cal-1",
|
|
30
|
+
accountConfigId: "acc-1",
|
|
31
|
+
urlSegment: "default",
|
|
32
|
+
displayName: "Calendar",
|
|
33
|
+
color: CalendarColor.Cal1,
|
|
34
|
+
componentSet: CalendarComponentSet.VeventOnly,
|
|
35
|
+
source: CalendarSource.Default,
|
|
36
|
+
timezone: "",
|
|
37
|
+
syncSequence: 1,
|
|
38
|
+
createdAt: 0,
|
|
39
|
+
updatedAt: 0,
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
interface StoredResource {
|
|
44
|
+
object: CalendarObjectItem;
|
|
45
|
+
rows: CalendarEventIndexItem[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A resource as the write path would have stored it — the projected columns and
|
|
50
|
+
* the occurrence rows both computed from the bytes, so a read test can never
|
|
51
|
+
* pass against a row the writer would not have produced.
|
|
52
|
+
*/
|
|
53
|
+
const store = async (
|
|
54
|
+
calendar: CalendarCollectionItem,
|
|
55
|
+
resourceName: string,
|
|
56
|
+
icalData: string,
|
|
57
|
+
): Promise<StoredResource> => {
|
|
58
|
+
const parsed = await parseCalendar(icalData);
|
|
59
|
+
assert.ok(parsed.ok, `expected a parse, got ${JSON.stringify(parsed)}`);
|
|
60
|
+
const projection = projectCalendar(parsed.value, calendar.timezone);
|
|
61
|
+
assert.ok(projection.ok);
|
|
62
|
+
const expansion = expandCalendar(parsed.value, calendar.timezone);
|
|
63
|
+
const calendarObjectId = deriveCalendarObjectId(
|
|
64
|
+
calendar.calendarId,
|
|
65
|
+
resourceName,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
object: {
|
|
70
|
+
...projection.value,
|
|
71
|
+
calendarObjectId,
|
|
72
|
+
calendarId: calendar.calendarId,
|
|
73
|
+
resourceName,
|
|
74
|
+
icalData,
|
|
75
|
+
etag: computeEtag(icalData),
|
|
76
|
+
syncSequence: 1,
|
|
77
|
+
expandedThrough: expansion.expandedThrough,
|
|
78
|
+
createdAt: 0,
|
|
79
|
+
updatedAt: 0,
|
|
80
|
+
},
|
|
81
|
+
rows: expansion.occurrences.map((occurrence) => ({
|
|
82
|
+
...occurrence,
|
|
83
|
+
calendarId: calendar.calendarId,
|
|
84
|
+
calendarObjectId,
|
|
85
|
+
createdAt: 0,
|
|
86
|
+
updatedAt: 0,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const repositories = (
|
|
92
|
+
resources: StoredResource[],
|
|
93
|
+
): CalendarWindowRepositories => ({
|
|
94
|
+
calendarObject: {
|
|
95
|
+
find: async (calendarId, calendarObjectId) =>
|
|
96
|
+
resources
|
|
97
|
+
.map((resource) => resource.object)
|
|
98
|
+
.find(
|
|
99
|
+
(object) =>
|
|
100
|
+
object.calendarId === calendarId &&
|
|
101
|
+
object.calendarObjectId === calendarObjectId,
|
|
102
|
+
) ?? null,
|
|
103
|
+
listIncompleteExpansions: async (calendarId, instant) =>
|
|
104
|
+
resources
|
|
105
|
+
.map((resource) => resource.object)
|
|
106
|
+
.filter(
|
|
107
|
+
(object) =>
|
|
108
|
+
object.calendarId === calendarId &&
|
|
109
|
+
object.expandedThrough !== "" &&
|
|
110
|
+
object.expandedThrough < instant,
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
calendarEventIndex: {
|
|
114
|
+
listByStartRange: async (calendarId, startAt, endAt) =>
|
|
115
|
+
resources
|
|
116
|
+
.flatMap((resource) => resource.rows)
|
|
117
|
+
.filter(
|
|
118
|
+
(row) =>
|
|
119
|
+
row.calendarId === calendarId &&
|
|
120
|
+
row.startAt >= startAt &&
|
|
121
|
+
row.startAt < endAt,
|
|
122
|
+
)
|
|
123
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt)),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("listCalendarInstances", () => {
|
|
128
|
+
it("returns the occurrences that start in the window and nothing after it", async () => {
|
|
129
|
+
const calendar = collection();
|
|
130
|
+
const weekly = await store(
|
|
131
|
+
calendar,
|
|
132
|
+
"weekly.ics",
|
|
133
|
+
ical(
|
|
134
|
+
"BEGIN:VCALENDAR",
|
|
135
|
+
"VERSION:2.0",
|
|
136
|
+
"BEGIN:VEVENT",
|
|
137
|
+
"UID:weekly@example.com",
|
|
138
|
+
"DTSTART:20260907T090000Z",
|
|
139
|
+
"DTEND:20260907T100000Z",
|
|
140
|
+
"SUMMARY:Stand-up",
|
|
141
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
142
|
+
"END:VEVENT",
|
|
143
|
+
"END:VCALENDAR",
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const instances = await listCalendarInstances(
|
|
148
|
+
repositories([weekly]),
|
|
149
|
+
[calendar],
|
|
150
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-28T00:00:00Z" },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
instances.map((instance) => instance.startAt),
|
|
155
|
+
["2026-09-14T09:00:00Z", "2026-09-21T09:00:00Z"],
|
|
156
|
+
);
|
|
157
|
+
assert.equal(instances[0]?.summary, "Stand-up");
|
|
158
|
+
assert.equal(instances[0]?.etag, weekly.object.etag);
|
|
159
|
+
assert.equal(instances[0]?.hasRecurrence, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps an occurrence that began before the window and runs into it", async () => {
|
|
163
|
+
const calendar = collection();
|
|
164
|
+
const overnight = await store(
|
|
165
|
+
calendar,
|
|
166
|
+
"overnight.ics",
|
|
167
|
+
singleEvent(
|
|
168
|
+
"DTSTART:20260913T230000Z",
|
|
169
|
+
"DTEND:20260914T010000Z",
|
|
170
|
+
"SUMMARY:Deploy window",
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const instances = await listCalendarInstances(
|
|
175
|
+
repositories([overnight]),
|
|
176
|
+
[calendar],
|
|
177
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-15T00:00:00Z" },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
assert.deepEqual(
|
|
181
|
+
instances.map((instance) => instance.summary),
|
|
182
|
+
["Deploy window"],
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("leaves out an event that ended before the window opened", async () => {
|
|
187
|
+
const calendar = collection();
|
|
188
|
+
const earlier = await store(
|
|
189
|
+
calendar,
|
|
190
|
+
"earlier.ics",
|
|
191
|
+
singleEvent("DTSTART:20260913T090000Z", "DTEND:20260913T100000Z"),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const instances = await listCalendarInstances(
|
|
195
|
+
repositories([earlier]),
|
|
196
|
+
[calendar],
|
|
197
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-15T00:00:00Z" },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
assert.deepEqual(instances, []);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("expands a series live when the stored index stops before the window", async () => {
|
|
204
|
+
const calendar = collection();
|
|
205
|
+
// Open-ended and old: the horizon anchors at the series' own start, so the
|
|
206
|
+
// index runs out two years in and today is nowhere in it.
|
|
207
|
+
const openEnded = await store(
|
|
208
|
+
calendar,
|
|
209
|
+
"open.ics",
|
|
210
|
+
ical(
|
|
211
|
+
"BEGIN:VCALENDAR",
|
|
212
|
+
"VERSION:2.0",
|
|
213
|
+
"BEGIN:VEVENT",
|
|
214
|
+
"UID:open@example.com",
|
|
215
|
+
"DTSTART:20200907T090000Z",
|
|
216
|
+
"DTEND:20200907T100000Z",
|
|
217
|
+
"SUMMARY:Weekly one-to-one",
|
|
218
|
+
"RRULE:FREQ=WEEKLY",
|
|
219
|
+
"END:VEVENT",
|
|
220
|
+
"END:VCALENDAR",
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.notEqual(
|
|
225
|
+
openEnded.object.expandedThrough,
|
|
226
|
+
"",
|
|
227
|
+
"the fixture only tests anything if the index really is incomplete",
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
openEnded.rows.some((row) => row.startAt >= "2026-09-14T00:00:00Z"),
|
|
231
|
+
false,
|
|
232
|
+
"and the index really does stop before the window",
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const instances = await listCalendarInstances(
|
|
236
|
+
repositories([openEnded]),
|
|
237
|
+
[calendar],
|
|
238
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-28T00:00:00Z" },
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
instances.map((instance) => instance.startAt),
|
|
243
|
+
["2026-09-14T09:00:00Z", "2026-09-21T09:00:00Z"],
|
|
244
|
+
);
|
|
245
|
+
assert.equal(instances[0]?.summary, "Weekly one-to-one");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("serves a live-expanded series from one source, never twice", async () => {
|
|
249
|
+
const calendar = collection();
|
|
250
|
+
const openEnded = await store(
|
|
251
|
+
calendar,
|
|
252
|
+
"open.ics",
|
|
253
|
+
ical(
|
|
254
|
+
"BEGIN:VCALENDAR",
|
|
255
|
+
"VERSION:2.0",
|
|
256
|
+
"BEGIN:VEVENT",
|
|
257
|
+
"UID:open@example.com",
|
|
258
|
+
"DTSTART:20200907T090000Z",
|
|
259
|
+
"DTEND:20200907T100000Z",
|
|
260
|
+
"RRULE:FREQ=WEEKLY",
|
|
261
|
+
"END:VEVENT",
|
|
262
|
+
"END:VCALENDAR",
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// A window the index does cover, for a resource still marked incomplete.
|
|
267
|
+
const instances = await listCalendarInstances(
|
|
268
|
+
repositories([openEnded]),
|
|
269
|
+
[calendar],
|
|
270
|
+
{ from: "2020-09-14T00:00:00Z", to: "2020-09-28T00:00:00Z" },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
instances.map((instance) => instance.startAt),
|
|
275
|
+
["2020-09-14T09:00:00Z", "2020-09-21T09:00:00Z"],
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps a series at its local hour across a DST transition", async () => {
|
|
280
|
+
const calendar = collection({ timezone: "Europe/Amsterdam" });
|
|
281
|
+
const amsterdam = await store(
|
|
282
|
+
calendar,
|
|
283
|
+
"amsterdam.ics",
|
|
284
|
+
ical(
|
|
285
|
+
"BEGIN:VCALENDAR",
|
|
286
|
+
"VERSION:2.0",
|
|
287
|
+
...AMSTERDAM_VTIMEZONE,
|
|
288
|
+
"BEGIN:VEVENT",
|
|
289
|
+
"UID:amsterdam@example.com",
|
|
290
|
+
"DTSTART;TZID=Europe/Amsterdam:20261015T090000",
|
|
291
|
+
"DTEND;TZID=Europe/Amsterdam:20261015T100000",
|
|
292
|
+
"SUMMARY:Stand-up",
|
|
293
|
+
"RRULE:FREQ=WEEKLY;COUNT=4",
|
|
294
|
+
"END:VEVENT",
|
|
295
|
+
"END:VCALENDAR",
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const instances = await listCalendarInstances(
|
|
300
|
+
repositories([amsterdam]),
|
|
301
|
+
[calendar],
|
|
302
|
+
{ from: "2026-10-01T00:00:00Z", to: "2026-11-30T00:00:00Z" },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
instances.map((instance) => instance.start),
|
|
307
|
+
[
|
|
308
|
+
"2026-10-15T09:00:00+02:00",
|
|
309
|
+
"2026-10-22T09:00:00+02:00",
|
|
310
|
+
"2026-10-29T09:00:00+01:00",
|
|
311
|
+
"2026-11-05T09:00:00+01:00",
|
|
312
|
+
],
|
|
313
|
+
);
|
|
314
|
+
assert.deepEqual(
|
|
315
|
+
instances.map((instance) => instance.startAt),
|
|
316
|
+
[
|
|
317
|
+
"2026-10-15T07:00:00Z",
|
|
318
|
+
"2026-10-22T07:00:00Z",
|
|
319
|
+
"2026-10-29T08:00:00Z",
|
|
320
|
+
"2026-11-05T08:00:00Z",
|
|
321
|
+
],
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("mergeBusySpans", () => {
|
|
327
|
+
it("collapses overlapping and touching spans", () => {
|
|
328
|
+
assert.deepEqual(
|
|
329
|
+
mergeBusySpans([
|
|
330
|
+
{ startMs: 30, endMs: 40 },
|
|
331
|
+
{ startMs: 0, endMs: 10 },
|
|
332
|
+
{ startMs: 5, endMs: 20 },
|
|
333
|
+
{ startMs: 40, endMs: 50 },
|
|
334
|
+
]),
|
|
335
|
+
[
|
|
336
|
+
{ startMs: 0, endMs: 20 },
|
|
337
|
+
{ startMs: 30, endMs: 50 },
|
|
338
|
+
],
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("keeps a span that a longer one already covers out of the result", () => {
|
|
343
|
+
assert.deepEqual(
|
|
344
|
+
mergeBusySpans([
|
|
345
|
+
{ startMs: 0, endMs: 100 },
|
|
346
|
+
{ startMs: 20, endMs: 30 },
|
|
347
|
+
]),
|
|
348
|
+
[{ startMs: 0, endMs: 100 }],
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("listBusySpans", () => {
|
|
354
|
+
it("merges overlapping events across calendars into one stretch", async () => {
|
|
355
|
+
const work = collection({ calendarId: "cal-work", urlSegment: "work" });
|
|
356
|
+
const home = collection({ calendarId: "cal-home", urlSegment: "home" });
|
|
357
|
+
const standup = await store(
|
|
358
|
+
work,
|
|
359
|
+
"standup.ics",
|
|
360
|
+
singleEvent("DTSTART:20260914T090000Z", "DTEND:20260914T100000Z"),
|
|
361
|
+
);
|
|
362
|
+
const review = await store(
|
|
363
|
+
home,
|
|
364
|
+
"review.ics",
|
|
365
|
+
singleEvent("DTSTART:20260914T093000Z", "DTEND:20260914T110000Z"),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const spans = await listBusySpans(
|
|
369
|
+
repositories([standup, review]),
|
|
370
|
+
[work, home],
|
|
371
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-15T00:00:00Z" },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
assert.deepEqual(spans, [
|
|
375
|
+
{
|
|
376
|
+
startMs: Date.parse("2026-09-14T09:00:00Z"),
|
|
377
|
+
endMs: Date.parse("2026-09-14T11:00:00Z"),
|
|
378
|
+
},
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("leaves out a transparent event and a cancelled one", async () => {
|
|
383
|
+
const calendar = collection();
|
|
384
|
+
const transparent = await store(
|
|
385
|
+
calendar,
|
|
386
|
+
"transparent.ics",
|
|
387
|
+
singleEvent(
|
|
388
|
+
"DTSTART:20260914T090000Z",
|
|
389
|
+
"DTEND:20260914T100000Z",
|
|
390
|
+
"TRANSP:TRANSPARENT",
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
const cancelled = await store(
|
|
394
|
+
calendar,
|
|
395
|
+
"cancelled.ics",
|
|
396
|
+
singleEvent(
|
|
397
|
+
"DTSTART:20260914T110000Z",
|
|
398
|
+
"DTEND:20260914T120000Z",
|
|
399
|
+
"STATUS:CANCELLED",
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
const busy = await store(
|
|
403
|
+
calendar,
|
|
404
|
+
"busy.ics",
|
|
405
|
+
singleEvent("DTSTART:20260914T140000Z", "DTEND:20260914T150000Z"),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const spans = await listBusySpans(
|
|
409
|
+
repositories([transparent, cancelled, busy]),
|
|
410
|
+
[calendar],
|
|
411
|
+
{ from: "2026-09-14T00:00:00Z", to: "2026-09-15T00:00:00Z" },
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
assert.deepEqual(spans, [
|
|
415
|
+
{
|
|
416
|
+
startMs: Date.parse("2026-09-14T14:00:00Z"),
|
|
417
|
+
endMs: Date.parse("2026-09-14T15:00:00Z"),
|
|
418
|
+
},
|
|
419
|
+
]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("clips a span that runs past the window to the window", async () => {
|
|
423
|
+
const calendar = collection();
|
|
424
|
+
const overnight = await store(
|
|
425
|
+
calendar,
|
|
426
|
+
"overnight.ics",
|
|
427
|
+
singleEvent("DTSTART:20260913T230000Z", "DTEND:20260914T010000Z"),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const spans = await listBusySpans(repositories([overnight]), [calendar], {
|
|
431
|
+
from: "2026-09-14T00:00:00Z",
|
|
432
|
+
to: "2026-09-15T00:00:00Z",
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
assert.deepEqual(spans, [
|
|
436
|
+
{
|
|
437
|
+
startMs: Date.parse("2026-09-14T00:00:00Z"),
|
|
438
|
+
endMs: Date.parse("2026-09-14T01:00:00Z"),
|
|
439
|
+
},
|
|
440
|
+
]);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("an occurrence a client edited on its own", () => {
|
|
445
|
+
const withOverride = (...overrideLines: string[]) =>
|
|
446
|
+
ical(
|
|
447
|
+
"BEGIN:VCALENDAR",
|
|
448
|
+
"VERSION:2.0",
|
|
449
|
+
"BEGIN:VEVENT",
|
|
450
|
+
"UID:weekly@example.com",
|
|
451
|
+
"DTSTART:20260907T090000Z",
|
|
452
|
+
"DTEND:20260907T100000Z",
|
|
453
|
+
"SUMMARY:Stand-up",
|
|
454
|
+
"RRULE:FREQ=WEEKLY;COUNT=3",
|
|
455
|
+
"END:VEVENT",
|
|
456
|
+
"BEGIN:VEVENT",
|
|
457
|
+
"UID:weekly@example.com",
|
|
458
|
+
"RECURRENCE-ID:20260914T090000Z",
|
|
459
|
+
"DTSTART:20260914T090000Z",
|
|
460
|
+
"DTEND:20260914T100000Z",
|
|
461
|
+
...overrideLines,
|
|
462
|
+
"END:VEVENT",
|
|
463
|
+
"END:VCALENDAR",
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const window = { from: "2026-09-01T00:00:00Z", to: "2026-09-30T00:00:00Z" };
|
|
467
|
+
|
|
468
|
+
it("shows the override's summary, not the one the series carries", async () => {
|
|
469
|
+
const calendar = collection();
|
|
470
|
+
const stored = await store(
|
|
471
|
+
calendar,
|
|
472
|
+
"weekly.ics",
|
|
473
|
+
withOverride("SUMMARY:Stand-up (with the client team)"),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const instances = await listCalendarInstances(
|
|
477
|
+
repositories([stored]),
|
|
478
|
+
[calendar],
|
|
479
|
+
window,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
assert.deepEqual(
|
|
483
|
+
instances.map((instance) => instance.summary),
|
|
484
|
+
["Stand-up", "Stand-up (with the client team)", "Stand-up"],
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("stops counting one cancelled occurrence as busy time", async () => {
|
|
489
|
+
const calendar = collection();
|
|
490
|
+
const stored = await store(
|
|
491
|
+
calendar,
|
|
492
|
+
"weekly.ics",
|
|
493
|
+
withOverride("SUMMARY:Stand-up", "STATUS:CANCELLED"),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const spans = await listBusySpans(
|
|
497
|
+
repositories([stored]),
|
|
498
|
+
[calendar],
|
|
499
|
+
window,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
assert.deepEqual(
|
|
503
|
+
spans.map((span) => new Date(span.startMs).toISOString()),
|
|
504
|
+
["2026-09-07T09:00:00.000Z", "2026-09-21T09:00:00.000Z"],
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("stops counting one occurrence marked transparent as busy time", async () => {
|
|
509
|
+
const calendar = collection();
|
|
510
|
+
const stored = await store(
|
|
511
|
+
calendar,
|
|
512
|
+
"weekly.ics",
|
|
513
|
+
withOverride("SUMMARY:Stand-up", "TRANSP:TRANSPARENT"),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const spans = await listBusySpans(
|
|
517
|
+
repositories([stored]),
|
|
518
|
+
[calendar],
|
|
519
|
+
window,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
assert.equal(spans.length, 2);
|
|
523
|
+
});
|
|
524
|
+
});
|