@oneuptime/common 13.0.3 → 13.0.4

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.
@@ -0,0 +1,1014 @@
1
+ import LayerUtil, {
2
+ LayerEventsResult,
3
+ PriorityCalendarEvents,
4
+ ROTATION_PERIOD_START_KEY,
5
+ } from "../../../Types/OnCallDutyPolicy/Layer";
6
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
7
+ import RestrictionTimes, {
8
+ RestrictionType,
9
+ WeeklyResctriction,
10
+ } from "../../../Types/OnCallDutyPolicy/RestrictionTimes";
11
+ import Recurring from "../../../Types/Events/Recurring";
12
+ import EventInterval from "../../../Types/Events/EventInterval";
13
+ import StartAndEndTime from "../../../Types/Time/StartAndEndTime";
14
+ import DayOfWeek from "../../../Types/Day/DayOfWeek";
15
+ import User from "../../../Models/DatabaseModels/User";
16
+ import { JSONObject } from "../../../Types/JSON";
17
+ import { describe, expect, test } from "@jest/globals";
18
+ import moment from "moment-timezone";
19
+
20
+ /*
21
+ * Core-path coverage for LayerUtil that the many audit/regression suites next
22
+ * to this file do not pin directly: JSON / string input sanitisation, a
23
+ * missing handoff, the plain round-robin order for several users, handoff
24
+ * phase, daily and weekly restriction trimming (including overnight and
25
+ * weekend wrap-around windows), DST behaviour of Day vs Hour rotations,
26
+ * multi-layer priority metadata and the event-count cap.
27
+ *
28
+ * Every instant is built in an explicit IANA zone and every layer carries an
29
+ * explicit `timezone`, so results do not depend on the process TZ.
30
+ */
31
+
32
+ const UTC: string = "UTC";
33
+ const NY: string = "America/New_York";
34
+ const SECOND_MS: number = 1000;
35
+ const HOUR_MS: number = 60 * 60 * SECOND_MS;
36
+
37
+ function user(id: string): User {
38
+ return {
39
+ id: {
40
+ toString: (): string => {
41
+ return id;
42
+ },
43
+ },
44
+ } as unknown as User;
45
+ }
46
+
47
+ function rotation(
48
+ intervalType: EventInterval,
49
+ intervalCount: number,
50
+ ): Recurring {
51
+ return Recurring.fromJSON({
52
+ _type: "Recurring",
53
+ value: {
54
+ intervalType: intervalType,
55
+ intervalCount: { _type: "PositiveNumber", value: intervalCount },
56
+ },
57
+ } as JSONObject);
58
+ }
59
+
60
+ function at(iso: string, tz: string = UTC): Date {
61
+ return moment.tz(iso, tz).toDate();
62
+ }
63
+
64
+ function wall(d: Date, tz: string = UTC): string {
65
+ return moment.tz(d, tz).format("YYYY-MM-DD HH:mm:ss");
66
+ }
67
+
68
+ function noRestriction(): RestrictionTimes {
69
+ const rt: RestrictionTimes = new RestrictionTimes();
70
+ rt.restictionType = RestrictionType.None;
71
+ return rt;
72
+ }
73
+
74
+ function dailyRestriction(
75
+ startIso: string,
76
+ endIso: string,
77
+ tz: string = UTC,
78
+ ): RestrictionTimes {
79
+ const rt: RestrictionTimes = new RestrictionTimes();
80
+ rt.restictionType = RestrictionType.Daily;
81
+ rt.dayRestrictionTimes = {
82
+ startTime: at(startIso, tz),
83
+ endTime: at(endIso, tz),
84
+ };
85
+ return rt;
86
+ }
87
+
88
+ function weeklyRestriction(
89
+ windows: Array<WeeklyResctriction>,
90
+ ): RestrictionTimes {
91
+ const rt: RestrictionTimes = new RestrictionTimes();
92
+ rt.restictionType = RestrictionType.Weekly;
93
+ rt.weeklyRestrictionTimes = windows;
94
+ return rt;
95
+ }
96
+
97
+ interface Summary {
98
+ user: string;
99
+ start: string;
100
+ end: string;
101
+ }
102
+
103
+ function summarize(
104
+ events: Array<CalendarEvent>,
105
+ tz: string = UTC,
106
+ ): Array<Summary> {
107
+ return events.map((e: CalendarEvent): Summary => {
108
+ return { user: e.title, start: wall(e.start, tz), end: wall(e.end, tz) };
109
+ });
110
+ }
111
+
112
+ describe("LayerUtil.getEvents - input validation", () => {
113
+ test("returns no events when the user list is empty", () => {
114
+ const result: LayerEventsResult = new LayerUtil().getEventsWithMeta({
115
+ users: [],
116
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
117
+ handOffTime: at("2026-01-05 00:00"),
118
+ restrictionTimes: noRestriction(),
119
+ rotation: rotation(EventInterval.Day, 1),
120
+ timezone: UTC,
121
+ calendarStartDate: at("2026-01-05 00:00"),
122
+ calendarEndDate: at("2026-01-10 00:00"),
123
+ });
124
+
125
+ expect(result).toEqual({ events: [], truncated: false });
126
+ });
127
+
128
+ test("returns no events when the calendar end is before the calendar start", () => {
129
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
130
+ users: [user("a")],
131
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
132
+ handOffTime: at("2026-01-01 00:00"),
133
+ restrictionTimes: noRestriction(),
134
+ rotation: rotation(EventInterval.Day, 1),
135
+ timezone: UTC,
136
+ calendarStartDate: at("2026-01-10 00:00"),
137
+ calendarEndDate: at("2026-01-05 00:00"),
138
+ });
139
+
140
+ expect(events).toEqual([]);
141
+ });
142
+
143
+ test("returns no events when the layer starts after the calendar window ends", () => {
144
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
145
+ users: [user("a")],
146
+ startDateTimeOfLayer: at("2026-02-01 00:00"),
147
+ handOffTime: at("2026-02-01 00:00"),
148
+ restrictionTimes: noRestriction(),
149
+ rotation: rotation(EventInterval.Day, 1),
150
+ timezone: UTC,
151
+ calendarStartDate: at("2026-01-01 00:00"),
152
+ calendarEndDate: at("2026-01-10 00:00"),
153
+ });
154
+
155
+ expect(events).toEqual([]);
156
+ });
157
+
158
+ test("returns no events when the handoff time is missing", () => {
159
+ const result: LayerEventsResult = new LayerUtil().getEventsWithMeta({
160
+ users: [user("a")],
161
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
162
+ handOffTime: null as unknown as Date,
163
+ restrictionTimes: noRestriction(),
164
+ rotation: rotation(EventInterval.Day, 1),
165
+ timezone: UTC,
166
+ calendarStartDate: at("2026-01-01 00:00"),
167
+ calendarEndDate: at("2026-01-03 00:00"),
168
+ });
169
+
170
+ expect(result).toEqual({ events: [], truncated: false });
171
+ });
172
+
173
+ test("clamps the window start to the layer start when the layer starts inside the window", () => {
174
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
175
+ users: [user("a")],
176
+ startDateTimeOfLayer: at("2026-01-03 06:00"),
177
+ handOffTime: at("2026-01-03 06:00"),
178
+ restrictionTimes: noRestriction(),
179
+ rotation: rotation(EventInterval.Day, 1),
180
+ timezone: UTC,
181
+ calendarStartDate: at("2026-01-01 00:00"),
182
+ calendarEndDate: at("2026-01-04 00:00"),
183
+ });
184
+
185
+ expect(events.length).toBeGreaterThan(0);
186
+ expect(wall(events[0]!.start)).toBe("2026-01-03 06:00:00");
187
+ expect(wall(events[events.length - 1]!.end)).toBe("2026-01-04 00:00:00");
188
+ });
189
+
190
+ test("accepts JSON-serialised rotation / restriction and ISO string dates", () => {
191
+ const util: LayerUtil = new LayerUtil();
192
+
193
+ const typed: Array<CalendarEvent> = util.getEvents({
194
+ users: [user("a"), user("b")],
195
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
196
+ handOffTime: at("2026-01-05 00:00"),
197
+ restrictionTimes: dailyRestriction(
198
+ "2026-01-05 09:00",
199
+ "2026-01-05 17:00",
200
+ ),
201
+ rotation: rotation(EventInterval.Day, 1),
202
+ timezone: UTC,
203
+ calendarStartDate: at("2026-01-05 00:00"),
204
+ calendarEndDate: at("2026-01-08 00:00"),
205
+ });
206
+
207
+ const fromJson: Array<CalendarEvent> = new LayerUtil().getEvents({
208
+ users: [user("a"), user("b")],
209
+ startDateTimeOfLayer: "2026-01-05T00:00:00.000Z" as unknown as Date,
210
+ handOffTime: "2026-01-05T00:00:00.000Z" as unknown as Date,
211
+ restrictionTimes: dailyRestriction(
212
+ "2026-01-05 09:00",
213
+ "2026-01-05 17:00",
214
+ ).toJSON() as unknown as RestrictionTimes,
215
+ rotation: rotation(EventInterval.Day, 1).toJSON() as unknown as Recurring,
216
+ timezone: UTC,
217
+ calendarStartDate: "2026-01-05T00:00:00.000Z" as unknown as Date,
218
+ calendarEndDate: "2026-01-08T00:00:00.000Z" as unknown as Date,
219
+ });
220
+
221
+ expect(typed.length).toBe(3);
222
+ expect(summarize(fromJson)).toEqual(summarize(typed));
223
+ });
224
+ });
225
+
226
+ describe("LayerUtil.getEvents - rotation order and handoff", () => {
227
+ test("rotates three users round-robin on a daily handoff with 1 s seams", () => {
228
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
229
+ users: [user("a"), user("b"), user("c")],
230
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
231
+ handOffTime: at("2026-01-05 00:00"),
232
+ restrictionTimes: noRestriction(),
233
+ rotation: rotation(EventInterval.Day, 1),
234
+ timezone: UTC,
235
+ calendarStartDate: at("2026-01-05 00:00"),
236
+ calendarEndDate: at("2026-01-10 00:00"),
237
+ });
238
+
239
+ expect(
240
+ events.map((e: CalendarEvent): string => {
241
+ return e.title;
242
+ }),
243
+ ).toEqual(["a", "b", "c", "a", "b"]);
244
+
245
+ // Ids are unique and sequential.
246
+ expect(
247
+ events.map((e: CalendarEvent): number => {
248
+ return e.id;
249
+ }),
250
+ ).toEqual([1, 2, 3, 4, 5]);
251
+
252
+ for (let i: number = 1; i < events.length; i++) {
253
+ expect(events[i]!.start.getTime() - events[i - 1]!.end.getTime()).toBe(
254
+ SECOND_MS,
255
+ );
256
+ }
257
+
258
+ expect(wall(events[0]!.start)).toBe("2026-01-05 00:00:00");
259
+ expect(wall(events[0]!.end)).toBe("2026-01-06 00:00:00");
260
+ expect(wall(events[4]!.end)).toBe("2026-01-10 00:00:00");
261
+ });
262
+
263
+ test("REGRESSION: a window ending exactly on a handoff emits no trailing inverted event", () => {
264
+ for (const restrictionTimes of [
265
+ noRestriction(),
266
+ dailyRestriction("2026-01-01 00:00", "2026-01-01 23:00"),
267
+ ]) {
268
+ const result: LayerEventsResult = new LayerUtil().getEventsWithMeta({
269
+ users: [user("a"), user("b")],
270
+ startDateTimeOfLayer: at("2026-01-05 08:00"),
271
+ handOffTime: at("2026-01-05 08:00"),
272
+ restrictionTimes: restrictionTimes,
273
+ rotation: rotation(EventInterval.Day, 1),
274
+ timezone: UTC,
275
+ calendarStartDate: at("2026-01-05 08:00"),
276
+ calendarEndDate: at("2026-01-07 08:00"),
277
+ });
278
+
279
+ expect(result.truncated).toBe(false);
280
+ for (const e of result.events) {
281
+ expect(e.end.getTime()).toBeGreaterThan(e.start.getTime());
282
+ expect(e.end.getTime()).toBeLessThanOrEqual(
283
+ at("2026-01-07 08:00").getTime(),
284
+ );
285
+ }
286
+ // The next user ("a" again) must not appear as a 1 s-inverted stub.
287
+ expect(result.events[result.events.length - 1]!.title).toBe("b");
288
+ }
289
+ });
290
+
291
+ test("a single user holds every period", () => {
292
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
293
+ users: [user("solo")],
294
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
295
+ handOffTime: at("2026-01-05 00:00"),
296
+ restrictionTimes: noRestriction(),
297
+ rotation: rotation(EventInterval.Day, 1),
298
+ timezone: UTC,
299
+ calendarStartDate: at("2026-01-05 00:00"),
300
+ calendarEndDate: at("2026-01-08 00:00"),
301
+ });
302
+
303
+ expect(events.length).toBe(3);
304
+ for (const e of events) {
305
+ expect(e.title).toBe("solo");
306
+ }
307
+ });
308
+
309
+ test("a mid-day handoff splits the first period at the handoff time", () => {
310
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
311
+ users: [user("a"), user("b")],
312
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
313
+ handOffTime: at("2026-01-05 09:30"),
314
+ restrictionTimes: noRestriction(),
315
+ rotation: rotation(EventInterval.Day, 1),
316
+ timezone: UTC,
317
+ calendarStartDate: at("2026-01-05 00:00"),
318
+ calendarEndDate: at("2026-01-07 12:00"),
319
+ });
320
+
321
+ expect(summarize(events)).toEqual([
322
+ { user: "a", start: "2026-01-05 00:00:00", end: "2026-01-05 09:30:00" },
323
+ { user: "b", start: "2026-01-05 09:30:01", end: "2026-01-06 09:30:00" },
324
+ { user: "a", start: "2026-01-06 09:30:01", end: "2026-01-07 09:30:00" },
325
+ { user: "b", start: "2026-01-07 09:30:01", end: "2026-01-07 12:00:00" },
326
+ ]);
327
+ });
328
+
329
+ test("a window starting weeks after the layer start resumes at the correct user", () => {
330
+ const layer: {
331
+ users: Array<User>;
332
+ startDateTimeOfLayer: Date;
333
+ handOffTime: Date;
334
+ } = {
335
+ users: [user("a"), user("b"), user("c")],
336
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
337
+ handOffTime: at("2026-01-05 00:00"),
338
+ };
339
+
340
+ const full: Array<CalendarEvent> = new LayerUtil().getEvents({
341
+ ...layer,
342
+ restrictionTimes: noRestriction(),
343
+ rotation: rotation(EventInterval.Day, 1),
344
+ timezone: UTC,
345
+ calendarStartDate: at("2026-01-05 00:00"),
346
+ calendarEndDate: at("2026-01-25 00:00"),
347
+ });
348
+
349
+ const late: Array<CalendarEvent> = new LayerUtil().getEvents({
350
+ ...layer,
351
+ restrictionTimes: noRestriction(),
352
+ rotation: rotation(EventInterval.Day, 1),
353
+ timezone: UTC,
354
+ calendarStartDate: at("2026-01-20 00:00"),
355
+ calendarEndDate: at("2026-01-25 00:00"),
356
+ });
357
+
358
+ // Day 15 after the start: 15 % 3 === 0 -> user "a".
359
+ expect(late[0]!.title).toBe("a");
360
+ // The first event is clamped to the window start instead of the seam.
361
+ expect(wall(late[0]!.start)).toBe("2026-01-20 00:00:00");
362
+ expect(summarize(late.slice(1))).toEqual(summarize(full.slice(16)));
363
+ expect(
364
+ late.map((e: CalendarEvent): string => {
365
+ return e.title;
366
+ }),
367
+ ).toEqual(
368
+ full.slice(15).map((e: CalendarEvent): string => {
369
+ return e.title;
370
+ }),
371
+ );
372
+ });
373
+
374
+ test("a multi-day rotation interval keeps a user on for the whole interval", () => {
375
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
376
+ users: [user("a"), user("b")],
377
+ startDateTimeOfLayer: at("2026-01-05 08:00"),
378
+ handOffTime: at("2026-01-05 08:00"),
379
+ restrictionTimes: noRestriction(),
380
+ rotation: rotation(EventInterval.Day, 3),
381
+ timezone: UTC,
382
+ calendarStartDate: at("2026-01-05 08:00"),
383
+ calendarEndDate: at("2026-01-14 08:00"),
384
+ });
385
+
386
+ expect(summarize(events)).toEqual([
387
+ { user: "a", start: "2026-01-05 08:00:00", end: "2026-01-08 08:00:00" },
388
+ { user: "b", start: "2026-01-08 08:00:01", end: "2026-01-11 08:00:00" },
389
+ { user: "a", start: "2026-01-11 08:00:01", end: "2026-01-14 08:00:00" },
390
+ ]);
391
+ });
392
+
393
+ test("a restricted layer stamps the true period start on a window-clamped first event", () => {
394
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
395
+ users: [user("a"), user("b")],
396
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
397
+ handOffTime: at("2026-01-05 00:00"),
398
+ restrictionTimes: dailyRestriction(
399
+ "2026-01-01 09:00",
400
+ "2026-01-01 17:00",
401
+ ),
402
+ rotation: rotation(EventInterval.Day, 1),
403
+ timezone: UTC,
404
+ calendarStartDate: at("2026-01-06 15:00"),
405
+ calendarEndDate: at("2026-01-08 00:00"),
406
+ });
407
+
408
+ const periodStart: (e: CalendarEvent) => number = (
409
+ e: CalendarEvent,
410
+ ): number => {
411
+ return (e as unknown as Record<string, number>)[
412
+ ROTATION_PERIOD_START_KEY
413
+ ]!;
414
+ };
415
+
416
+ expect(summarize(events)).toEqual([
417
+ { user: "b", start: "2026-01-06 15:00:00", end: "2026-01-06 17:00:00" },
418
+ { user: "a", start: "2026-01-07 09:00:00", end: "2026-01-07 17:00:00" },
419
+ ]);
420
+ // The seam puts every period after the first one second past the handoff.
421
+ expect(periodStart(events[0]!)).toBe(at("2026-01-06 00:00:01").getTime());
422
+ expect(periodStart(events[1]!)).toBe(at("2026-01-07 00:00:01").getTime());
423
+ });
424
+ });
425
+
426
+ describe("LayerUtil.getEvents - DST", () => {
427
+ test("a daily rotation in New York keeps its 09:00 wall-clock handoff across spring-forward", () => {
428
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
429
+ users: [user("a"), user("b")],
430
+ startDateTimeOfLayer: at("2026-03-06 09:00", NY),
431
+ handOffTime: at("2026-03-06 09:00", NY),
432
+ restrictionTimes: noRestriction(),
433
+ rotation: rotation(EventInterval.Day, 1),
434
+ timezone: NY,
435
+ calendarStartDate: at("2026-03-06 09:00", NY),
436
+ calendarEndDate: at("2026-03-10 09:00", NY),
437
+ });
438
+
439
+ expect(summarize(events, NY)).toEqual([
440
+ { user: "a", start: "2026-03-06 09:00:00", end: "2026-03-07 09:00:00" },
441
+ { user: "b", start: "2026-03-07 09:00:01", end: "2026-03-08 09:00:00" },
442
+ { user: "a", start: "2026-03-08 09:00:01", end: "2026-03-09 09:00:00" },
443
+ { user: "b", start: "2026-03-09 09:00:01", end: "2026-03-10 09:00:00" },
444
+ ]);
445
+
446
+ // The day containing the transition (Mar 7 -> Mar 8) is only 23 real hours.
447
+ expect(events[1]!.end.getTime() - events[1]!.start.getTime()).toBe(
448
+ 23 * HOUR_MS - SECOND_MS,
449
+ );
450
+ });
451
+
452
+ test("a daily rotation in New York is 25 real hours across fall-back", () => {
453
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
454
+ users: [user("a"), user("b")],
455
+ startDateTimeOfLayer: at("2026-10-31 09:00", NY),
456
+ handOffTime: at("2026-10-31 09:00", NY),
457
+ restrictionTimes: noRestriction(),
458
+ rotation: rotation(EventInterval.Day, 1),
459
+ timezone: NY,
460
+ calendarStartDate: at("2026-10-31 09:00", NY),
461
+ calendarEndDate: at("2026-11-02 09:00", NY),
462
+ });
463
+
464
+ expect(summarize(events, NY)).toEqual([
465
+ { user: "a", start: "2026-10-31 09:00:00", end: "2026-11-01 09:00:00" },
466
+ { user: "b", start: "2026-11-01 09:00:01", end: "2026-11-02 09:00:00" },
467
+ ]);
468
+ expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(
469
+ 25 * HOUR_MS,
470
+ );
471
+ });
472
+
473
+ test("an hourly rotation steps absolute hours across spring-forward", () => {
474
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
475
+ users: [user("a"), user("b")],
476
+ startDateTimeOfLayer: at("2026-03-08 00:00", NY),
477
+ handOffTime: at("2026-03-08 00:00", NY),
478
+ restrictionTimes: noRestriction(),
479
+ rotation: rotation(EventInterval.Hour, 1),
480
+ timezone: NY,
481
+ calendarStartDate: at("2026-03-08 00:00", NY),
482
+ calendarEndDate: at("2026-03-08 04:00", NY),
483
+ });
484
+
485
+ // 00:00 -> 04:00 NY on spring-forward day is only 3 real hours.
486
+ expect(
487
+ events.map((e: CalendarEvent): string => {
488
+ return e.title;
489
+ }),
490
+ ).toEqual(["a", "b", "a"]);
491
+ expect(
492
+ events.map((e: CalendarEvent): string => {
493
+ return moment.tz(e.start, NY).format("HH:mm:ss");
494
+ }),
495
+ ).toEqual(["00:00:00", "01:00:01", "03:00:01"]);
496
+ });
497
+ });
498
+
499
+ describe("LayerUtil.getEvents - restrictions", () => {
500
+ test("a daily 09:00-17:00 restriction yields one working-hours event per day, rotating users", () => {
501
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
502
+ users: [user("a"), user("b")],
503
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
504
+ handOffTime: at("2026-01-05 00:00"),
505
+ restrictionTimes: dailyRestriction(
506
+ "2026-01-01 09:00",
507
+ "2026-01-01 17:00",
508
+ ),
509
+ rotation: rotation(EventInterval.Day, 1),
510
+ timezone: UTC,
511
+ calendarStartDate: at("2026-01-05 00:00"),
512
+ calendarEndDate: at("2026-01-08 00:00"),
513
+ });
514
+
515
+ expect(summarize(events)).toEqual([
516
+ { user: "a", start: "2026-01-05 09:00:00", end: "2026-01-05 17:00:00" },
517
+ { user: "b", start: "2026-01-06 09:00:00", end: "2026-01-06 17:00:00" },
518
+ { user: "a", start: "2026-01-07 09:00:00", end: "2026-01-07 17:00:00" },
519
+ ]);
520
+ });
521
+
522
+ test("a daily restriction is resolved in the schedule zone (New York), not the process zone", () => {
523
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
524
+ users: [user("a")],
525
+ startDateTimeOfLayer: at("2026-07-06 00:00", NY),
526
+ handOffTime: at("2026-07-06 00:00", NY),
527
+ restrictionTimes: dailyRestriction(
528
+ "2026-01-01 09:00",
529
+ "2026-01-01 17:00",
530
+ NY,
531
+ ),
532
+ rotation: rotation(EventInterval.Day, 1),
533
+ timezone: NY,
534
+ calendarStartDate: at("2026-07-06 00:00", NY),
535
+ calendarEndDate: at("2026-07-08 00:00", NY),
536
+ });
537
+
538
+ // Authored in winter (EST) but applied in summer (EDT): wall-clock holds.
539
+ expect(summarize(events, NY)).toEqual([
540
+ { user: "a", start: "2026-07-06 09:00:00", end: "2026-07-06 17:00:00" },
541
+ { user: "a", start: "2026-07-07 09:00:00", end: "2026-07-07 17:00:00" },
542
+ ]);
543
+ });
544
+
545
+ test("an overnight daily restriction (22:00-06:00) covers the night across the day boundary", () => {
546
+ const util: LayerUtil = new LayerUtil();
547
+ const events: Array<CalendarEvent> = util.getEvents({
548
+ users: [user("a")],
549
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
550
+ handOffTime: at("2026-01-05 00:00"),
551
+ restrictionTimes: dailyRestriction(
552
+ "2026-01-01 22:00",
553
+ "2026-01-01 06:00",
554
+ ),
555
+ rotation: rotation(EventInterval.Week, 1),
556
+ timezone: UTC,
557
+ calendarStartDate: at("2026-01-05 00:00"),
558
+ calendarEndDate: at("2026-01-07 00:00"),
559
+ });
560
+
561
+ const covered: (iso: string) => boolean = (iso: string): boolean => {
562
+ const t: number = at(iso).getTime();
563
+ return events.some((e: CalendarEvent): boolean => {
564
+ return e.start.getTime() <= t && t <= e.end.getTime();
565
+ });
566
+ };
567
+
568
+ expect(covered("2026-01-05 00:30")).toBe(true);
569
+ expect(covered("2026-01-05 05:59")).toBe(true);
570
+ expect(covered("2026-01-05 06:30")).toBe(false);
571
+ expect(covered("2026-01-05 12:00")).toBe(false);
572
+ expect(covered("2026-01-05 21:59")).toBe(false);
573
+ expect(covered("2026-01-05 22:30")).toBe(true);
574
+ expect(covered("2026-01-06 03:00")).toBe(true);
575
+ expect(covered("2026-01-06 12:00")).toBe(false);
576
+ expect(covered("2026-01-06 23:00")).toBe(true);
577
+
578
+ for (const e of events) {
579
+ expect(e.end.getTime()).toBeGreaterThan(e.start.getTime());
580
+ }
581
+ });
582
+
583
+ test("a period fully inside a restriction gap does not consume a user's turn", () => {
584
+ // Weekdays 09:00-17:00 only; daily rotation. Sat/Sun produce no coverage.
585
+ const windows: Array<WeeklyResctriction> = [];
586
+ const mondayIso: Array<string> = [
587
+ "2026-01-05",
588
+ "2026-01-06",
589
+ "2026-01-07",
590
+ "2026-01-08",
591
+ "2026-01-09",
592
+ ];
593
+ const days: Array<DayOfWeek> = [
594
+ DayOfWeek.Monday,
595
+ DayOfWeek.Tuesday,
596
+ DayOfWeek.Wednesday,
597
+ DayOfWeek.Thursday,
598
+ DayOfWeek.Friday,
599
+ ];
600
+ for (let i: number = 0; i < days.length; i++) {
601
+ windows.push({
602
+ startDay: days[i]!,
603
+ endDay: days[i]!,
604
+ startTime: at(`${mondayIso[i]} 09:00`),
605
+ endTime: at(`${mondayIso[i]} 17:00`),
606
+ });
607
+ }
608
+
609
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
610
+ users: [user("a"), user("b"), user("c")],
611
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
612
+ handOffTime: at("2026-01-05 00:00"),
613
+ restrictionTimes: weeklyRestriction(windows),
614
+ rotation: rotation(EventInterval.Day, 1),
615
+ timezone: UTC,
616
+ calendarStartDate: at("2026-01-05 00:00"),
617
+ calendarEndDate: at("2026-01-14 00:00"),
618
+ });
619
+
620
+ expect(summarize(events)).toEqual([
621
+ { user: "a", start: "2026-01-05 09:00:00", end: "2026-01-05 17:00:00" },
622
+ { user: "b", start: "2026-01-06 09:00:00", end: "2026-01-06 17:00:00" },
623
+ { user: "c", start: "2026-01-07 09:00:00", end: "2026-01-07 17:00:00" },
624
+ { user: "a", start: "2026-01-08 09:00:00", end: "2026-01-08 17:00:00" },
625
+ { user: "b", start: "2026-01-09 09:00:00", end: "2026-01-09 17:00:00" },
626
+ // weekend skipped without advancing the rotation
627
+ { user: "c", start: "2026-01-12 09:00:00", end: "2026-01-12 17:00:00" },
628
+ { user: "a", start: "2026-01-13 09:00:00", end: "2026-01-13 17:00:00" },
629
+ ]);
630
+ });
631
+
632
+ test("a weekend wrap-around weekly restriction (Fri 18:00 -> Mon 08:00) on a weekly rotation", () => {
633
+ const events: Array<CalendarEvent> = new LayerUtil().getEvents({
634
+ users: [user("a"), user("b")],
635
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
636
+ handOffTime: at("2026-01-05 00:00"),
637
+ restrictionTimes: weeklyRestriction([
638
+ {
639
+ startDay: DayOfWeek.Friday,
640
+ endDay: DayOfWeek.Monday,
641
+ startTime: at("2026-01-09 18:00"),
642
+ endTime: at("2026-01-12 08:00"),
643
+ },
644
+ ]),
645
+ rotation: rotation(EventInterval.Week, 1),
646
+ timezone: UTC,
647
+ calendarStartDate: at("2026-01-05 00:00"),
648
+ calendarEndDate: at("2026-01-19 00:00"),
649
+ });
650
+
651
+ const covering: (iso: string) => string | null = (
652
+ iso: string,
653
+ ): string | null => {
654
+ const t: number = at(iso).getTime();
655
+ const hit: CalendarEvent | undefined = events.find(
656
+ (e: CalendarEvent): boolean => {
657
+ return e.start.getTime() <= t && t <= e.end.getTime();
658
+ },
659
+ );
660
+ return hit ? hit.title : null;
661
+ };
662
+
663
+ // Week 1 (user a): early Monday tail of the previous weekend + Fri->Mon.
664
+ expect(covering("2026-01-05 07:00")).toBe("a");
665
+ expect(covering("2026-01-07 12:00")).toBeNull();
666
+ expect(covering("2026-01-09 17:59")).toBeNull();
667
+ expect(covering("2026-01-09 19:00")).toBe("a");
668
+ expect(covering("2026-01-11 12:00")).toBe("a");
669
+ // Week 2 (user b).
670
+ expect(covering("2026-01-12 12:00")).toBeNull();
671
+ expect(covering("2026-01-16 19:00")).toBe("b");
672
+ expect(covering("2026-01-18 23:00")).toBe("b");
673
+
674
+ // No two events overlap.
675
+ const sorted: Array<CalendarEvent> = [...events].sort(
676
+ (x: CalendarEvent, y: CalendarEvent): number => {
677
+ return x.start.getTime() - y.start.getTime();
678
+ },
679
+ );
680
+ for (let i: number = 1; i < sorted.length; i++) {
681
+ expect(sorted[i]!.start.getTime()).toBeGreaterThan(
682
+ sorted[i - 1]!.end.getTime(),
683
+ );
684
+ }
685
+ });
686
+ });
687
+
688
+ describe("LayerUtil restriction helpers", () => {
689
+ test("trimStartAndEndTimesBasedOnRestrictionTimes passes the event through with no restriction", () => {
690
+ const start: Date = at("2026-01-05 03:00");
691
+ const end: Date = at("2026-01-06 03:00");
692
+ expect(
693
+ new LayerUtil().trimStartAndEndTimesBasedOnRestrictionTimes({
694
+ eventStartTime: start,
695
+ eventEndTime: end,
696
+ restrictionTimes: noRestriction(),
697
+ }),
698
+ ).toEqual([{ startTime: start, endTime: end }]);
699
+ });
700
+
701
+ test("a Daily restriction type with no day window yields nothing", () => {
702
+ const rt: RestrictionTimes = new RestrictionTimes();
703
+ rt.restictionType = RestrictionType.Daily;
704
+ rt.dayRestrictionTimes = null;
705
+
706
+ expect(
707
+ new LayerUtil().trimStartAndEndTimesBasedOnRestrictionTimes({
708
+ eventStartTime: at("2026-01-05 00:00"),
709
+ eventEndTime: at("2026-01-06 00:00"),
710
+ restrictionTimes: rt,
711
+ }),
712
+ ).toEqual([]);
713
+ });
714
+
715
+ test("trimming does not mutate the caller's RestrictionTimes", () => {
716
+ const rt: RestrictionTimes = dailyRestriction(
717
+ "2026-01-01 09:00",
718
+ "2026-01-01 17:00",
719
+ );
720
+ const before: StartAndEndTime = { ...rt.dayRestrictionTimes! };
721
+
722
+ const util: LayerUtil = new LayerUtil();
723
+ util.trimStartAndEndTimesBasedOnRestrictionTimes({
724
+ eventStartTime: at("2026-03-05 00:00"),
725
+ eventEndTime: at("2026-03-06 00:00"),
726
+ restrictionTimes: rt,
727
+ });
728
+
729
+ expect(rt.dayRestrictionTimes!.startTime.getTime()).toBe(
730
+ before.startTime.getTime(),
731
+ );
732
+ expect(rt.dayRestrictionTimes!.endTime.getTime()).toBe(
733
+ before.endTime.getTime(),
734
+ );
735
+ });
736
+
737
+ test("getEventsByDailyRestriction with a null window returns the event unchanged", () => {
738
+ const start: Date = at("2026-01-05 00:00");
739
+ const end: Date = at("2026-01-05 12:00");
740
+ expect(
741
+ new LayerUtil().getEventsByDailyRestriction({
742
+ eventStartTime: start,
743
+ eventEndTime: end,
744
+ restrictionStartAndEndTime: null as unknown as StartAndEndTime,
745
+ props: { intervalType: EventInterval.Day },
746
+ }),
747
+ ).toEqual([{ startTime: start, endTime: end }]);
748
+ });
749
+
750
+ test("getEventsByDailyRestriction emits nothing when the event ends before the window opens", () => {
751
+ expect(
752
+ new LayerUtil().getEventsByDailyRestriction({
753
+ eventStartTime: at("2026-01-05 01:00"),
754
+ eventEndTime: at("2026-01-05 08:00"),
755
+ restrictionStartAndEndTime: {
756
+ startTime: at("2026-01-05 09:00"),
757
+ endTime: at("2026-01-05 17:00"),
758
+ },
759
+ props: { intervalType: EventInterval.Day },
760
+ }),
761
+ ).toEqual([]);
762
+ });
763
+
764
+ test("getEventsByDailyRestriction tiles a multi-day event day by day", () => {
765
+ const util: LayerUtil = new LayerUtil();
766
+ (util as unknown as { timezone: string }).timezone = UTC;
767
+
768
+ const out: Array<StartAndEndTime> = util.getEventsByDailyRestriction({
769
+ eventStartTime: at("2026-01-05 12:00"),
770
+ eventEndTime: at("2026-01-07 10:00"),
771
+ restrictionStartAndEndTime: {
772
+ startTime: at("2026-01-05 09:00"),
773
+ endTime: at("2026-01-05 17:00"),
774
+ },
775
+ props: { intervalType: EventInterval.Day },
776
+ });
777
+
778
+ expect(
779
+ out.map((s: StartAndEndTime): Array<string> => {
780
+ return [wall(s.startTime), wall(s.endTime)];
781
+ }),
782
+ ).toEqual([
783
+ ["2026-01-05 12:00:00", "2026-01-05 17:00:00"],
784
+ ["2026-01-06 09:00:00", "2026-01-06 17:00:00"],
785
+ ["2026-01-07 09:00:00", "2026-01-07 10:00:00"],
786
+ ]);
787
+ });
788
+
789
+ test("getEventsByWeeklyRestriction with no weekly windows returns the event unchanged", () => {
790
+ const start: Date = at("2026-01-05 00:00");
791
+ const end: Date = at("2026-01-12 00:00");
792
+ expect(
793
+ new LayerUtil().getEventsByWeeklyRestriction({
794
+ eventStartTime: start,
795
+ eventEndTime: end,
796
+ restrictionTimes: weeklyRestriction([]),
797
+ }),
798
+ ).toEqual([{ startTime: start, endTime: end }]);
799
+ });
800
+
801
+ test("getEventsByWeeklyRestriction merges overlapping windows that share a start", () => {
802
+ const util: LayerUtil = new LayerUtil();
803
+ (util as unknown as { timezone: string }).timezone = UTC;
804
+
805
+ const out: Array<StartAndEndTime> = util.getEventsByWeeklyRestriction({
806
+ eventStartTime: at("2026-01-05 00:00"),
807
+ eventEndTime: at("2026-01-12 00:00"),
808
+ restrictionTimes: weeklyRestriction([
809
+ {
810
+ startDay: DayOfWeek.Tuesday,
811
+ endDay: DayOfWeek.Tuesday,
812
+ startTime: at("2026-01-06 09:00"),
813
+ endTime: at("2026-01-06 12:00"),
814
+ },
815
+ {
816
+ startDay: DayOfWeek.Tuesday,
817
+ endDay: DayOfWeek.Tuesday,
818
+ startTime: at("2026-01-06 09:00"),
819
+ endTime: at("2026-01-06 17:00"),
820
+ },
821
+ {
822
+ startDay: DayOfWeek.Thursday,
823
+ endDay: DayOfWeek.Thursday,
824
+ startTime: at("2026-01-08 10:00"),
825
+ endTime: at("2026-01-08 11:00"),
826
+ },
827
+ ]),
828
+ });
829
+
830
+ expect(
831
+ out.map((s: StartAndEndTime): Array<string> => {
832
+ return [wall(s.startTime), wall(s.endTime)];
833
+ }),
834
+ ).toEqual([
835
+ ["2026-01-06 09:00:00", "2026-01-06 17:00:00"],
836
+ ["2026-01-08 10:00:00", "2026-01-08 11:00:00"],
837
+ ]);
838
+ });
839
+
840
+ test("getWeeklyRestrictionTimesForWeek splits a wrap-around window into head and main segments", () => {
841
+ const util: LayerUtil = new LayerUtil();
842
+ (util as unknown as { timezone: string }).timezone = UTC;
843
+
844
+ const out: Array<StartAndEndTime> = util.getWeeklyRestrictionTimesForWeek({
845
+ eventStartTime: at("2026-01-07 12:00"), // Wednesday
846
+ eventEndTime: at("2026-01-14 12:00"),
847
+ restrictionTimes: weeklyRestriction([
848
+ {
849
+ startDay: DayOfWeek.Saturday,
850
+ endDay: DayOfWeek.Monday,
851
+ startTime: at("2026-01-03 00:00"),
852
+ endTime: at("2026-01-05 06:00"),
853
+ },
854
+ ]),
855
+ });
856
+
857
+ expect(out.length).toBe(2);
858
+ // head: from the start of the (Sunday-based) week to Monday 06:00
859
+ expect(wall(out[0]!.startTime)).toBe("2026-01-04 00:00:00");
860
+ expect(wall(out[0]!.endTime)).toBe("2026-01-05 06:00:00");
861
+ // main: Saturday 00:00 through the following Monday 06:00
862
+ expect(wall(out[1]!.startTime)).toBe("2026-01-10 00:00:00");
863
+ expect(wall(out[1]!.endTime)).toBe("2026-01-12 06:00:00");
864
+ });
865
+ });
866
+
867
+ describe("LayerUtil.getMultiLayerEvents", () => {
868
+ test("a higher priority layer wins and events are stamped with priority and layer identity", () => {
869
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
870
+ calendarStartDate: at("2026-01-05 00:00"),
871
+ calendarEndDate: at("2026-01-06 00:00"),
872
+ layers: [
873
+ {
874
+ layerId: "primary",
875
+ layerName: "Business hours",
876
+ users: [user("day")],
877
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
878
+ handOffTime: at("2026-01-01 00:00"),
879
+ restrictionTimes: dailyRestriction(
880
+ "2026-01-01 09:00",
881
+ "2026-01-01 17:00",
882
+ ),
883
+ rotation: rotation(EventInterval.Day, 1),
884
+ timezone: UTC,
885
+ },
886
+ {
887
+ users: [user("fallback")],
888
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
889
+ handOffTime: at("2026-01-01 00:00"),
890
+ restrictionTimes: noRestriction(),
891
+ rotation: rotation(EventInterval.Day, 1),
892
+ timezone: UTC,
893
+ },
894
+ ],
895
+ });
896
+
897
+ const shaped: Array<{
898
+ user: string;
899
+ priority: number;
900
+ layerId: string | undefined;
901
+ layerName: string | undefined;
902
+ start: string;
903
+ end: string;
904
+ }> = (events as Array<PriorityCalendarEvents>).map(
905
+ (e: PriorityCalendarEvents) => {
906
+ return {
907
+ user: e.title,
908
+ priority: e.priority,
909
+ layerId: e.layerId,
910
+ layerName: e.layerName,
911
+ start: wall(e.start),
912
+ end: wall(e.end),
913
+ };
914
+ },
915
+ );
916
+
917
+ expect(shaped).toEqual([
918
+ {
919
+ user: "fallback",
920
+ priority: 2,
921
+ layerId: undefined,
922
+ layerName: undefined,
923
+ start: "2026-01-05 00:00:00",
924
+ end: "2026-01-05 08:59:59",
925
+ },
926
+ {
927
+ user: "day",
928
+ priority: 1,
929
+ layerId: "primary",
930
+ layerName: "Business hours",
931
+ start: "2026-01-05 09:00:00",
932
+ end: "2026-01-05 17:00:00",
933
+ },
934
+ {
935
+ user: "fallback",
936
+ priority: 2,
937
+ layerId: undefined,
938
+ layerName: undefined,
939
+ start: "2026-01-05 17:00:01",
940
+ end: "2026-01-06 00:00:00",
941
+ },
942
+ ]);
943
+
944
+ // Fallback events carry no layer identity keys at all.
945
+ expect(Object.keys(events[0]!)).not.toContain("layerId");
946
+ });
947
+
948
+ test("getNumberOfEvents caps the merged result, not each layer", () => {
949
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents(
950
+ {
951
+ calendarStartDate: at("2026-01-05 00:00"),
952
+ calendarEndDate: at("2026-01-10 00:00"),
953
+ layers: [
954
+ {
955
+ users: [user("a"), user("b")],
956
+ startDateTimeOfLayer: at("2026-01-05 00:00"),
957
+ handOffTime: at("2026-01-05 00:00"),
958
+ restrictionTimes: noRestriction(),
959
+ rotation: rotation(EventInterval.Day, 1),
960
+ timezone: UTC,
961
+ },
962
+ ],
963
+ },
964
+ { getNumberOfEvents: 2 },
965
+ );
966
+
967
+ expect(
968
+ events.map((e: CalendarEvent): string => {
969
+ return e.title;
970
+ }),
971
+ ).toEqual(["a", "b"]);
972
+ });
973
+
974
+ test("a layer with no users contributes nothing while other layers still resolve", () => {
975
+ const result: LayerEventsResult =
976
+ new LayerUtil().getMultiLayerEventsWithMeta({
977
+ calendarStartDate: at("2026-01-05 00:00"),
978
+ calendarEndDate: at("2026-01-06 00:00"),
979
+ layers: [
980
+ {
981
+ users: [],
982
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
983
+ handOffTime: at("2026-01-01 00:00"),
984
+ restrictionTimes: noRestriction(),
985
+ rotation: rotation(EventInterval.Day, 1),
986
+ timezone: UTC,
987
+ },
988
+ {
989
+ users: [user("b")],
990
+ startDateTimeOfLayer: at("2026-01-01 00:00"),
991
+ handOffTime: at("2026-01-01 00:00"),
992
+ restrictionTimes: noRestriction(),
993
+ rotation: rotation(EventInterval.Day, 1),
994
+ timezone: UTC,
995
+ },
996
+ ],
997
+ });
998
+
999
+ expect(result.truncated).toBe(false);
1000
+ expect(result.events.length).toBe(1);
1001
+ expect(result.events[0]!.title).toBe("b");
1002
+ expect((result.events[0] as PriorityCalendarEvents).priority).toBe(2);
1003
+ });
1004
+
1005
+ test("an empty layer list yields no events", () => {
1006
+ expect(
1007
+ new LayerUtil().getMultiLayerEvents({
1008
+ calendarStartDate: at("2026-01-05 00:00"),
1009
+ calendarEndDate: at("2026-01-06 00:00"),
1010
+ layers: [],
1011
+ }),
1012
+ ).toEqual([]);
1013
+ });
1014
+ });