@oneuptime/common 12.0.29 → 12.0.30

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.
Files changed (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,678 @@
1
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
2
+ import OneUptimeDate from "../../../Types/Date";
3
+ import EventInterval from "../../../Types/Events/EventInterval";
4
+ import LayerUtil, {
5
+ LayerProps,
6
+ ROTATION_PERIOD_START_KEY,
7
+ } from "../../../Types/OnCallDutyPolicy/Layer";
8
+ import ScheduleShiftUtil, {
9
+ OnCallShift,
10
+ } from "../../../Types/OnCallDutyPolicy/ScheduleShiftUtil";
11
+ import ShiftSeamUtil from "../../../Types/OnCallDutyPolicy/ShiftSeamUtil";
12
+ import UserOverrideUtil, {
13
+ OVERRIDE_META_KEY,
14
+ OverrideEventMeta,
15
+ } from "../../../Types/OnCallDutyPolicy/UserOverrideUtil";
16
+ import {
17
+ at,
18
+ dailyRestriction,
19
+ noRestriction,
20
+ rotation,
21
+ user,
22
+ } from "./CalendarFeedTestFixtures";
23
+
24
+ /*
25
+ * groupEventsIntoShifts gained a `groupKey` option for the on-call calendar
26
+ * feed. The contract that matters most is that the DEFAULT behaviour is
27
+ * unchanged: every dashboard summary (LayersPreview, FinalScheduleSummary)
28
+ * calls it without a key and must keep producing byte-identical shifts.
29
+ *
30
+ * `legacyGroupEventsIntoShifts` below is a verbatim copy of the function as
31
+ * it was before the option existed; the regression tests compare against it.
32
+ */
33
+
34
+ const CONTIGUITY_TOLERANCE_SECONDS: number = 5;
35
+
36
+ function legacyGroupEventsIntoShifts(
37
+ events: Array<CalendarEvent>,
38
+ options?: { mergeAcrossGaps?: boolean | undefined } | undefined,
39
+ ): Array<OnCallShift> {
40
+ if (!events || events.length === 0) {
41
+ return [];
42
+ }
43
+
44
+ const mergeAcrossGaps: boolean = Boolean(options?.mergeAcrossGaps);
45
+
46
+ const sorted: Array<CalendarEvent> = [...events]
47
+ .filter((event: CalendarEvent) => {
48
+ return Boolean(event.title) && Boolean(event.start) && Boolean(event.end);
49
+ })
50
+ .sort((a: CalendarEvent, b: CalendarEvent) => {
51
+ if (OneUptimeDate.isBefore(a.start, b.start)) {
52
+ return -1;
53
+ }
54
+ if (OneUptimeDate.isAfter(a.start, b.start)) {
55
+ return 1;
56
+ }
57
+ return 0;
58
+ });
59
+
60
+ const shifts: Array<OnCallShift> = [];
61
+
62
+ for (const event of sorted) {
63
+ const userId: string = event.title;
64
+ const eventSeconds: number = OneUptimeDate.getDifferenceInSeconds(
65
+ event.end,
66
+ event.start,
67
+ );
68
+ const last: OnCallShift | undefined = shifts[shifts.length - 1];
69
+
70
+ const sameUser: boolean = Boolean(last) && last!.userId === userId;
71
+
72
+ const isContiguous: boolean =
73
+ Boolean(last) &&
74
+ OneUptimeDate.getDifferenceInSeconds(last!.end, event.start) <=
75
+ CONTIGUITY_TOLERANCE_SECONDS &&
76
+ OneUptimeDate.isOnOrBefore(last!.end, event.end);
77
+
78
+ if (last && sameUser && (mergeAcrossGaps || isContiguous)) {
79
+ if (OneUptimeDate.isAfter(event.end, last.end)) {
80
+ last.end = event.end;
81
+ }
82
+ last.coverageSeconds += eventSeconds;
83
+ continue;
84
+ }
85
+
86
+ shifts.push({
87
+ userId,
88
+ start: event.start,
89
+ end: event.end,
90
+ coverageSeconds: eventSeconds,
91
+ });
92
+ }
93
+
94
+ return shifts;
95
+ }
96
+
97
+ const UTC: string = "UTC";
98
+ const WINDOW_START: Date = at("2026-03-02T00:00:00Z");
99
+ const WINDOW_END: Date = at("2026-03-09T00:00:00Z");
100
+
101
+ function multiLayerFixture(withLayerMeta: boolean): Array<LayerProps> {
102
+ const primary: LayerProps = {
103
+ users: [user("A"), user("B")],
104
+ startDateTimeOfLayer: WINDOW_START,
105
+ restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
106
+ handOffTime: at("2026-03-02T09:00:00Z"),
107
+ rotation: rotation(EventInterval.Day, 1),
108
+ timezone: UTC,
109
+ };
110
+
111
+ const fallback: LayerProps = {
112
+ users: [user("C"), user("A")],
113
+ startDateTimeOfLayer: WINDOW_START,
114
+ restrictionTimes: noRestriction(),
115
+ handOffTime: WINDOW_START,
116
+ rotation: rotation(EventInterval.Day, 2),
117
+ timezone: UTC,
118
+ };
119
+
120
+ if (withLayerMeta) {
121
+ primary.layerId = "layer-1";
122
+ primary.layerName = "Primary";
123
+ fallback.layerId = "layer-2";
124
+ fallback.layerName = "Fallback";
125
+ }
126
+
127
+ return [primary, fallback];
128
+ }
129
+
130
+ function expand(layers: Array<LayerProps>): Array<CalendarEvent> {
131
+ return new LayerUtil().getMultiLayerEvents({
132
+ layers,
133
+ calendarStartDate: WINDOW_START,
134
+ calendarEndDate: WINDOW_END,
135
+ });
136
+ }
137
+
138
+ function event(
139
+ userId: string,
140
+ start: Date,
141
+ end: Date,
142
+ extra?: Record<string, unknown> | undefined,
143
+ ): CalendarEvent {
144
+ return {
145
+ id: 0,
146
+ title: userId,
147
+ allDay: false,
148
+ start,
149
+ end,
150
+ ...(extra ?? {}),
151
+ } as CalendarEvent;
152
+ }
153
+
154
+ function project(shifts: Array<OnCallShift>): Array<string> {
155
+ return shifts.map((shift: OnCallShift) => {
156
+ return `${shift.userId}|${shift.start.toISOString()}|${shift.end.toISOString()}|${shift.coverageSeconds}`;
157
+ });
158
+ }
159
+
160
+ describe("ScheduleShiftUtil.groupEventsIntoShifts default grouping is unchanged", () => {
161
+ test("byte-identical to the legacy implementation on a multi-layer fixture (no layer metadata)", () => {
162
+ const events: Array<CalendarEvent> = expand(multiLayerFixture(false));
163
+ expect(events.length).toBeGreaterThan(5);
164
+
165
+ for (const mergeAcrossGaps of [false, true]) {
166
+ const current: Array<OnCallShift> =
167
+ ScheduleShiftUtil.groupEventsIntoShifts(events, { mergeAcrossGaps });
168
+ const legacy: Array<OnCallShift> = legacyGroupEventsIntoShifts(events, {
169
+ mergeAcrossGaps,
170
+ });
171
+
172
+ expect(JSON.stringify(current)).toBe(JSON.stringify(legacy));
173
+ expect(current).toEqual(legacy);
174
+ }
175
+ });
176
+
177
+ test("identical shifts (projection) when the events DO carry layer metadata", () => {
178
+ const events: Array<CalendarEvent> = expand(multiLayerFixture(true));
179
+
180
+ for (const mergeAcrossGaps of [false, true]) {
181
+ const current: Array<OnCallShift> =
182
+ ScheduleShiftUtil.groupEventsIntoShifts(events, { mergeAcrossGaps });
183
+ const legacy: Array<OnCallShift> = legacyGroupEventsIntoShifts(events, {
184
+ mergeAcrossGaps,
185
+ });
186
+
187
+ expect(project(current)).toEqual(project(legacy));
188
+ }
189
+ });
190
+
191
+ test("identical shifts (projection) after overrides are applied", () => {
192
+ const events: Array<CalendarEvent> =
193
+ UserOverrideUtil.applyOverridesToEvents({
194
+ events: expand(multiLayerFixture(false)),
195
+ overrides: [
196
+ {
197
+ overrideUserId: "A",
198
+ routeAlertsToUserId: "B",
199
+ startsAt: at("2026-03-03T11:00:00Z"),
200
+ endsAt: at("2026-03-03T13:00:00Z"),
201
+ },
202
+ {
203
+ // C holds the fallback 03-02..03-04; this sits in its evening.
204
+ overrideUserId: "C",
205
+ routeAlertsToUserId: "D",
206
+ startsAt: at("2026-03-02T18:00:00Z"),
207
+ endsAt: at("2026-03-02T22:00:00Z"),
208
+ },
209
+ ],
210
+ });
211
+
212
+ const current: Array<OnCallShift> =
213
+ ScheduleShiftUtil.groupEventsIntoShifts(events);
214
+ const legacy: Array<OnCallShift> = legacyGroupEventsIntoShifts(events);
215
+
216
+ expect(project(current)).toEqual(project(legacy));
217
+ // The substitute really is in there, so the comparison is meaningful.
218
+ expect(
219
+ current.some((shift: OnCallShift) => {
220
+ return shift.userId === "D";
221
+ }),
222
+ ).toBe(true);
223
+ });
224
+
225
+ test("hand-built events without metadata produce shift objects with exactly the legacy keys", () => {
226
+ const events: Array<CalendarEvent> = [
227
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z")),
228
+ event("A", at("2026-03-02T12:00:01Z"), at("2026-03-02T17:00:00Z")),
229
+ event("B", at("2026-03-02T17:00:01Z"), at("2026-03-02T20:00:00Z")),
230
+ ];
231
+
232
+ const shifts: Array<OnCallShift> =
233
+ ScheduleShiftUtil.groupEventsIntoShifts(events);
234
+
235
+ expect(shifts).toHaveLength(2);
236
+ expect(Object.keys(shifts[0]!)).toEqual([
237
+ "userId",
238
+ "start",
239
+ "end",
240
+ "coverageSeconds",
241
+ ]);
242
+ expect("override" in shifts[0]!).toBe(false);
243
+ expect("layerId" in shifts[0]!).toBe(false);
244
+ });
245
+
246
+ test("the 5 second contiguity tolerance and empty input are unchanged", () => {
247
+ expect(ScheduleShiftUtil.groupEventsIntoShifts([])).toEqual([]);
248
+
249
+ const events: Array<CalendarEvent> = [
250
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z")),
251
+ event("A", at("2026-03-02T12:00:05Z"), at("2026-03-02T13:00:00Z")),
252
+ event("A", at("2026-03-02T13:00:06Z"), at("2026-03-02T14:00:00Z")),
253
+ ];
254
+
255
+ expect(ScheduleShiftUtil.groupEventsIntoShifts(events)).toHaveLength(2);
256
+ });
257
+ });
258
+
259
+ describe("ScheduleShiftUtil.groupEventsIntoShifts with a custom groupKey", () => {
260
+ const overrideMeta: OverrideEventMeta = {
261
+ isOverride: true,
262
+ originalUserId: "B",
263
+ overrideUserId: "A",
264
+ overrideStartsAt: at("2026-03-02T12:00:00Z"),
265
+ overrideEndsAt: at("2026-03-02T15:00:00Z"),
266
+ };
267
+
268
+ test("groupKeyByUserOverrideAndLayer splits a layer handover between the same user", () => {
269
+ const events: Array<CalendarEvent> = [
270
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z"), {
271
+ layerId: "layer-1",
272
+ layerName: "Primary",
273
+ }),
274
+ event("A", at("2026-03-02T12:00:00Z"), at("2026-03-02T15:00:00Z"), {
275
+ layerId: "layer-2",
276
+ layerName: "Fallback",
277
+ }),
278
+ ];
279
+
280
+ const merged: Array<OnCallShift> =
281
+ ScheduleShiftUtil.groupEventsIntoShifts(events);
282
+ expect(merged).toHaveLength(1);
283
+
284
+ const split: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
285
+ events,
286
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
287
+ );
288
+
289
+ expect(split).toHaveLength(2);
290
+ expect(split[0]!.layerId).toBe("layer-1");
291
+ expect(split[0]!.layerName).toBe("Primary");
292
+ expect(split[1]!.layerId).toBe("layer-2");
293
+ expect(split[1]!.layerName).toBe("Fallback");
294
+ expect(split[0]!.end.getTime()).toBe(split[1]!.start.getTime());
295
+ });
296
+
297
+ test("groupKeyByUserOverrideAndLayer keeps an override segment separate from the user's own shift", () => {
298
+ const events: Array<CalendarEvent> = [
299
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z")),
300
+ // A covering for B, immediately after A's own shift.
301
+ event("A", at("2026-03-02T12:00:00Z"), at("2026-03-02T15:00:00Z"), {
302
+ [OVERRIDE_META_KEY]: overrideMeta,
303
+ }),
304
+ event("A", at("2026-03-02T15:00:00Z"), at("2026-03-02T17:00:00Z")),
305
+ ];
306
+
307
+ expect(ScheduleShiftUtil.groupEventsIntoShifts(events)).toHaveLength(1);
308
+
309
+ const split: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
310
+ events,
311
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
312
+ );
313
+
314
+ expect(split).toHaveLength(3);
315
+ expect("override" in split[0]!).toBe(false);
316
+ expect(split[1]!.override).toEqual(overrideMeta);
317
+ expect("override" in split[2]!).toBe(false);
318
+ });
319
+
320
+ test("two adjacent segments produced by the SAME override merge into one shift", () => {
321
+ const events: Array<CalendarEvent> = [
322
+ event("A", at("2026-03-02T12:00:00Z"), at("2026-03-02T13:00:00Z"), {
323
+ [OVERRIDE_META_KEY]: overrideMeta,
324
+ }),
325
+ event("A", at("2026-03-02T13:00:01Z"), at("2026-03-02T15:00:00Z"), {
326
+ [OVERRIDE_META_KEY]: overrideMeta,
327
+ }),
328
+ ];
329
+
330
+ const shifts: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
331
+ events,
332
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
333
+ );
334
+
335
+ expect(shifts).toHaveLength(1);
336
+ expect(shifts[0]!.override).toEqual(overrideMeta);
337
+ expect(shifts[0]!.coverageSeconds).toBe(3600 + 7199);
338
+ });
339
+
340
+ test("a different override window for the same pair is a different key", () => {
341
+ const later: OverrideEventMeta = {
342
+ ...overrideMeta,
343
+ overrideStartsAt: at("2026-03-02T13:00:00Z"),
344
+ overrideEndsAt: at("2026-03-02T15:00:00Z"),
345
+ };
346
+
347
+ const events: Array<CalendarEvent> = [
348
+ event("A", at("2026-03-02T12:00:00Z"), at("2026-03-02T13:00:00Z"), {
349
+ [OVERRIDE_META_KEY]: overrideMeta,
350
+ }),
351
+ event("A", at("2026-03-02T13:00:00Z"), at("2026-03-02T15:00:00Z"), {
352
+ [OVERRIDE_META_KEY]: later,
353
+ }),
354
+ ];
355
+
356
+ const shifts: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
357
+ events,
358
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
359
+ );
360
+
361
+ expect(shifts).toHaveLength(2);
362
+ });
363
+
364
+ test("an arbitrary key function decides grouping; the shift keeps the first segment's user", () => {
365
+ const events: Array<CalendarEvent> = [
366
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z")),
367
+ event("B", at("2026-03-02T12:00:00Z"), at("2026-03-02T15:00:00Z")),
368
+ ];
369
+
370
+ const shifts: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
371
+ events,
372
+ {
373
+ groupKey: (): string => {
374
+ return "everyone";
375
+ },
376
+ },
377
+ );
378
+
379
+ expect(shifts).toHaveLength(1);
380
+ expect(shifts[0]!.userId).toBe("A");
381
+ expect(shifts[0]!.end.getTime()).toBe(at("2026-03-02T15:00:00Z").getTime());
382
+ expect(shifts[0]!.coverageSeconds).toBe(6 * 3600);
383
+ });
384
+
385
+ test("a custom key still respects contiguity unless mergeAcrossGaps is set", () => {
386
+ const events: Array<CalendarEvent> = [
387
+ event("A", at("2026-03-02T09:00:00Z"), at("2026-03-02T12:00:00Z"), {
388
+ layerId: "layer-1",
389
+ }),
390
+ event("A", at("2026-03-02T14:00:00Z"), at("2026-03-02T17:00:00Z"), {
391
+ layerId: "layer-1",
392
+ }),
393
+ ];
394
+
395
+ expect(
396
+ ScheduleShiftUtil.groupEventsIntoShifts(events, {
397
+ groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer,
398
+ }),
399
+ ).toHaveLength(2);
400
+
401
+ expect(
402
+ ScheduleShiftUtil.groupEventsIntoShifts(events, {
403
+ groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer,
404
+ mergeAcrossGaps: true,
405
+ }),
406
+ ).toHaveLength(1);
407
+ });
408
+
409
+ test("engine-derived events with layer metadata group per layer under the feed key", () => {
410
+ const events: Array<CalendarEvent> = expand(multiLayerFixture(true));
411
+
412
+ const shifts: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
413
+ events,
414
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
415
+ );
416
+
417
+ expect(shifts.length).toBeGreaterThan(0);
418
+ for (const shift of shifts) {
419
+ expect(["layer-1", "layer-2"]).toContain(shift.layerId);
420
+ if (shift.layerId === "layer-1") {
421
+ expect(["A", "B"]).toContain(shift.userId);
422
+ expect(shift.layerName).toBe("Primary");
423
+ } else {
424
+ expect(["C", "A"]).toContain(shift.userId);
425
+ expect(shift.layerName).toBe("Fallback");
426
+ }
427
+ }
428
+
429
+ // Never fewer shifts than the default grouping: the key only splits.
430
+ expect(shifts.length).toBeGreaterThanOrEqual(
431
+ ScheduleShiftUtil.groupEventsIntoShifts(events).length,
432
+ );
433
+ });
434
+
435
+ test("the default key helper equals the event title", () => {
436
+ const sample: CalendarEvent = event(
437
+ "user-x",
438
+ at("2026-03-02T09:00:00Z"),
439
+ at("2026-03-02T10:00:00Z"),
440
+ );
441
+ expect(ScheduleShiftUtil.defaultGroupKey(sample)).toBe("user-x");
442
+ expect(ScheduleShiftUtil.groupKeyByUserOverrideAndLayer(sample)).toBe(
443
+ "user-x|||",
444
+ );
445
+ });
446
+
447
+ test("the feed key carries the rotation period the engine stamped, except on override segments", () => {
448
+ const period: number = at("2026-03-02T00:00:00Z").getTime();
449
+
450
+ const plain: CalendarEvent = event(
451
+ "user-x",
452
+ at("2026-03-02T09:00:00Z"),
453
+ at("2026-03-02T10:00:00Z"),
454
+ { [ROTATION_PERIOD_START_KEY]: period },
455
+ );
456
+
457
+ expect(ScheduleShiftUtil.getEventRotationPeriodStart(plain)).toBe(period);
458
+ expect(ScheduleShiftUtil.groupKeyByUserOverrideAndLayer(plain)).toBe(
459
+ `user-x|||${period}`,
460
+ );
461
+
462
+ // The override window is the identity; it may legitimately span turns.
463
+ const covering: CalendarEvent = event(
464
+ "user-x",
465
+ at("2026-03-02T09:00:00Z"),
466
+ at("2026-03-02T10:00:00Z"),
467
+ {
468
+ [ROTATION_PERIOD_START_KEY]: period,
469
+ [OVERRIDE_META_KEY]: overrideMeta,
470
+ },
471
+ );
472
+
473
+ expect(ScheduleShiftUtil.groupKeyByUserOverrideAndLayer(covering)).toBe(
474
+ `user-x|B@${overrideMeta.overrideStartsAt.getTime()}-${overrideMeta.overrideEndsAt.getTime()}||`,
475
+ );
476
+ });
477
+
478
+ test("getEventRotationPeriodStart accepts Dates and ISO strings, ignores anything else", () => {
479
+ const period: Date = at("2026-03-02T00:00:00Z");
480
+
481
+ const asDate: CalendarEvent = event(
482
+ "u",
483
+ at("2026-03-02T09:00:00Z"),
484
+ at("2026-03-02T10:00:00Z"),
485
+ { [ROTATION_PERIOD_START_KEY]: period },
486
+ );
487
+ const asString: CalendarEvent = event(
488
+ "u",
489
+ at("2026-03-02T09:00:00Z"),
490
+ at("2026-03-02T10:00:00Z"),
491
+ { [ROTATION_PERIOD_START_KEY]: period.toISOString() },
492
+ );
493
+ const nonsense: CalendarEvent = event(
494
+ "u",
495
+ at("2026-03-02T09:00:00Z"),
496
+ at("2026-03-02T10:00:00Z"),
497
+ { [ROTATION_PERIOD_START_KEY]: { not: "a date" } },
498
+ );
499
+ const missing: CalendarEvent = event(
500
+ "u",
501
+ at("2026-03-02T09:00:00Z"),
502
+ at("2026-03-02T10:00:00Z"),
503
+ );
504
+
505
+ expect(ScheduleShiftUtil.getEventRotationPeriodStart(asDate)).toBe(
506
+ period.getTime(),
507
+ );
508
+ expect(ScheduleShiftUtil.getEventRotationPeriodStart(asString)).toBe(
509
+ period.getTime(),
510
+ );
511
+ expect(
512
+ ScheduleShiftUtil.getEventRotationPeriodStart(nonsense),
513
+ ).toBeUndefined();
514
+ expect(
515
+ ScheduleShiftUtil.getEventRotationPeriodStart(missing),
516
+ ).toBeUndefined();
517
+ });
518
+
519
+ test("getEventLayerId / getEventLayerName ignore non-string values", () => {
520
+ const sample: CalendarEvent = event(
521
+ "u",
522
+ at("2026-03-02T09:00:00Z"),
523
+ at("2026-03-02T10:00:00Z"),
524
+ { layerId: 42, layerName: null },
525
+ );
526
+ expect(ScheduleShiftUtil.getEventLayerId(sample)).toBeUndefined();
527
+ expect(ScheduleShiftUtil.getEventLayerName(sample)).toBeUndefined();
528
+ });
529
+ });
530
+
531
+ /*
532
+ * Regression: the feed's shift identity must not depend on where the feed
533
+ * window happens to start. Before the rotation period entered the group key,
534
+ * a run of consecutive turns held by the same user folded into ONE block that
535
+ * began at the first period overlapping the window and ended at the last one,
536
+ * so a subscriber's event silently changed its start (and therefore its UID)
537
+ * and grew its end every time the rolling window crossed a rotation boundary.
538
+ */
539
+ describe("ScheduleShiftUtil: consecutive turns of the same user stay separate shifts", () => {
540
+ const LAYER_START: Date = at("2026-03-02T00:00:00Z");
541
+
542
+ function weeklyLayer(users: Array<string>): LayerProps {
543
+ return {
544
+ users: users.map((id: string) => {
545
+ return user(id);
546
+ }),
547
+ startDateTimeOfLayer: LAYER_START,
548
+ restrictionTimes: noRestriction(),
549
+ handOffTime: LAYER_START,
550
+ rotation: rotation(EventInterval.Week, 1),
551
+ timezone: UTC,
552
+ layerId: "layer-1",
553
+ layerName: "Primary",
554
+ };
555
+ }
556
+
557
+ function feedShifts(
558
+ users: Array<string>,
559
+ windowStart: Date,
560
+ windowEnd: Date,
561
+ ): Array<OnCallShift> {
562
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
563
+ layers: [weeklyLayer(users)],
564
+ calendarStartDate: windowStart,
565
+ calendarEndDate: windowEnd,
566
+ });
567
+
568
+ // Exactly what OnCallShiftMaterializer.toShifts does.
569
+ return ShiftSeamUtil.normalizeSeams(
570
+ ScheduleShiftUtil.groupEventsIntoShifts(events, {
571
+ mergeAcrossGaps: false,
572
+ groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer,
573
+ }),
574
+ );
575
+ }
576
+
577
+ function starts(shifts: Array<OnCallShift>): Array<string> {
578
+ return shifts.map((shift: OnCallShift) => {
579
+ return shift.start.toISOString();
580
+ });
581
+ }
582
+
583
+ test("a single-user weekly layer yields one shift per week, not one block", () => {
584
+ const shifts: Array<OnCallShift> = feedShifts(
585
+ ["A"],
586
+ LAYER_START,
587
+ at("2026-03-30T00:00:00Z"),
588
+ );
589
+
590
+ expect(shifts.length).toBe(4);
591
+ for (const shift of shifts) {
592
+ expect(shift.userId).toBe("A");
593
+ // A week each, give or take the engine's 1 s seam.
594
+ expect(
595
+ shift.end.getTime() - shift.start.getTime(),
596
+ ).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 5000);
597
+ expect(shift.end.getTime() - shift.start.getTime()).toBeLessThanOrEqual(
598
+ 7 * 24 * 3600 * 1000,
599
+ );
600
+ }
601
+ });
602
+
603
+ test("the same rotation period keeps the same start in two different windows", () => {
604
+ const early: Array<OnCallShift> = feedShifts(
605
+ ["A"],
606
+ LAYER_START,
607
+ at("2026-04-06T00:00:00Z"),
608
+ );
609
+ const later: Array<OnCallShift> = feedShifts(
610
+ ["A"],
611
+ at("2026-03-11T00:00:00Z"),
612
+ at("2026-04-13T00:00:00Z"),
613
+ );
614
+
615
+ // Every week both windows contain is the very same shift in both.
616
+ const shared: Array<string> = starts(early).filter((start: string) => {
617
+ return starts(later).includes(start);
618
+ });
619
+
620
+ expect(shared.length).toBeGreaterThan(1);
621
+ expect(starts(early)).toContain("2026-03-16T00:00:00.000Z");
622
+ expect(starts(later)).toContain("2026-03-16T00:00:00.000Z");
623
+ });
624
+
625
+ test("[A, A, B] keeps A's two turns apart while B stays one shift", () => {
626
+ const shifts: Array<OnCallShift> = feedShifts(
627
+ ["A", "A", "B"],
628
+ LAYER_START,
629
+ at("2026-03-23T00:00:00Z"),
630
+ );
631
+
632
+ expect(
633
+ shifts.map((shift: OnCallShift) => {
634
+ return shift.userId;
635
+ }),
636
+ ).toEqual(["A", "A", "B"]);
637
+ expect(starts(shifts)).toEqual([
638
+ "2026-03-02T00:00:00.000Z",
639
+ "2026-03-09T00:00:00.000Z",
640
+ "2026-03-16T00:00:00.000Z",
641
+ ]);
642
+ });
643
+
644
+ test("an override that spans a rotation boundary is still ONE shift", () => {
645
+ const events: Array<CalendarEvent> =
646
+ UserOverrideUtil.applyOverridesToEvents({
647
+ events: new LayerUtil().getMultiLayerEvents({
648
+ layers: [weeklyLayer(["A"])],
649
+ calendarStartDate: LAYER_START,
650
+ calendarEndDate: at("2026-03-23T00:00:00Z"),
651
+ }),
652
+ overrides: [
653
+ {
654
+ overrideUserId: "A",
655
+ routeAlertsToUserId: "B",
656
+ // Wednesday to Wednesday, straight through the Monday handover.
657
+ startsAt: at("2026-03-04T00:00:00Z"),
658
+ endsAt: at("2026-03-11T00:00:00Z"),
659
+ },
660
+ ],
661
+ });
662
+
663
+ const shifts: Array<OnCallShift> = ShiftSeamUtil.normalizeSeams(
664
+ ScheduleShiftUtil.groupEventsIntoShifts(events, {
665
+ mergeAcrossGaps: false,
666
+ groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer,
667
+ }),
668
+ );
669
+
670
+ const covering: Array<OnCallShift> = shifts.filter((shift: OnCallShift) => {
671
+ return shift.userId === "B";
672
+ });
673
+
674
+ expect(covering).toHaveLength(1);
675
+ expect(covering[0]!.start.toISOString()).toBe("2026-03-04T00:00:00.000Z");
676
+ expect(covering[0]!.end.toISOString()).toBe("2026-03-11T00:00:00.000Z");
677
+ });
678
+ });