@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
@@ -23,6 +23,15 @@ export interface LayerProps {
23
23
  * time exactly as before — fully backward compatible.
24
24
  */
25
25
  timezone?: string | undefined;
26
+ /*
27
+ * Identity of the layer these props were built from. Optional and purely
28
+ * informational: the engine never reads them, it only stamps them onto every
29
+ * event it emits for this layer (see PriorityCalendarEvents) so consumers
30
+ * that render or group by layer — the on-call calendar feed — can tell which
31
+ * layer a merged segment came from without re-deriving it from priority.
32
+ */
33
+ layerId?: string;
34
+ layerName?: string;
26
35
  }
27
36
 
28
37
  export interface EventProps extends LayerProps {
@@ -36,8 +45,61 @@ export interface MultiLayerProps {
36
45
  calendarEndDate: Date;
37
46
  }
38
47
 
48
+ /*
49
+ * Key stamped on every event getEvents emits, holding the epoch milliseconds
50
+ * of the TRUE (un-clamped) start of the rotation period the event belongs to.
51
+ *
52
+ * Two consecutive rotation periods held by the SAME user (a single-user layer,
53
+ * or any rotation where one person holds two turns in a row) are otherwise
54
+ * indistinguishable once expanded: the engine's 1 s seam makes them contiguous
55
+ * and every other property matches, so a consumer that groups contiguous
56
+ * same-user segments folds them into one block whose start and end depend on
57
+ * how far the expansion window reached — an unstable identity for anything
58
+ * that must be the same object across two different windows (the calendar
59
+ * feed's UID/DTSTART). The period start is window-independent, so grouping on
60
+ * it keeps one shift per rotation turn.
61
+ *
62
+ * Read it through ScheduleShiftUtil.getEventRotationPeriodStart, which
63
+ * tolerates events that never carried it (hand-built fixtures, JSON round
64
+ * trips) rather than reaching into the index signature here.
65
+ */
66
+ export const ROTATION_PERIOD_START_KEY: string = "rotationPeriodStartsAt";
67
+
39
68
  export interface PriorityCalendarEvents extends CalendarEvent {
40
69
  priority: number;
70
+ /*
71
+ * Copied from LayerProps.layerId / layerName when the caller supplied them.
72
+ * Absent (not undefined) otherwise, so callers that never set them see
73
+ * exactly the events they always did.
74
+ */
75
+ layerId?: string;
76
+ layerName?: string;
77
+ }
78
+
79
+ export interface LayerExpansionOptions {
80
+ getNumberOfEvents?: number;
81
+ /*
82
+ * Upper bound on the number of rotation periods the engine will step
83
+ * through for ONE layer, both while locating the rotation position at the
84
+ * window start (the per-period simulation restricted layers need) and while
85
+ * expanding periods inside the window. Defaults to the engine's own caps
86
+ * (5,000,000 / 1,000,000), so omitting it changes nothing. When the bound is
87
+ * hit the expansion stops and the result is flagged `truncated`: a
88
+ * pre-window hit contributes NO events for that layer (its rotation
89
+ * position is unknown, and emitting the wrong person is worse than emitting
90
+ * nobody), an in-window hit keeps the events produced so far.
91
+ */
92
+ maxSimulationIterations?: number;
93
+ }
94
+
95
+ export interface LayerEventsResult {
96
+ events: Array<CalendarEvent>;
97
+ /*
98
+ * True when any expansion stopped at maxSimulationIterations (or the
99
+ * engine's own ceiling) before reaching the end of the window, i.e. the
100
+ * events may be incomplete.
101
+ */
102
+ truncated: boolean;
41
103
  }
42
104
 
43
105
  export default class LayerUtil {
@@ -52,16 +114,24 @@ export default class LayerUtil {
52
114
 
53
115
  public getEvents(
54
116
  data: EventProps,
55
- options?:
56
- | {
57
- getNumberOfEvents?: number;
58
- }
59
- | undefined,
117
+ options?: LayerExpansionOptions | undefined,
60
118
  ): Array<CalendarEvent> {
119
+ return this.getEventsWithMeta(data, options).events;
120
+ }
121
+
122
+ /*
123
+ * Same expansion as getEvents, plus a `truncated` flag telling the caller
124
+ * whether an iteration cap cut the expansion short. getEvents discards the
125
+ * flag so its existing callers are untouched.
126
+ */
127
+ public getEventsWithMeta(
128
+ data: EventProps,
129
+ options?: LayerExpansionOptions | undefined,
130
+ ): LayerEventsResult {
61
131
  let events: Array<CalendarEvent> = [];
62
132
 
63
133
  if (!this.isDataValid(data)) {
64
- return [];
134
+ return { events: [], truncated: false };
65
135
  }
66
136
 
67
137
  data = this.sanitizeData(data);
@@ -85,9 +155,16 @@ export default class LayerUtil {
85
155
  let handOffTime: Date = data.handOffTime;
86
156
 
87
157
  if (!handOffTime) {
88
- return [];
158
+ return { events: [], truncated: false };
89
159
  }
90
160
 
161
+ const maxSimulationIterations: number | undefined =
162
+ options?.maxSimulationIterations !== undefined &&
163
+ Number.isFinite(options.maxSimulationIterations) &&
164
+ options.maxSimulationIterations > 0
165
+ ? Math.floor(options.maxSimulationIterations)
166
+ : undefined;
167
+
91
168
  // Looop vars
92
169
  let currentUserIndex: number = 0;
93
170
  let currentEventStartTime: Date = start;
@@ -99,6 +176,7 @@ export default class LayerUtil {
99
176
  const currentUserResolution: {
100
177
  currentUserIndex: number;
101
178
  currentPeriodStart: Date;
179
+ truncated: boolean;
102
180
  } = this.getCurrentUserIndexBasedOnHandoffTime({
103
181
  rotation,
104
182
  handOffTime,
@@ -107,7 +185,18 @@ export default class LayerUtil {
107
185
  users: data.users,
108
186
  currentEventStartTime,
109
187
  restrictionTimes: data.restrictionTimes,
188
+ maxSimulationIterations,
110
189
  });
190
+
191
+ /*
192
+ * The pre-window simulation gave up before reaching the window start, so
193
+ * the rotation position is unknown. Emit nothing for this layer rather
194
+ * than shifts attributed to the wrong person, and tell the caller.
195
+ */
196
+ if (currentUserResolution.truncated) {
197
+ return { events: [], truncated: true };
198
+ }
199
+
111
200
  currentUserIndex = currentUserResolution.currentUserIndex;
112
201
 
113
202
  /*
@@ -146,10 +235,11 @@ export default class LayerUtil {
146
235
  trimmedStartAndEndTimes,
147
236
  data.users,
148
237
  currentUserIndex,
238
+ firstPeriodTrueStart,
149
239
  ),
150
240
  ];
151
241
 
152
- return events;
242
+ return { events, truncated: false };
153
243
  }
154
244
 
155
245
  /*
@@ -175,10 +265,12 @@ export default class LayerUtil {
175
265
  rotation.intervalType,
176
266
  );
177
267
  const maxLoopCount: number = Math.min(
268
+ maxSimulationIterations ?? 1000000,
178
269
  1000000,
179
270
  Math.max(100, Math.ceil(windowUnits / periodUnitsForBound) + 10),
180
271
  );
181
272
  let loopCount: number = 0;
273
+ let truncated: boolean = false;
182
274
 
183
275
  /*
184
276
  * The first loop iteration expands the rotation period that CONTAINS the
@@ -191,6 +283,7 @@ export default class LayerUtil {
191
283
  while (!hasReachedTheEndOfTheCalendar) {
192
284
  loopCount++;
193
285
  if (loopCount > maxLoopCount) {
286
+ truncated = true;
194
287
  break;
195
288
  }
196
289
  currentEventEndTime = handOffTime;
@@ -242,12 +335,18 @@ export default class LayerUtil {
242
335
  trimmedStartAndEndTimes,
243
336
  data.users,
244
337
  currentUserIndex,
338
+ /*
339
+ * The first iteration expands the period that CONTAINS the window
340
+ * start, whose currentEventStartTime may be clamped to it; stamp the
341
+ * period's TRUE start so the identity does not move with the window.
342
+ */
343
+ isFirstPeriod ? firstPeriodTrueStart : currentEventStartTime,
245
344
  ),
246
345
  );
247
346
 
248
347
  if (options?.getNumberOfEvents !== undefined) {
249
348
  if (events.length >= options.getNumberOfEvents) {
250
- return events;
349
+ return { events, truncated: false };
251
350
  }
252
351
  }
253
352
 
@@ -328,7 +427,7 @@ export default class LayerUtil {
328
427
  }
329
428
  }
330
429
 
331
- return events;
430
+ return { events, truncated };
332
431
  }
333
432
 
334
433
  private sanitizeData(data: EventProps): EventProps {
@@ -524,7 +623,12 @@ export default class LayerUtil {
524
623
  users: Array<UserModel>;
525
624
  currentEventStartTime: Date;
526
625
  restrictionTimes: RestrictionTimes;
527
- }): { currentUserIndex: number; currentPeriodStart: Date } {
626
+ maxSimulationIterations?: number | undefined;
627
+ }): {
628
+ currentUserIndex: number;
629
+ currentPeriodStart: Date;
630
+ truncated: boolean;
631
+ } {
528
632
  /*
529
633
  * Returns both the on-call user index for the rotation period that CONTAINS
530
634
  * currentEventStartTime AND the true (un-clamped) start of that period.
@@ -546,6 +650,7 @@ export default class LayerUtil {
546
650
  return {
547
651
  currentUserIndex,
548
652
  currentPeriodStart: data.currentEventStartTime,
653
+ truncated: false,
549
654
  };
550
655
  }
551
656
 
@@ -558,6 +663,7 @@ export default class LayerUtil {
558
663
  return {
559
664
  currentUserIndex,
560
665
  currentPeriodStart: data.startDateTimeOfLayer,
666
+ truncated: false,
561
667
  };
562
668
  }
563
669
 
@@ -599,6 +705,7 @@ export default class LayerUtil {
599
705
  currentUserIndex:
600
706
  (((currentUserIndex + periodsElapsed) % length) + length) % length,
601
707
  currentPeriodStart: data.currentEventStartTime,
708
+ truncated: false,
602
709
  };
603
710
  }
604
711
 
@@ -639,15 +746,23 @@ export default class LayerUtil {
639
746
  data.rotation.intervalType,
640
747
  );
641
748
  const maxIterations: number = Math.min(
749
+ data.maxSimulationIterations ?? 5000000,
642
750
  5000000,
643
751
  Math.max(10000, Math.ceil(simUnitsBetween / simPeriodUnits) + 10),
644
752
  );
645
753
  let iterations: number = 0;
754
+ let truncated: boolean = false;
646
755
 
647
- while (
648
- OneUptimeDate.isBefore(simulatedTime, data.currentEventStartTime) &&
649
- iterations < maxIterations
650
- ) {
756
+ while (OneUptimeDate.isBefore(simulatedTime, data.currentEventStartTime)) {
757
+ /*
758
+ * Cap hit before the target was reached: the rotation position for the
759
+ * window is unknown. Report it instead of silently returning the index
760
+ * at the cap (audit F9 explains why that is the wrong on-call person).
761
+ */
762
+ if (iterations >= maxIterations) {
763
+ truncated = true;
764
+ break;
765
+ }
651
766
  iterations++;
652
767
 
653
768
  const eventEnd: Date = simulatedHandOff;
@@ -685,7 +800,7 @@ export default class LayerUtil {
685
800
  * next period start each covered iteration and breaks once a period would
686
801
  * extend past the target, so it holds the current period's real start.
687
802
  */
688
- return { currentUserIndex, currentPeriodStart: simulatedTime };
803
+ return { currentUserIndex, currentPeriodStart: simulatedTime, truncated };
689
804
  }
690
805
 
691
806
  /*
@@ -1432,6 +1547,8 @@ export default class LayerUtil {
1432
1547
  trimmedStartAndEndTimes: Array<StartAndEndTime>,
1433
1548
  users: Array<UserModel>,
1434
1549
  currentUserIndex: number,
1550
+ // True (un-clamped) start of the rotation period these segments came from.
1551
+ rotationPeriodStartsAt: Date,
1435
1552
  ): Array<CalendarEvent> {
1436
1553
  const events: Array<CalendarEvent> = [];
1437
1554
 
@@ -1446,6 +1563,10 @@ export default class LayerUtil {
1446
1563
  end: trimmedStartAndEndTime.endTime,
1447
1564
  };
1448
1565
 
1566
+ // See ROTATION_PERIOD_START_KEY.
1567
+ (event as unknown as Record<string, unknown>)[ROTATION_PERIOD_START_KEY] =
1568
+ rotationPeriodStartsAt.getTime();
1569
+
1449
1570
  events.push(event);
1450
1571
  }
1451
1572
 
@@ -1454,14 +1575,29 @@ export default class LayerUtil {
1454
1575
 
1455
1576
  public getMultiLayerEvents(
1456
1577
  data: MultiLayerProps,
1457
- options?:
1458
- | {
1459
- getNumberOfEvents?: number;
1460
- }
1461
- | undefined,
1578
+ options?: LayerExpansionOptions | undefined,
1462
1579
  ): Array<CalendarEvent> {
1580
+ return this.getMultiLayerEventsWithMeta(data, options).events;
1581
+ }
1582
+
1583
+ /*
1584
+ * Same priority merge as getMultiLayerEvents, plus a `truncated` flag that
1585
+ * is true when ANY layer's expansion hit an iteration cap (see
1586
+ * LayerExpansionOptions.maxSimulationIterations). getMultiLayerEvents
1587
+ * discards the flag so its existing callers are untouched.
1588
+ */
1589
+ public getMultiLayerEventsWithMeta(
1590
+ data: MultiLayerProps,
1591
+ options?: LayerExpansionOptions | undefined,
1592
+ ): LayerEventsResult {
1463
1593
  const events: Array<PriorityCalendarEvents> = [];
1464
1594
  let layerPriority: number = 1;
1595
+ let truncated: boolean = false;
1596
+
1597
+ const layerOptions: LayerExpansionOptions | undefined =
1598
+ options?.maxSimulationIterations !== undefined
1599
+ ? { maxSimulationIterations: options.maxSimulationIterations }
1600
+ : undefined;
1465
1601
 
1466
1602
  for (const layer of data.layers) {
1467
1603
  /*
@@ -1473,25 +1609,41 @@ export default class LayerUtil {
1473
1609
  * is never generated, corrupting the merged "next" roster. The cap is
1474
1610
  * applied only once, after the merge, below.
1475
1611
  */
1476
- const layerEvents: Array<CalendarEvent> = this.getEvents({
1477
- users: layer.users,
1478
- startDateTimeOfLayer: layer.startDateTimeOfLayer,
1479
- restrictionTimes: layer.restrictionTimes,
1480
- handOffTime: layer.handOffTime,
1481
- rotation: layer.rotation,
1482
- timezone: layer.timezone,
1483
- calendarStartDate: data.calendarStartDate,
1484
- calendarEndDate: data.calendarEndDate,
1485
- });
1612
+ const layerResult: LayerEventsResult = this.getEventsWithMeta(
1613
+ {
1614
+ users: layer.users,
1615
+ startDateTimeOfLayer: layer.startDateTimeOfLayer,
1616
+ restrictionTimes: layer.restrictionTimes,
1617
+ handOffTime: layer.handOffTime,
1618
+ rotation: layer.rotation,
1619
+ timezone: layer.timezone,
1620
+ calendarStartDate: data.calendarStartDate,
1621
+ calendarEndDate: data.calendarEndDate,
1622
+ },
1623
+ layerOptions,
1624
+ );
1486
1625
 
1487
- // add priority to each event
1626
+ truncated = truncated || layerResult.truncated;
1488
1627
 
1489
- for (const layerEvent of layerEvents) {
1628
+ /*
1629
+ * Add priority (and, when the caller identified the layer, its id and
1630
+ * name) to each event. The keys are only added when present so callers
1631
+ * that never set them get exactly the event shape they always did.
1632
+ */
1633
+ for (const layerEvent of layerResult.events) {
1490
1634
  const priorityEvent: PriorityCalendarEvents = {
1491
1635
  ...layerEvent,
1492
1636
  priority: layerPriority,
1493
1637
  };
1494
1638
 
1639
+ if (layer.layerId !== undefined) {
1640
+ priorityEvent.layerId = layer.layerId;
1641
+ }
1642
+
1643
+ if (layer.layerName !== undefined) {
1644
+ priorityEvent.layerName = layer.layerName;
1645
+ }
1646
+
1495
1647
  events.push(priorityEvent);
1496
1648
  }
1497
1649
 
@@ -1506,11 +1658,14 @@ export default class LayerUtil {
1506
1658
 
1507
1659
  if (options?.getNumberOfEvents !== undefined) {
1508
1660
  if (nonOverlappingEvents.length > options.getNumberOfEvents) {
1509
- return nonOverlappingEvents.slice(0, options.getNumberOfEvents);
1661
+ return {
1662
+ events: nonOverlappingEvents.slice(0, options.getNumberOfEvents),
1663
+ truncated,
1664
+ };
1510
1665
  }
1511
1666
  }
1512
1667
 
1513
- return nonOverlappingEvents;
1668
+ return { events: nonOverlappingEvents, truncated };
1514
1669
  }
1515
1670
 
1516
1671
  public removeOverlappingEvents(
@@ -0,0 +1,274 @@
1
+ import OneUptimeDate from "../Date";
2
+
3
+ /*
4
+ * A fully resolved on-call shift: one person, one schedule, one contiguous
5
+ * block of time, with everything a calendar entry or a reminder needs already
6
+ * looked up (names, policies, override provenance). Produced by the server's
7
+ * OnCallShiftMaterializer from LayerUtil -> overrides -> groupEventsIntoShifts
8
+ * -> seam normalisation, cached per schedule, and consumed by the calendar
9
+ * feed mapper (OnCallCalendarFeedUtil), the /my-shifts API and the shift
10
+ * reminder runner.
11
+ *
12
+ * Identity is (schedule, seam-normalised start) — NOT the user — so an
13
+ * override that swaps who is on call updates the same calendar event in place
14
+ * instead of deleting one and creating another. Policy-variant shifts (an
15
+ * extra shift that exists only for one escalation policy because of a
16
+ * policy-scoped override) add the policy id to that identity.
17
+ */
18
+
19
+ export interface MaterializedShiftOverride {
20
+ originalUserId: string;
21
+ originalUserName: string;
22
+ overrideStartsAt: Date;
23
+ overrideEndsAt: Date;
24
+ // Absent for a global override; set when the override is policy-scoped.
25
+ onCallDutyPolicyId?: string;
26
+ }
27
+
28
+ export interface MaterializedShiftPolicyVariant {
29
+ policyId: string;
30
+ policyName: string;
31
+ // Who the GLOBAL resolution puts on call for the same interval.
32
+ globalUserId: string;
33
+ }
34
+
35
+ export interface MaterializedShiftPolicy {
36
+ policyId: string;
37
+ policyName: string;
38
+ ruleId: string;
39
+ ruleName: string;
40
+ ruleOrder: number;
41
+ }
42
+
43
+ export interface MaterializedShift {
44
+ // `${scheduleId}:${normalizedStartEpochSeconds}` (+ `:${policyId}` for policy variants).
45
+ shiftKey: string;
46
+ // sha256 of {end, userId, summary, description, transparency, policies, override, layer}.
47
+ contentHash: string;
48
+ projectId: string;
49
+ projectName?: string;
50
+ scheduleId: string;
51
+ scheduleName: string;
52
+ // IANA zone the schedule is authored in; absent for legacy schedules.
53
+ scheduleTimezone?: string;
54
+ userId: string;
55
+ userName: string;
56
+ // Seam-normalised (see ShiftSeamUtil); DTEND-style exclusive end.
57
+ start: Date;
58
+ end: Date;
59
+ coverageSeconds: number;
60
+ layerId?: string;
61
+ layerName?: string;
62
+ override?: MaterializedShiftOverride;
63
+ // Set only on the extra, policy-specific shifts.
64
+ policyVariantOf?: MaterializedShiftPolicyVariant;
65
+ policies: Array<MaterializedShiftPolicy>;
66
+ // start < now at materialisation time: "reflects current configuration".
67
+ isPast: boolean;
68
+ lastModifiedAt: Date;
69
+ shiftConfigVersion: number;
70
+ }
71
+
72
+ /*
73
+ * Wire shape (dates as ISO strings, optional strings as null) used by the
74
+ * /my-shifts endpoint and the schedule-level cache.
75
+ */
76
+ export interface MaterializedShiftOverrideJson {
77
+ originalUserId: string;
78
+ originalUserName: string;
79
+ overrideStartsAt: string;
80
+ overrideEndsAt: string;
81
+ onCallDutyPolicyId?: string;
82
+ }
83
+
84
+ export interface MaterializedShiftJson {
85
+ shiftKey: string;
86
+ contentHash: string;
87
+ projectId: string;
88
+ projectName?: string;
89
+ scheduleId: string;
90
+ scheduleName: string;
91
+ scheduleTimezone: string | null;
92
+ userId: string;
93
+ userName: string;
94
+ start: string;
95
+ end: string;
96
+ coverageSeconds: number;
97
+ layerId?: string;
98
+ layerName?: string;
99
+ override?: MaterializedShiftOverrideJson;
100
+ policyVariantOf?: MaterializedShiftPolicyVariant;
101
+ policies: Array<MaterializedShiftPolicy>;
102
+ isPast: boolean;
103
+ lastModifiedAt: string;
104
+ shiftConfigVersion: number;
105
+ }
106
+
107
+ export default class MaterializedShiftUtil {
108
+ public static toJSON(shift: MaterializedShift): MaterializedShiftJson {
109
+ const json: MaterializedShiftJson = {
110
+ shiftKey: shift.shiftKey,
111
+ contentHash: shift.contentHash,
112
+ projectId: shift.projectId,
113
+ scheduleId: shift.scheduleId,
114
+ scheduleName: shift.scheduleName,
115
+ scheduleTimezone: shift.scheduleTimezone ?? null,
116
+ userId: shift.userId,
117
+ userName: shift.userName,
118
+ start: shift.start.toISOString(),
119
+ end: shift.end.toISOString(),
120
+ coverageSeconds: shift.coverageSeconds,
121
+ policies: shift.policies.map((policy: MaterializedShiftPolicy) => {
122
+ return { ...policy };
123
+ }),
124
+ isPast: shift.isPast,
125
+ lastModifiedAt: shift.lastModifiedAt.toISOString(),
126
+ shiftConfigVersion: shift.shiftConfigVersion,
127
+ };
128
+
129
+ if (shift.projectName !== undefined) {
130
+ json.projectName = shift.projectName;
131
+ }
132
+
133
+ if (shift.layerId !== undefined) {
134
+ json.layerId = shift.layerId;
135
+ }
136
+
137
+ if (shift.layerName !== undefined) {
138
+ json.layerName = shift.layerName;
139
+ }
140
+
141
+ if (shift.override) {
142
+ const override: MaterializedShiftOverrideJson = {
143
+ originalUserId: shift.override.originalUserId,
144
+ originalUserName: shift.override.originalUserName,
145
+ overrideStartsAt: shift.override.overrideStartsAt.toISOString(),
146
+ overrideEndsAt: shift.override.overrideEndsAt.toISOString(),
147
+ };
148
+
149
+ if (shift.override.onCallDutyPolicyId !== undefined) {
150
+ override.onCallDutyPolicyId = shift.override.onCallDutyPolicyId;
151
+ }
152
+
153
+ json.override = override;
154
+ }
155
+
156
+ if (shift.policyVariantOf) {
157
+ json.policyVariantOf = { ...shift.policyVariantOf };
158
+ }
159
+
160
+ return json;
161
+ }
162
+
163
+ public static fromJSON(json: MaterializedShiftJson): MaterializedShift {
164
+ const shift: MaterializedShift = {
165
+ shiftKey: json.shiftKey,
166
+ contentHash: json.contentHash,
167
+ projectId: json.projectId,
168
+ scheduleId: json.scheduleId,
169
+ scheduleName: json.scheduleName,
170
+ userId: json.userId,
171
+ userName: json.userName,
172
+ start: OneUptimeDate.fromString(json.start),
173
+ end: OneUptimeDate.fromString(json.end),
174
+ coverageSeconds: json.coverageSeconds,
175
+ policies: (json.policies || []).map((policy: MaterializedShiftPolicy) => {
176
+ return { ...policy };
177
+ }),
178
+ isPast: Boolean(json.isPast),
179
+ lastModifiedAt: OneUptimeDate.fromString(json.lastModifiedAt),
180
+ shiftConfigVersion: json.shiftConfigVersion,
181
+ };
182
+
183
+ if (json.projectName !== undefined) {
184
+ shift.projectName = json.projectName;
185
+ }
186
+
187
+ if (json.scheduleTimezone !== null && json.scheduleTimezone !== undefined) {
188
+ shift.scheduleTimezone = json.scheduleTimezone;
189
+ }
190
+
191
+ if (json.layerId !== undefined) {
192
+ shift.layerId = json.layerId;
193
+ }
194
+
195
+ if (json.layerName !== undefined) {
196
+ shift.layerName = json.layerName;
197
+ }
198
+
199
+ if (json.override) {
200
+ const override: MaterializedShiftOverride = {
201
+ originalUserId: json.override.originalUserId,
202
+ originalUserName: json.override.originalUserName,
203
+ overrideStartsAt: OneUptimeDate.fromString(
204
+ json.override.overrideStartsAt,
205
+ ),
206
+ overrideEndsAt: OneUptimeDate.fromString(json.override.overrideEndsAt),
207
+ };
208
+
209
+ if (json.override.onCallDutyPolicyId !== undefined) {
210
+ override.onCallDutyPolicyId = json.override.onCallDutyPolicyId;
211
+ }
212
+
213
+ shift.override = override;
214
+ }
215
+
216
+ if (json.policyVariantOf) {
217
+ shift.policyVariantOf = { ...json.policyVariantOf };
218
+ }
219
+
220
+ return shift;
221
+ }
222
+
223
+ public static toJSONArray(
224
+ shifts: Array<MaterializedShift>,
225
+ ): Array<MaterializedShiftJson> {
226
+ return shifts.map((shift: MaterializedShift) => {
227
+ return MaterializedShiftUtil.toJSON(shift);
228
+ });
229
+ }
230
+
231
+ public static fromJSONArray(
232
+ json: Array<MaterializedShiftJson>,
233
+ ): Array<MaterializedShift> {
234
+ return json.map((item: MaterializedShiftJson) => {
235
+ return MaterializedShiftUtil.fromJSON(item);
236
+ });
237
+ }
238
+
239
+ /*
240
+ * Deterministic order for rendering and hashing: by start, then schedule,
241
+ * then shift key. Returns a sorted copy; never mutates the input.
242
+ */
243
+ public static sortByStart(
244
+ shifts: Array<MaterializedShift>,
245
+ ): Array<MaterializedShift> {
246
+ return [...shifts].sort((a: MaterializedShift, b: MaterializedShift) => {
247
+ const byStart: number = a.start.getTime() - b.start.getTime();
248
+ if (byStart !== 0) {
249
+ return byStart;
250
+ }
251
+
252
+ if (a.scheduleId !== b.scheduleId) {
253
+ return a.scheduleId < b.scheduleId ? -1 : 1;
254
+ }
255
+
256
+ if (a.shiftKey === b.shiftKey) {
257
+ return 0;
258
+ }
259
+
260
+ return a.shiftKey < b.shiftKey ? -1 : 1;
261
+ });
262
+ }
263
+
264
+ // `${scheduleId}:${startEpochSeconds}` (+ `:${policyId}` for variants).
265
+ public static buildShiftKey(data: {
266
+ scheduleId: string;
267
+ start: Date;
268
+ policyId?: string | undefined;
269
+ }): string {
270
+ const epochSeconds: number = Math.floor(data.start.getTime() / 1000);
271
+ const base: string = `${data.scheduleId}:${epochSeconds}`;
272
+ return data.policyId ? `${base}:${data.policyId}` : base;
273
+ }
274
+ }