@oneuptime/common 11.3.25 → 11.3.28

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 (397) hide show
  1. package/Models/DatabaseModels/AIConversation.ts +322 -0
  2. package/Models/DatabaseModels/AIConversationMessage.ts +495 -0
  3. package/Models/DatabaseModels/AIRun.ts +584 -0
  4. package/Models/DatabaseModels/AIRunEvent.ts +443 -0
  5. package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
  6. package/Models/DatabaseModels/IncidentMember.ts +1 -1
  7. package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
  8. package/Models/DatabaseModels/Index.ts +11 -0
  9. package/Models/DatabaseModels/LlmLog.ts +26 -0
  10. package/Models/DatabaseModels/LlmProvider.ts +2 -2
  11. package/Models/DatabaseModels/MigrationFailure.ts +170 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
  13. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
  14. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
  15. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
  16. package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
  17. package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
  18. package/Server/API/AIChatAPI.ts +693 -0
  19. package/Server/API/AlertAPI.ts +1 -1
  20. package/Server/API/IncidentAPI.ts +2 -2
  21. package/Server/API/IncidentEpisodeAPI.ts +1 -1
  22. package/Server/API/LlmProviderAPI.ts +170 -0
  23. package/Server/API/MicrosoftTeamsAPI.ts +5 -0
  24. package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
  25. package/Server/API/SlackAPI.ts +663 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.ts +245 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.ts +36 -0
  28. package/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.ts +46 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.ts +119 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +16 -0
  35. package/Server/Infrastructure/PostgresDatabase.ts +11 -0
  36. package/Server/Services/AIConversationMessageService.ts +39 -0
  37. package/Server/Services/AIConversationService.ts +63 -0
  38. package/Server/Services/AIRunEventService.ts +39 -0
  39. package/Server/Services/AIRunService.ts +39 -0
  40. package/Server/Services/AIService.ts +127 -33
  41. package/Server/Services/AnalyticsDatabaseService.ts +10 -1
  42. package/Server/Services/DatabaseService.ts +29 -1
  43. package/Server/Services/IncidentService.ts +1 -1
  44. package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
  45. package/Server/Services/IncomingCallPolicyService.ts +42 -1
  46. package/Server/Services/Index.ts +2 -0
  47. package/Server/Services/LlmProviderService.ts +110 -0
  48. package/Server/Services/MigrationFailureService.ts +9 -0
  49. package/Server/Services/MonitorProbeService.ts +33 -7
  50. package/Server/Services/MonitorService.ts +19 -7
  51. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
  52. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
  53. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
  54. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
  55. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
  56. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
  57. package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
  58. package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
  59. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
  60. package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
  61. package/Server/Services/ProjectService.ts +24 -0
  62. package/Server/Services/TeamMemberService.ts +6 -0
  63. package/Server/Services/UserNotificationRuleService.ts +13 -42
  64. package/Server/Services/UserOnCallLogService.ts +90 -0
  65. package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
  66. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +74 -9
  67. package/Server/Types/Database/QueryUtil.ts +57 -0
  68. package/Server/Utils/AI/AIChatPrivacyFilter.ts +28 -0
  69. package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
  70. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +1066 -0
  71. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +239 -0
  72. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +51 -0
  73. package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
  74. package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
  75. package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
  76. package/Server/Utils/AI/Toolbox/AlertTools.ts +201 -0
  77. package/Server/Utils/AI/Toolbox/AlertWriteTools.ts +174 -0
  78. package/Server/Utils/AI/Toolbox/ContextTools.ts +189 -0
  79. package/Server/Utils/AI/Toolbox/ExceptionTools.ts +149 -0
  80. package/Server/Utils/AI/Toolbox/IncidentTools.ts +386 -0
  81. package/Server/Utils/AI/Toolbox/IncidentWriteTools.ts +350 -0
  82. package/Server/Utils/AI/Toolbox/Index.ts +231 -0
  83. package/Server/Utils/AI/Toolbox/LogTools.ts +339 -0
  84. package/Server/Utils/AI/Toolbox/MetricTools.ts +179 -0
  85. package/Server/Utils/AI/Toolbox/MonitorTools.ts +193 -0
  86. package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
  87. package/Server/Utils/AI/Toolbox/Serializer.ts +299 -0
  88. package/Server/Utils/AI/Toolbox/ToolTypes.ts +249 -0
  89. package/Server/Utils/AI/Toolbox/TraceTools.ts +402 -0
  90. package/Server/Utils/AI/Toolbox/WidgetBuilder.ts +189 -0
  91. package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
  92. package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
  93. package/Server/Utils/LLM/LLMService.ts +605 -96
  94. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
  95. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
  96. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +512 -19
  97. package/Server/Utils/Workspace/Slack/Slack.ts +80 -0
  98. package/Server/Utils/Workspace/Slack/app-manifest.json +16 -1
  99. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
  100. package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
  101. package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
  102. package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
  103. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
  104. package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
  105. package/Tests/Server/Utils/AI/AIChatModelACL.test.ts +42 -0
  106. package/Tests/Server/Utils/AI/ChatAgentHelpers.test.ts +89 -0
  107. package/Tests/Server/Utils/AI/LLMServiceBaseUrl.test.ts +139 -0
  108. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +386 -0
  109. package/Tests/Server/Utils/AI/ToolArgsGetTimeRange.test.ts +62 -0
  110. package/Tests/Server/Utils/AI/ToolArgsScopeServiceIds.test.ts +79 -0
  111. package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +223 -0
  112. package/Tests/Types/Billing/SubscriptionStatus.test.ts +127 -0
  113. package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
  114. package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
  115. package/Tests/Types/Events/Recurring.test.ts +22 -9
  116. package/Tests/Types/Metrics/RecordingRuleDefinition.test.ts +213 -0
  117. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
  118. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
  119. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
  120. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
  121. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
  122. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
  123. package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
  124. package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
  125. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
  126. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
  127. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
  128. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
  129. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
  130. package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
  131. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
  132. package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
  133. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
  134. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
  135. package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
  136. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
  137. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
  138. package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
  139. package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
  140. package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
  141. package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
  142. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
  143. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
  144. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
  145. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
  146. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
  147. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
  148. package/Types/AI/AIChatMessageRole.ts +6 -0
  149. package/Types/AI/AIChatMessageStatus.ts +32 -0
  150. package/Types/AI/AIChatPermissionMode.ts +69 -0
  151. package/Types/AI/AIChatTypes.ts +231 -0
  152. package/Types/AI/AIRunEventType.ts +17 -0
  153. package/Types/AI/AIRunStatus.ts +28 -0
  154. package/Types/AI/AIRunType.ts +6 -0
  155. package/Types/Date.ts +200 -15
  156. package/Types/LLM/LlmType.ts +6 -0
  157. package/Types/OnCallDutyPolicy/Layer.ts +790 -260
  158. package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
  159. package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
  160. package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
  161. package/UI/Components/Header/HeaderIconDropdownButton.tsx +22 -12
  162. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +35 -3
  163. package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
  164. package/UI/Components/Page/Page.tsx +14 -0
  165. package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
  166. package/UI/Utils/LlmTypeDropdownOptions.ts +40 -0
  167. package/UI/Utils/TestLLMProvider.ts +59 -0
  168. package/build/dist/Models/DatabaseModels/AIConversation.js +345 -0
  169. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -0
  170. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +521 -0
  171. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -0
  172. package/build/dist/Models/DatabaseModels/AIRun.js +619 -0
  173. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -0
  174. package/build/dist/Models/DatabaseModels/AIRunEvent.js +469 -0
  175. package/build/dist/Models/DatabaseModels/AIRunEvent.js.map +1 -0
  176. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
  177. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
  178. package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
  179. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  180. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
  181. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
  182. package/build/dist/Models/DatabaseModels/Index.js +10 -0
  183. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  184. package/build/dist/Models/DatabaseModels/LlmLog.js +28 -0
  185. package/build/dist/Models/DatabaseModels/LlmLog.js.map +1 -1
  186. package/build/dist/Models/DatabaseModels/LlmProvider.js +2 -2
  187. package/build/dist/Models/DatabaseModels/LlmProvider.js.map +1 -1
  188. package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
  189. package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
  190. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
  191. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  192. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
  193. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  194. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
  195. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  196. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
  197. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
  198. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
  199. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
  200. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
  201. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
  202. package/build/dist/Server/API/AIChatAPI.js +498 -0
  203. package/build/dist/Server/API/AIChatAPI.js.map +1 -0
  204. package/build/dist/Server/API/AlertAPI.js +1 -1
  205. package/build/dist/Server/API/IncidentAPI.js +2 -2
  206. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  207. package/build/dist/Server/API/LlmProviderAPI.js +108 -1
  208. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  209. package/build/dist/Server/API/MicrosoftTeamsAPI.js +4 -0
  210. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  211. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  212. package/build/dist/Server/API/SlackAPI.js +442 -0
  213. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  214. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.js +96 -0
  215. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.js.map +1 -0
  216. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.js +25 -0
  217. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.js.map +1 -0
  218. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.js +31 -0
  219. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.js.map +1 -0
  220. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.js +46 -0
  221. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.js.map +1 -0
  222. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
  223. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
  224. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
  225. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
  226. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
  227. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +16 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  232. package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
  233. package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
  234. package/build/dist/Server/Services/AIConversationMessageService.js +34 -0
  235. package/build/dist/Server/Services/AIConversationMessageService.js.map +1 -0
  236. package/build/dist/Server/Services/AIConversationService.js +42 -0
  237. package/build/dist/Server/Services/AIConversationService.js.map +1 -0
  238. package/build/dist/Server/Services/AIRunEventService.js +34 -0
  239. package/build/dist/Server/Services/AIRunEventService.js.map +1 -0
  240. package/build/dist/Server/Services/AIRunService.js +34 -0
  241. package/build/dist/Server/Services/AIRunService.js.map +1 -0
  242. package/build/dist/Server/Services/AIService.js +84 -25
  243. package/build/dist/Server/Services/AIService.js.map +1 -1
  244. package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
  245. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  246. package/build/dist/Server/Services/DatabaseService.js +20 -1
  247. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  248. package/build/dist/Server/Services/IncidentService.js +1 -1
  249. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
  250. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
  251. package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
  252. package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
  253. package/build/dist/Server/Services/Index.js +2 -0
  254. package/build/dist/Server/Services/Index.js.map +1 -1
  255. package/build/dist/Server/Services/LlmProviderService.js +108 -0
  256. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  257. package/build/dist/Server/Services/MigrationFailureService.js +9 -0
  258. package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
  259. package/build/dist/Server/Services/MonitorProbeService.js +33 -7
  260. package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
  261. package/build/dist/Server/Services/MonitorService.js +12 -5
  262. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  263. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
  264. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  265. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
  266. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  267. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
  268. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  269. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
  270. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  271. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
  272. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  273. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
  274. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  275. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
  276. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  277. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
  278. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
  279. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
  280. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  281. package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
  282. package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
  283. package/build/dist/Server/Services/ProjectService.js +25 -0
  284. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  285. package/build/dist/Server/Services/TeamMemberService.js +6 -0
  286. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  287. package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
  288. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  289. package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
  290. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  291. package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
  292. package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
  293. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +50 -7
  294. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  295. package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
  296. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  297. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js +18 -0
  298. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js.map +1 -0
  299. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
  300. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
  301. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +756 -0
  302. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -0
  303. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +165 -0
  304. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -0
  305. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +44 -0
  306. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -0
  307. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
  308. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
  309. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
  310. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
  311. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
  312. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
  313. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +167 -0
  314. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -0
  315. package/build/dist/Server/Utils/AI/Toolbox/AlertWriteTools.js +136 -0
  316. package/build/dist/Server/Utils/AI/Toolbox/AlertWriteTools.js.map +1 -0
  317. package/build/dist/Server/Utils/AI/Toolbox/ContextTools.js +141 -0
  318. package/build/dist/Server/Utils/AI/Toolbox/ContextTools.js.map +1 -0
  319. package/build/dist/Server/Utils/AI/Toolbox/ExceptionTools.js +117 -0
  320. package/build/dist/Server/Utils/AI/Toolbox/ExceptionTools.js.map +1 -0
  321. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +318 -0
  322. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -0
  323. package/build/dist/Server/Utils/AI/Toolbox/IncidentWriteTools.js +280 -0
  324. package/build/dist/Server/Utils/AI/Toolbox/IncidentWriteTools.js.map +1 -0
  325. package/build/dist/Server/Utils/AI/Toolbox/Index.js +153 -0
  326. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -0
  327. package/build/dist/Server/Utils/AI/Toolbox/LogTools.js +246 -0
  328. package/build/dist/Server/Utils/AI/Toolbox/LogTools.js.map +1 -0
  329. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +120 -0
  330. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -0
  331. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +158 -0
  332. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -0
  333. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
  334. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
  335. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +228 -0
  336. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -0
  337. package/build/dist/Server/Utils/AI/Toolbox/ToolTypes.js +142 -0
  338. package/build/dist/Server/Utils/AI/Toolbox/ToolTypes.js.map +1 -0
  339. package/build/dist/Server/Utils/AI/Toolbox/TraceTools.js +309 -0
  340. package/build/dist/Server/Utils/AI/Toolbox/TraceTools.js.map +1 -0
  341. package/build/dist/Server/Utils/AI/Toolbox/WidgetBuilder.js +120 -0
  342. package/build/dist/Server/Utils/AI/Toolbox/WidgetBuilder.js.map +1 -0
  343. package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
  344. package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
  345. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
  346. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
  347. package/build/dist/Server/Utils/LLM/LLMService.js +452 -92
  348. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  349. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
  350. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  351. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
  352. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  353. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +378 -15
  354. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  355. package/build/dist/Server/Utils/Workspace/Slack/Slack.js +59 -0
  356. package/build/dist/Server/Utils/Workspace/Slack/Slack.js.map +1 -1
  357. package/build/dist/Server/Utils/Workspace/Slack/app-manifest.json +16 -1
  358. package/build/dist/Types/AI/AIChatMessageRole.js +7 -0
  359. package/build/dist/Types/AI/AIChatMessageRole.js.map +1 -0
  360. package/build/dist/Types/AI/AIChatMessageStatus.js +27 -0
  361. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -0
  362. package/build/dist/Types/AI/AIChatPermissionMode.js +53 -0
  363. package/build/dist/Types/AI/AIChatPermissionMode.js.map +1 -0
  364. package/build/dist/Types/AI/AIChatTypes.js +74 -0
  365. package/build/dist/Types/AI/AIChatTypes.js.map +1 -0
  366. package/build/dist/Types/AI/AIRunEventType.js +18 -0
  367. package/build/dist/Types/AI/AIRunEventType.js.map +1 -0
  368. package/build/dist/Types/AI/AIRunStatus.js +26 -0
  369. package/build/dist/Types/AI/AIRunStatus.js.map +1 -0
  370. package/build/dist/Types/AI/AIRunType.js +7 -0
  371. package/build/dist/Types/AI/AIRunType.js.map +1 -0
  372. package/build/dist/Types/Date.js +153 -15
  373. package/build/dist/Types/Date.js.map +1 -1
  374. package/build/dist/Types/LLM/LlmType.js +6 -0
  375. package/build/dist/Types/LLM/LlmType.js.map +1 -1
  376. package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
  377. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  378. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
  379. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
  380. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
  381. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
  382. package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
  383. package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
  384. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +10 -5
  385. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  386. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +18 -4
  387. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  388. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  389. package/build/dist/UI/Components/Page/Page.js +3 -1
  390. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  391. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
  392. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
  393. package/build/dist/UI/Utils/LlmTypeDropdownOptions.js +38 -0
  394. package/build/dist/UI/Utils/LlmTypeDropdownOptions.js.map +1 -0
  395. package/build/dist/UI/Utils/TestLLMProvider.js +37 -0
  396. package/build/dist/UI/Utils/TestLLMProvider.js.map +1 -0
  397. package/package.json +1 -1
@@ -16,6 +16,13 @@ export interface LayerProps {
16
16
  restrictionTimes: RestrictionTimes;
17
17
  handOffTime: Date;
18
18
  rotation: Recurring;
19
+ /*
20
+ * IANA timezone (e.g. "America/New_York") the schedule's wall-clock
21
+ * restriction/handoff times are authored in. When omitted (existing
22
+ * schedules), restriction windows are reconstructed in the server's local
23
+ * time exactly as before — fully backward compatible.
24
+ */
25
+ timezone?: string | undefined;
19
26
  }
20
27
 
21
28
  export interface EventProps extends LayerProps {
@@ -34,6 +41,15 @@ export interface PriorityCalendarEvents extends CalendarEvent {
34
41
  }
35
42
 
36
43
  export default class LayerUtil {
44
+ /*
45
+ * The timezone of the layer currently being expanded. Set at the start of
46
+ * getEvents and read by the restriction-trimming helpers so wall-clock
47
+ * restriction windows resolve in the schedule's zone. undefined => local
48
+ * time (legacy behavior). getEvents runs synchronously, so this per-call
49
+ * field is not subject to interleaving.
50
+ */
51
+ private timezone: string | undefined = undefined;
52
+
37
53
  public getEvents(
38
54
  data: EventProps,
39
55
  options?:
@@ -50,6 +66,8 @@ export default class LayerUtil {
50
66
 
51
67
  data = this.sanitizeData(data);
52
68
 
69
+ this.timezone = data.timezone;
70
+
53
71
  let start: Date = data.calendarStartDate;
54
72
  const end: Date = data.calendarEndDate;
55
73
 
@@ -78,7 +96,10 @@ export default class LayerUtil {
78
96
 
79
97
  // before we do this, we need to update the user index.
80
98
 
81
- currentUserIndex = this.getCurrentUserIndexBasedOnHandoffTime({
99
+ const currentUserResolution: {
100
+ currentUserIndex: number;
101
+ currentPeriodStart: Date;
102
+ } = this.getCurrentUserIndexBasedOnHandoffTime({
82
103
  rotation,
83
104
  handOffTime,
84
105
  currentUserIndex,
@@ -87,6 +108,17 @@ export default class LayerUtil {
87
108
  currentEventStartTime,
88
109
  restrictionTimes: data.restrictionTimes,
89
110
  });
111
+ currentUserIndex = currentUserResolution.currentUserIndex;
112
+
113
+ /*
114
+ * True (un-clamped) start of the first rotation period we are about to
115
+ * expand. When the calendar window starts partway through a period (the
116
+ * live "who is on call now" path always starts its window at the current
117
+ * instant), currentEventStartTime is clamped to that instant. The advance
118
+ * guard in the loop below uses this to decide whether the first period
119
+ * consumed a rotation turn based on its FULL-span coverage (audit F2).
120
+ */
121
+ const firstPeriodTrueStart: Date = currentUserResolution.currentPeriodStart;
90
122
 
91
123
  // update handoff time to the same day as current start time
92
124
 
@@ -120,10 +152,42 @@ export default class LayerUtil {
120
152
  return events;
121
153
  }
122
154
 
123
- // break clause. This loop executes 50 times at max.
124
- const maxLoopCount: number = 100;
155
+ /*
156
+ * Bound the loop by the actual calendar window instead of a fixed count.
157
+ * Each iteration advances currentEventStartTime by at least one rotation
158
+ * period (fully-restricted periods produce no event but still advance the
159
+ * handoff), so at most ~windowUnits/periodUnits periods can fall inside
160
+ * [start, end]. A fixed cap of 100 silently truncated long windows for short
161
+ * rotations (audit F1) and could even return ZERO events — the schedule
162
+ * reporting nobody on-call and no next user — when "now" sat in a restriction
163
+ * gap longer than 100 periods (audit F8, e.g. an hourly rotation with a
164
+ * weekend-only restriction). Scale the cap to the window with a generous
165
+ * margin, keeping a hard ceiling to bound pathological inputs.
166
+ */
167
+ const rawRotationCount: number = rotation.intervalCount.toNumber();
168
+ const periodUnitsForBound: number =
169
+ Number.isFinite(rawRotationCount) && rawRotationCount >= 1
170
+ ? Math.floor(rawRotationCount)
171
+ : 1;
172
+ const windowUnits: number = this.getUnitsBetweenDates(
173
+ start,
174
+ end,
175
+ rotation.intervalType,
176
+ );
177
+ const maxLoopCount: number = Math.min(
178
+ 1000000,
179
+ Math.max(100, Math.ceil(windowUnits / periodUnitsForBound) + 10),
180
+ );
125
181
  let loopCount: number = 0;
126
182
 
183
+ /*
184
+ * The first loop iteration expands the rotation period that CONTAINS the
185
+ * window start; its currentEventStartTime may be clamped to the window
186
+ * start (now) rather than the true period start. Tracked so the rotation
187
+ * advance can be decided against the period's full span (audit F2).
188
+ */
189
+ let isFirstPeriod: boolean = true;
190
+
127
191
  while (!hasReachedTheEndOfTheCalendar) {
128
192
  loopCount++;
129
193
  if (loopCount > maxLoopCount) {
@@ -131,6 +195,9 @@ export default class LayerUtil {
131
195
  }
132
196
  currentEventEndTime = handOffTime;
133
197
 
198
+ // The rotation boundary that ends this period, before any clamp to `end`.
199
+ const periodBoundaryEnd: Date = handOffTime;
200
+
134
201
  // if current event start time and end time is the same then increase current event start time by 1 second.
135
202
 
136
203
  if (OneUptimeDate.isSame(currentEventStartTime, currentEventEndTime)) {
@@ -162,14 +229,21 @@ export default class LayerUtil {
162
229
  restrictionTimes: data.restrictionTimes,
163
230
  });
164
231
 
165
- events = [
166
- ...events,
232
+ /*
233
+ * push() instead of rebuilding the array with [...events, ...new] every
234
+ * iteration. The spread reallocated and copied the whole accumulated array
235
+ * each period — O(n^2) over the loop — which, combined with a window sized
236
+ * to a slow layer, made a fast (e.g. hourly) layer's expansion quadratic
237
+ * (audit H2). Each period contributes only a handful of segments, so the
238
+ * spread of the small per-period array as push args is safe.
239
+ */
240
+ events.push(
167
241
  ...this.getCalendarEventsFromStartAndEndDates(
168
242
  trimmedStartAndEndTimes,
169
243
  data.users,
170
244
  currentUserIndex,
171
245
  ),
172
- ];
246
+ );
173
247
 
174
248
  if (options?.getNumberOfEvents !== undefined) {
175
249
  if (events.length >= options.getNumberOfEvents) {
@@ -193,18 +267,50 @@ export default class LayerUtil {
193
267
  });
194
268
 
195
269
  /*
196
- * Only advance the rotation if at least one event was actually generated
197
- * for this rotation period. Otherwise the user "lost" their turn to a
198
- * fully restricted window (e.g. a weekend with Mon-Fri restrictions),
199
- * which would skip rotations and break ordering across the gap. See
200
- * issue #2413.
270
+ * Only advance the rotation if this rotation period actually produced
271
+ * coverage. Otherwise the user "lost" their turn to a fully restricted
272
+ * window (e.g. a weekend with Mon-Fri restrictions), which would skip
273
+ * rotations and break ordering across the gap. See issue #2413.
201
274
  */
202
- if (trimmedStartAndEndTimes.length > 0) {
275
+ let periodProducedCoverage: boolean = trimmedStartAndEndTimes.length > 0;
276
+
277
+ /*
278
+ * First-period correction (audit F2): when the window starts partway
279
+ * through the current period AND begins after that period's restriction
280
+ * window has already closed (the live roster refresh resolving in a
281
+ * daily/weekend off-hours gap), the clamped [now, periodEnd] slice trims
282
+ * to nothing even though the period DID have coverage earlier. Deciding
283
+ * the advance on that empty clamped slice carried the current user into
284
+ * the next period, so every subsequent shift resolved one user off from
285
+ * the calendar/full expansion — paging/notifying the wrong "next" user.
286
+ * Re-evaluate the advance against the period's FULL span so a
287
+ * partially-elapsed period still consumes its rotation turn, while a
288
+ * genuinely fully-restricted period (full-span trim also empty) still
289
+ * correctly skips its turn and preserves the #2413 behavior.
290
+ */
291
+ if (
292
+ isFirstPeriod &&
293
+ !periodProducedCoverage &&
294
+ data.restrictionTimes &&
295
+ data.restrictionTimes.restictionType !== RestrictionType.None
296
+ ) {
297
+ const fullSpanTrim: Array<StartAndEndTime> =
298
+ this.trimStartAndEndTimesBasedOnRestrictionTimes({
299
+ eventStartTime: firstPeriodTrueStart,
300
+ eventEndTime: periodBoundaryEnd,
301
+ restrictionTimes: data.restrictionTimes,
302
+ });
303
+ periodProducedCoverage = fullSpanTrim.length > 0;
304
+ }
305
+
306
+ if (periodProducedCoverage) {
203
307
  currentUserIndex = this.incrementUserIndex(
204
308
  currentUserIndex,
205
309
  data.users.length,
206
310
  );
207
311
  }
312
+
313
+ isFirstPeriod = false;
208
314
  }
209
315
 
210
316
  // increment ids of all the events and return them, to make sure they are unique
@@ -287,162 +393,127 @@ export default class LayerUtil {
287
393
  return data.handOffTime;
288
394
  }
289
395
 
290
- let handOffTime: Date = data.handOffTime;
291
-
292
- let intervalBetweenStartTimeAndHandoffTime: number = 0;
293
- const rotationInterval: number = data.rotation.intervalCount.toNumber();
294
-
295
- if (data.rotation.intervalType === EventInterval.Day) {
296
- intervalBetweenStartTimeAndHandoffTime =
297
- OneUptimeDate.getDaysBetweenTwoDatesInclusive(
298
- handOffTime,
299
- data.currentEventStartTime,
300
- );
301
-
302
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
303
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
304
- } else if (
305
- intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
306
- 0
307
- ) {
308
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
309
- }
310
-
311
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
312
-
313
- handOffTime = OneUptimeDate.addRemoveDays(
314
- handOffTime,
315
- intervalBetweenStartTimeAndHandoffTime,
316
- );
317
-
318
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
319
- handOffTime = OneUptimeDate.addRemoveDays(handOffTime, 1);
320
- }
321
-
322
- return handOffTime;
323
- }
324
-
325
- if (data.rotation.intervalType === EventInterval.Hour) {
326
- intervalBetweenStartTimeAndHandoffTime =
327
- OneUptimeDate.getHoursBetweenTwoDatesInclusive(
328
- handOffTime,
329
- data.currentEventStartTime,
330
- );
331
-
332
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
333
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
334
- } else if (
335
- intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
336
- 0
337
- ) {
338
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
339
- }
340
-
341
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
342
-
343
- handOffTime = OneUptimeDate.addRemoveHours(
344
- handOffTime,
345
- intervalBetweenStartTimeAndHandoffTime,
346
- );
396
+ const rawRotationInterval: number = data.rotation.intervalCount.toNumber();
397
+ /*
398
+ * Defensive clamp: an invalid interval count (0, NaN, negative) would make
399
+ * the alignment below produce an Invalid Date and spin the main getEvents
400
+ * loop. Treat any invalid value as a single unit.
401
+ */
402
+ const rotationInterval: number =
403
+ Number.isFinite(rawRotationInterval) && rawRotationInterval >= 1
404
+ ? Math.floor(rawRotationInterval)
405
+ : 1;
347
406
 
348
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
349
- handOffTime = OneUptimeDate.addRemoveHours(handOffTime, 1);
350
- }
407
+ const intervalType: EventInterval = data.rotation.intervalType;
351
408
 
352
- return handOffTime;
409
+ /*
410
+ * Rotation boundaries are exactly handOffTime + k * rotationInterval units
411
+ * (k a non-negative integer). Return the SMALLEST such boundary that is
412
+ * strictly after currentEventStartTime.
413
+ *
414
+ * We start from the floor of the whole-period distance and step UP one full
415
+ * rotation period at a time until strictly after the target. This:
416
+ * - stays on the interval grid (multiples of rotationInterval), so
417
+ * intervalCount >= 2 rotations never drift onto an off-grid boundary
418
+ * (audit HIGH-1); and
419
+ * - never OVERSHOOTS by a whole period. The previous
420
+ * ceil(getUnitsInclusive / interval) * interval formula used an
421
+ * INCLUSIVE unit count, which overshot by a full period for positions in
422
+ * the last partial period before a boundary — and a DST offset shift
423
+ * could push the inclusive count across an even/odd threshold — yielding
424
+ * a first period that spanned two rotations and resolved the wrong
425
+ * current/next on-call user for intervalCount >= 2 rotations.
426
+ * addRotationUnits carries the timezone per interval type (wall-clock across
427
+ * DST for Day/Week/Month/Year; absolute for Hour), matching the main loop.
428
+ */
429
+ const unitsBetween: number = this.getUnitsBetweenDates(
430
+ data.handOffTime,
431
+ data.currentEventStartTime,
432
+ intervalType,
433
+ );
434
+
435
+ let periods: number = Math.floor(unitsBetween / rotationInterval);
436
+ if (!Number.isFinite(periods) || periods < 0) {
437
+ periods = 0;
353
438
  }
354
439
 
355
- if (data.rotation.intervalType === EventInterval.Week) {
356
- intervalBetweenStartTimeAndHandoffTime =
357
- OneUptimeDate.getWeeksBetweenTwoDatesInclusive(
358
- handOffTime,
359
- data.currentEventStartTime,
360
- );
361
-
362
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
363
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
364
- } else if (
365
- intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
366
- 0
367
- ) {
368
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
369
- }
370
-
371
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
372
-
373
- handOffTime = OneUptimeDate.addRemoveWeeks(
440
+ /*
441
+ * Compute the boundary `periods` rotation periods after the anchor. For
442
+ * Month/Year this ITERATES one period at a time (see addRotationPeriods),
443
+ * because moment end-of-month-clamps a single multiplied add differently
444
+ * than stepping period-by-period — e.g. Jan-31 + 11 months multiplied =
445
+ * Dec-31, but eleven iterated +1-month steps = Dec-28. countElapsedRotation
446
+ * Periods and the main getEvents loop both walk the ITERATED grid, so a
447
+ * multiplied step here produced handoff boundaries off that grid and paged
448
+ * the wrong on-call user for Month/Year rotations anchored on day 29-31 (or
449
+ * Feb-29 for Year) — audit H1.
450
+ */
451
+ let handOffTime: Date = this.addRotationPeriods(
452
+ data.handOffTime,
453
+ periods,
454
+ rotationInterval,
455
+ intervalType,
456
+ );
457
+
458
+ let safety: number = 0;
459
+ while (
460
+ OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime) &&
461
+ safety < 1000000
462
+ ) {
463
+ periods++;
464
+ /*
465
+ * Step exactly ONE rotation period from the PREVIOUS boundary (not a fresh
466
+ * multiplied add from the anchor), so Month/Year stay on the same iterated,
467
+ * calendar-clamped grid as the initial jump above.
468
+ */
469
+ handOffTime = this.addRotationUnits(
374
470
  handOffTime,
375
- intervalBetweenStartTimeAndHandoffTime,
471
+ rotationInterval,
472
+ intervalType,
376
473
  );
377
-
378
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
379
- handOffTime = OneUptimeDate.addRemoveWeeks(handOffTime, 1);
380
- }
381
-
382
- return handOffTime;
474
+ safety++;
383
475
  }
384
476
 
385
- if (data.rotation.intervalType === EventInterval.Month) {
386
- intervalBetweenStartTimeAndHandoffTime =
387
- OneUptimeDate.getMonthsBetweenTwoDatesInclusive(
388
- handOffTime,
389
- data.currentEventStartTime,
390
- );
391
-
392
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
393
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
394
- } else if (
395
- intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
396
- 0
397
- ) {
398
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
399
- }
400
-
401
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
402
-
403
- handOffTime = OneUptimeDate.addRemoveMonths(
404
- handOffTime,
405
- intervalBetweenStartTimeAndHandoffTime,
406
- );
407
-
408
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
409
- handOffTime = OneUptimeDate.addRemoveMonths(handOffTime, 1);
410
- }
477
+ return handOffTime;
478
+ }
411
479
 
412
- return handOffTime;
480
+ /*
481
+ * Advance `anchor` by `numberOfPeriods` rotation periods (each period =
482
+ * rotationInterval units of intervalType). Hour/Day/Week have no calendar-
483
+ * length clamping, so a single multiplied add is exact and O(1). Month/Year
484
+ * DO clamp (moment: Jan-31 + 1mo = Feb-28, then Feb-28 + 1mo = Mar-28), so a
485
+ * multiplied add does NOT equal iterating; we step one period at a time to
486
+ * stay on the same grid the rest of the engine walks (audit H1). The period
487
+ * count for Month/Year is small even over decades, so iterating is cheap.
488
+ */
489
+ private addRotationPeriods(
490
+ anchor: Date,
491
+ numberOfPeriods: number,
492
+ rotationInterval: number,
493
+ intervalType: EventInterval,
494
+ ): Date {
495
+ if (numberOfPeriods <= 0) {
496
+ return anchor;
413
497
  }
414
498
 
415
- if (data.rotation.intervalType === EventInterval.Year) {
416
- intervalBetweenStartTimeAndHandoffTime =
417
- OneUptimeDate.getYearsBetweenTwoDatesInclusive(
418
- handOffTime,
419
- data.currentEventStartTime,
420
- );
421
-
422
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
423
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
424
- } else if (
425
- intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
426
- 0
427
- ) {
428
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
429
- }
430
-
431
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
432
-
433
- handOffTime = OneUptimeDate.addRemoveYears(
434
- handOffTime,
435
- intervalBetweenStartTimeAndHandoffTime,
436
- );
437
-
438
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
439
- handOffTime = OneUptimeDate.addRemoveYears(handOffTime, 1);
499
+ if (
500
+ intervalType === EventInterval.Month ||
501
+ intervalType === EventInterval.Year
502
+ ) {
503
+ let result: Date = anchor;
504
+ let safety: number = 0;
505
+ while (safety < numberOfPeriods && safety < 1000000) {
506
+ result = this.addRotationUnits(result, rotationInterval, intervalType);
507
+ safety++;
440
508
  }
441
-
442
- return handOffTime;
509
+ return result;
443
510
  }
444
511
 
445
- return handOffTime;
512
+ return this.addRotationUnits(
513
+ anchor,
514
+ numberOfPeriods * rotationInterval,
515
+ intervalType,
516
+ );
446
517
  }
447
518
 
448
519
  private getCurrentUserIndexBasedOnHandoffTime(data: {
@@ -453,7 +524,16 @@ export default class LayerUtil {
453
524
  users: Array<UserModel>;
454
525
  currentEventStartTime: Date;
455
526
  restrictionTimes: RestrictionTimes;
456
- }): number {
527
+ }): { currentUserIndex: number; currentPeriodStart: Date } {
528
+ /*
529
+ * Returns both the on-call user index for the rotation period that CONTAINS
530
+ * currentEventStartTime AND the true (un-clamped) start of that period.
531
+ * getEvents needs the true period start so it can decide, for the first
532
+ * (possibly clamped) period, whether that period consumed a rotation turn
533
+ * based on its FULL-span restriction coverage rather than the coverage in
534
+ * the clamped [now, periodEnd] slice (see the first-period advance guard in
535
+ * getEvents — audit F2).
536
+ */
457
537
  let currentUserIndex: number = data.currentUserIndex;
458
538
 
459
539
  // if current event start time is before layer start, idx unchanged.
@@ -463,12 +543,63 @@ export default class LayerUtil {
463
543
  data.startDateTimeOfLayer,
464
544
  )
465
545
  ) {
466
- return currentUserIndex;
546
+ return {
547
+ currentUserIndex,
548
+ currentPeriodStart: data.currentEventStartTime,
549
+ };
467
550
  }
468
551
 
469
552
  // if handoff is after current start, no rotation has occurred yet — idx unchanged.
470
553
  if (OneUptimeDate.isAfter(data.handOffTime, data.currentEventStartTime)) {
471
- return currentUserIndex;
554
+ /*
555
+ * No handoff has happened yet, so we are still inside the very first
556
+ * rotation period, which starts at the layer start.
557
+ */
558
+ return {
559
+ currentUserIndex,
560
+ currentPeriodStart: data.startDateTimeOfLayer,
561
+ };
562
+ }
563
+
564
+ /*
565
+ * Fast path: with no restriction, every rotation period produces exactly
566
+ * one event, so the current user index is simply the initial index plus the
567
+ * number of whole rotation periods elapsed since the first handoff. We
568
+ * compute this analytically in O(1) instead of simulating one iteration per
569
+ * period. The simulation below capped at 10000 iterations and returned the
570
+ * WRONG user for long-lived schedules (e.g. an hourly rotation older than
571
+ * ~14 months). Restricted layers still use the period-by-period simulation
572
+ * because fully-restricted periods must not advance the rotation.
573
+ */
574
+ if (
575
+ data.restrictionTimes &&
576
+ data.restrictionTimes.restictionType === RestrictionType.None &&
577
+ data.users.length > 0
578
+ ) {
579
+ const firstBoundary: Date =
580
+ this.moveHandsOffTimeAfterCurrentEventStartTime({
581
+ handOffTime: data.handOffTime,
582
+ currentEventStartTime: data.startDateTimeOfLayer,
583
+ rotation: data.rotation,
584
+ });
585
+
586
+ const periodsElapsed: number = this.countElapsedRotationPeriods(
587
+ firstBoundary,
588
+ data.currentEventStartTime,
589
+ data.rotation,
590
+ );
591
+
592
+ const length: number = data.users.length;
593
+ /*
594
+ * Unrestricted layers never have coverage gaps, so getEvents never needs
595
+ * the full-span first-period fallback for them; currentPeriodStart is
596
+ * returned for interface symmetry only and is not read on this path.
597
+ */
598
+ return {
599
+ currentUserIndex:
600
+ (((currentUserIndex + periodsElapsed) % length) + length) % length,
601
+ currentPeriodStart: data.currentEventStartTime,
602
+ };
472
603
  }
473
604
 
474
605
  /*
@@ -487,12 +618,30 @@ export default class LayerUtil {
487
618
  });
488
619
 
489
620
  /*
490
- * Generous safety bound: 10000 covers ~27 years of daily rotation or
491
- * ~14 months of hourly rotation. The loop normally exits via the
492
- * isBefore check; this cap only fires for pathologically long-running
493
- * schedules to keep the function bounded.
621
+ * Bound the simulation by the actual number of rotation periods between the
622
+ * layer start and the target, so the cap is always large enough to REACH the
623
+ * target for any realistic schedule age. A fixed 10000 cap (~14 months of
624
+ * hourly rotation) stopped early for long-lived sub-daily restricted
625
+ * schedules and returned the index at iteration 10000 instead of the index at
626
+ * "now" — paging the wrong current user (audit F9). We keep a very high
627
+ * ceiling to still bound pathological inputs. Restricted periods must be
628
+ * simulated one at a time (they must not advance the rotation), so this stays
629
+ * O(elapsed periods); for realistic ages that is small.
494
630
  */
495
- const maxIterations: number = 10000;
631
+ const rawSimCount: number = data.rotation.intervalCount.toNumber();
632
+ const simPeriodUnits: number =
633
+ Number.isFinite(rawSimCount) && rawSimCount >= 1
634
+ ? Math.floor(rawSimCount)
635
+ : 1;
636
+ const simUnitsBetween: number = this.getUnitsBetweenDates(
637
+ data.startDateTimeOfLayer,
638
+ data.currentEventStartTime,
639
+ data.rotation.intervalType,
640
+ );
641
+ const maxIterations: number = Math.min(
642
+ 5000000,
643
+ Math.max(10000, Math.ceil(simUnitsBetween / simPeriodUnits) + 10),
644
+ );
496
645
  let iterations: number = 0;
497
646
 
498
647
  while (
@@ -530,7 +679,147 @@ export default class LayerUtil {
530
679
  });
531
680
  }
532
681
 
533
- return currentUserIndex;
682
+ /*
683
+ * simulatedTime is now the true (un-clamped) start of the rotation period
684
+ * that contains data.currentEventStartTime — the loop advances it to the
685
+ * next period start each covered iteration and breaks once a period would
686
+ * extend past the target, so it holds the current period's real start.
687
+ */
688
+ return { currentUserIndex, currentPeriodStart: simulatedTime };
689
+ }
690
+
691
+ /*
692
+ * Count the number of rotation boundaries that fall on-or-before `target`,
693
+ * starting from `firstBoundary` and stepping by one rotation period. This is
694
+ * the number of whole rotation periods elapsed, used for the O(1) unrestricted
695
+ * current-user computation. Uses calendar-aware unit stepping so it stays
696
+ * correct for Month/Year (variable length) and across DST for Day/Week.
697
+ */
698
+ private countElapsedRotationPeriods(
699
+ firstBoundary: Date,
700
+ target: Date,
701
+ rotation: Recurring,
702
+ ): number {
703
+ if (OneUptimeDate.isAfter(firstBoundary, target)) {
704
+ return 0;
705
+ }
706
+
707
+ const intervalType: EventInterval = rotation.intervalType;
708
+ const rawCount: number = rotation.intervalCount.toNumber();
709
+ const periodUnits: number =
710
+ Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1;
711
+
712
+ /*
713
+ * Month and Year have variable calendar length (moment clamps end-of-month:
714
+ * Jan 31 + 1mo = Feb 29, and Feb 29 + 1mo = Mar 29). Because of that,
715
+ * boundary_k computed as a SINGLE multiplied step (anchor + k*units) does
716
+ * NOT equal advancing one period at a time — which is exactly how the real
717
+ * rotation in getEvents (via moveHandsOffTimeAfterCurrentEventStartTime)
718
+ * steps. Using the multiplied form here under-counted elapsed periods by one
719
+ * at month-end anchors and paged the previous on-call user (audit F0). So we
720
+ * iterate one clamped period at a time for Month/Year. The boundary count
721
+ * stays small even over decades of monthly/yearly rotation, so this is cheap.
722
+ */
723
+ if (
724
+ intervalType === EventInterval.Month ||
725
+ intervalType === EventInterval.Year
726
+ ) {
727
+ let periods: number = 0;
728
+ let boundary: Date = firstBoundary;
729
+ let safety: number = 0;
730
+ while (OneUptimeDate.isOnOrBefore(boundary, target) && safety < 100000) {
731
+ periods++;
732
+ // step from the PREVIOUS boundary, mirroring the main-loop rotation.
733
+ boundary = this.addRotationUnits(boundary, periodUnits, intervalType);
734
+ safety++;
735
+ }
736
+ return periods;
737
+ }
738
+
739
+ /*
740
+ * Hour/Day/Week have no calendar-length clamping, so the O(1) analytic count
741
+ * (a multiplied step) is exact and equals iterating.
742
+ */
743
+ const unitsBetween: number = this.getUnitsBetweenDates(
744
+ firstBoundary,
745
+ target,
746
+ intervalType,
747
+ );
748
+
749
+ let periods: number = Math.floor(unitsBetween / periodUnits);
750
+ if (periods < 0) {
751
+ periods = 0;
752
+ }
753
+
754
+ /*
755
+ * `periods` is a lower bound (unit diffs truncate toward zero). Advance
756
+ * until firstBoundary + periods*periodUnits is strictly after target; the
757
+ * resulting count equals the number of boundaries on-or-before target.
758
+ */
759
+ let safety: number = 0;
760
+ while (
761
+ OneUptimeDate.isOnOrBefore(
762
+ this.addRotationUnits(
763
+ firstBoundary,
764
+ periods * periodUnits,
765
+ intervalType,
766
+ ),
767
+ target,
768
+ ) &&
769
+ safety < 100000
770
+ ) {
771
+ periods++;
772
+ safety++;
773
+ }
774
+
775
+ return periods;
776
+ }
777
+
778
+ private addRotationUnits(
779
+ date: Date,
780
+ units: number,
781
+ intervalType: EventInterval,
782
+ ): Date {
783
+ /*
784
+ * Day/Week/Month/Year preserve schedule wall-clock across DST (consistent
785
+ * with moveHandsOffTimeAfterCurrentEventStartTime); Hour is absolute.
786
+ */
787
+ const tz: string | undefined = this.timezone;
788
+ switch (intervalType) {
789
+ case EventInterval.Hour:
790
+ return OneUptimeDate.addRemoveHours(date, units);
791
+ case EventInterval.Day:
792
+ return OneUptimeDate.addRemoveDays(date, units, tz);
793
+ case EventInterval.Week:
794
+ return OneUptimeDate.addRemoveWeeks(date, units, tz);
795
+ case EventInterval.Month:
796
+ return OneUptimeDate.addRemoveMonths(date, units, tz);
797
+ case EventInterval.Year:
798
+ return OneUptimeDate.addRemoveYears(date, units, tz);
799
+ default:
800
+ return OneUptimeDate.addRemoveDays(date, units, tz);
801
+ }
802
+ }
803
+
804
+ private getUnitsBetweenDates(
805
+ from: Date,
806
+ to: Date,
807
+ intervalType: EventInterval,
808
+ ): number {
809
+ switch (intervalType) {
810
+ case EventInterval.Hour:
811
+ return OneUptimeDate.getHoursBetweenTwoDates(from, to);
812
+ case EventInterval.Day:
813
+ return OneUptimeDate.getDaysBetweenTwoDates(from, to);
814
+ case EventInterval.Week:
815
+ return OneUptimeDate.getWeeksBetweenTwoDates(from, to);
816
+ case EventInterval.Month:
817
+ return OneUptimeDate.getMonthsBetweenTwoDates(from, to);
818
+ case EventInterval.Year:
819
+ return OneUptimeDate.getYearsBetweenTwoDates(from, to);
820
+ default:
821
+ return OneUptimeDate.getDaysBetweenTwoDates(from, to);
822
+ }
534
823
  }
535
824
 
536
825
  public trimStartAndEndTimesBasedOnRestrictionTimes(data: {
@@ -553,23 +842,32 @@ export default class LayerUtil {
553
842
  restrictionTimes.restictionType === RestrictionType.Daily &&
554
843
  restrictionTimes.dayRestrictionTimes
555
844
  ) {
556
- // before this we need to make sure restrciton times are moved to the day of the event.
557
- restrictionTimes.dayRestrictionTimes.startTime =
558
- OneUptimeDate.keepTimeButMoveDay(
845
+ /*
846
+ * Move the restriction window to the event's day WITHOUT mutating the
847
+ * shared RestrictionTimes object. The previous code wrote the moved
848
+ * start/end back into restrictionTimes.dayRestrictionTimes, corrupting the
849
+ * caller's object across events/layers/calls and making resolution
850
+ * order-dependent (a hygiene defect flagged in the audit). keepTimeButMoveDay
851
+ * preserves the time-of-day regardless of the base day, so working on a
852
+ * local copy produces identical windows with no shared-state side effects.
853
+ */
854
+ const movedDayRestriction: StartAndEndTime = {
855
+ startTime: OneUptimeDate.keepTimeButMoveDay(
559
856
  restrictionTimes.dayRestrictionTimes.startTime,
560
857
  data.eventStartTime,
561
- );
562
-
563
- restrictionTimes.dayRestrictionTimes.endTime =
564
- OneUptimeDate.keepTimeButMoveDay(
858
+ this.timezone,
859
+ ),
860
+ endTime: OneUptimeDate.keepTimeButMoveDay(
565
861
  restrictionTimes.dayRestrictionTimes.endTime,
566
862
  data.eventStartTime,
567
- );
863
+ this.timezone,
864
+ ),
865
+ };
568
866
 
569
867
  return this.getEventsByDailyRestriction({
570
868
  eventStartTime: data.eventStartTime,
571
869
  eventEndTime: data.eventEndTime,
572
- restrictionStartAndEndTime: restrictionTimes.dayRestrictionTimes,
870
+ restrictionStartAndEndTime: movedDayRestriction,
573
871
  props: {
574
872
  intervalType: EventInterval.Day,
575
873
  },
@@ -624,7 +922,58 @@ export default class LayerUtil {
624
922
  ];
625
923
  }
626
924
 
627
- return trimmedStartAndEndTimes;
925
+ /*
926
+ * Collapse overlapping/touching segments. The wrap-around split in
927
+ * getWeeklyRestrictionTimesForWeek emits a head segment (early-week tail)
928
+ * plus a main segment, and getEventsByDailyRestriction tiles each weekly
929
+ * across the event window. For a rotation event spanning more than one ISO
930
+ * week, week k's main segment already covers the Sunday->Monday that week
931
+ * (k+1)'s head segment re-covers, producing duplicate/overlapping events for
932
+ * the same user (audit F3). Merging contiguous coverage is always safe here
933
+ * because every segment belongs to the same layer/user.
934
+ */
935
+ return this.mergeOverlappingStartAndEndTimes(trimmedStartAndEndTimes);
936
+ }
937
+
938
+ private mergeOverlappingStartAndEndTimes(
939
+ times: Array<StartAndEndTime>,
940
+ ): Array<StartAndEndTime> {
941
+ if (times.length <= 1) {
942
+ return times;
943
+ }
944
+
945
+ const sorted: Array<StartAndEndTime> = [...times].sort(
946
+ (a: StartAndEndTime, b: StartAndEndTime) => {
947
+ if (OneUptimeDate.isBefore(a.startTime, b.startTime)) {
948
+ return -1;
949
+ }
950
+ if (OneUptimeDate.isAfter(a.startTime, b.startTime)) {
951
+ return 1;
952
+ }
953
+ return 0;
954
+ },
955
+ );
956
+
957
+ const merged: Array<StartAndEndTime> = [];
958
+
959
+ for (const current of sorted) {
960
+ const last: StartAndEndTime | undefined = merged[merged.length - 1];
961
+
962
+ // overlapping or directly touching the previous window -> extend it.
963
+ if (last && OneUptimeDate.isOnOrAfter(last.endTime, current.startTime)) {
964
+ if (OneUptimeDate.isAfter(current.endTime, last.endTime)) {
965
+ last.endTime = current.endTime;
966
+ }
967
+ continue;
968
+ }
969
+
970
+ merged.push({
971
+ startTime: current.startTime,
972
+ endTime: current.endTime,
973
+ });
974
+ }
975
+
976
+ return merged;
628
977
  }
629
978
 
630
979
  public getWeeklyRestrictionTimesForWeek(data: {
@@ -650,13 +999,15 @@ export default class LayerUtil {
650
999
  startTime = OneUptimeDate.moveDateToTheDayOfWeek(
651
1000
  startTime,
652
1001
  eventStartTime,
653
- OneUptimeDate.getDayOfWeek(startTime),
1002
+ OneUptimeDate.getDayOfWeek(startTime, this.timezone),
1003
+ this.timezone,
654
1004
  );
655
1005
 
656
1006
  endTime = OneUptimeDate.moveDateToTheDayOfWeek(
657
1007
  endTime,
658
1008
  eventStartTime,
659
- OneUptimeDate.getDayOfWeek(endTime),
1009
+ OneUptimeDate.getDayOfWeek(endTime, this.timezone),
1010
+ this.timezone,
660
1011
  );
661
1012
 
662
1013
  // now we have true start and end times of the weekly restriction
@@ -670,25 +1021,72 @@ export default class LayerUtil {
670
1021
  * and the other for end of the week .
671
1022
  */
672
1023
 
673
- const startOfWeek: Date = data.eventStartTime;
674
- // add 7 days to the end time to get the end of the week
675
- const endOfTheWeek: Date = OneUptimeDate.addRemoveDays(startOfWeek, 7);
1024
+ /*
1025
+ * Anchor the split to the START OF THE ISO WEEK that contains the event,
1026
+ * NOT to data.eventStartTime. When resolution begins mid-week (the live
1027
+ * "who is on call now" path always starts its window at the current
1028
+ * instant), using eventStartTime made the head segment
1029
+ * [eventStartTime, endTime] inverted (start > end) whenever "now" was
1030
+ * already past the window's end-day. getEventsByDailyRestriction then
1031
+ * mis-read that inverted segment as an overnight window and sprayed
1032
+ * phantom all-day on-call coverage across every day of the week, paging
1033
+ * the wrong user during hours the restriction excludes. Using the real
1034
+ * week start keeps the head segment correctly ordered; the later
1035
+ * intersection with the event window discards any portion that has
1036
+ * already elapsed.
1037
+ */
1038
+ const startOfWeek: Date = OneUptimeDate.getStartOfTheWeek(
1039
+ data.eventStartTime,
1040
+ this.timezone, // anchor to the schedule zone's week boundary (audit F6)
1041
+ );
676
1042
 
1043
+ /*
1044
+ * Head segment: the early-week tail (week start -> endTime) of a weekend
1045
+ * window that opened the PREVIOUS period. This is what covers an
1046
+ * in-progress wrap-around window when resolution starts mid-weekend
1047
+ * (e.g. resolving on the Sunday of a Fri 20:00 -> Mon 08:00 window).
1048
+ */
677
1049
  startAndEndTimesOfWeeklyRestrictions.push({
678
1050
  startTime: startOfWeek,
679
1051
  endTime: endTime,
680
1052
  });
681
1053
 
1054
+ /*
1055
+ * Main segment: the contiguous window from startTime (this week) through
1056
+ * endTime moved to the NEXT week. Because this is a wrap-around,
1057
+ * startTime is later in the week than endTime, so endTime + 7 days is the
1058
+ * window's true close (e.g. Fri 20:00 -> the following Mon 08:00).
1059
+ * Expressing it as one forward window — rather than clipping to the end
1060
+ * of THIS ISO week — lets getEventsByDailyRestriction tile it weekly
1061
+ * across a multi-week rotation event without leaving the Sunday/Monday
1062
+ * portion of the weekend uncovered.
1063
+ */
682
1064
  startAndEndTimesOfWeeklyRestrictions.push({
683
1065
  startTime: startTime,
684
- endTime: endOfTheWeek,
1066
+ /*
1067
+ * Forward the schedule timezone so this +7-day step is a wall-clock
1068
+ * week in the schedule's zone, consistent with every sibling
1069
+ * day-step in the weekly tiling path (audit F8). Without it, when the
1070
+ * server zone differs from the schedule zone across a DST transition,
1071
+ * the wrap-around window's close drifted by the DST offset and that
1072
+ * drift then propagated to every subsequent weekend of the expansion.
1073
+ */
1074
+ endTime: OneUptimeDate.addRemoveDays(endTime, 7, this.timezone),
1075
+ });
1076
+ } else {
1077
+ /*
1078
+ * Non-wrapping restriction: emit the single window. This is gated in an
1079
+ * `else` because the wrap-around case above is already fully described
1080
+ * by the two split segments; previously this raw push ran
1081
+ * unconditionally, adding a third INVERTED (start > end) segment that
1082
+ * getEventsByDailyRestriction then re-expanded into phantom nightly
1083
+ * on-call windows on every day of the week.
1084
+ */
1085
+ startAndEndTimesOfWeeklyRestrictions.push({
1086
+ startTime,
1087
+ endTime,
685
1088
  });
686
1089
  }
687
-
688
- startAndEndTimesOfWeeklyRestrictions.push({
689
- startTime,
690
- endTime,
691
- });
692
1090
  }
693
1091
 
694
1092
  return startAndEndTimesOfWeeklyRestrictions;
@@ -731,13 +1129,41 @@ export default class LayerUtil {
731
1129
  if (OneUptimeDate.isBefore(restrictionEndTime, restrictionStartTime)) {
732
1130
  const results: Array<StartAndEndTime> = [];
733
1131
 
734
- // We'll iterate day-by-day within the event range (max 31 iterations safeguard)
735
- let currentDayStart: Date = OneUptimeDate.getStartOfDay(
736
- data.eventStartTime,
1132
+ /*
1133
+ * Iterate day-by-day within the event range. We start ONE day BEFORE the
1134
+ * event's start day so the "morning" tail of the window that opened the
1135
+ * previous night (e.g. a 22:00 -> 06:00 window covering 00:00 -> 06:00 on
1136
+ * the event's own first day) is emitted. Previously the loop started at
1137
+ * getStartOfDay(eventStart) and only ever tied the morning segment to the
1138
+ * NEXT day, so a rotation event beginning at midnight lost its first-day
1139
+ * morning coverage entirely, leaving a nightly gap where nobody was on
1140
+ * call. The addIntersection clip to [eventStart, eventEnd] discards any
1141
+ * segment of the extra leading day that falls outside the event.
1142
+ */
1143
+ let currentDayStart: Date = OneUptimeDate.addRemoveDays(
1144
+ OneUptimeDate.getStartOfDay(data.eventStartTime, this.timezone),
1145
+ -1,
1146
+ this.timezone, // step wall-clock days in the schedule zone (audit L1)
737
1147
  );
738
1148
  const absoluteEventEnd: Date = data.eventEndTime;
739
1149
  let safetyCounter: number = 0;
740
- const maxDays: number = 62; // generous safeguard
1150
+ /*
1151
+ * Scale the day-by-day safeguard to the actual event span. A fixed 62-day
1152
+ * cap dropped every night past ~day 62 for rotation events longer than
1153
+ * that (e.g. a quarterly/annual rotation with an overnight restriction),
1154
+ * leaving those nights with no on-call coverage (audit F2). Bound to the
1155
+ * event length plus margin, with a hard ceiling for pathological inputs.
1156
+ */
1157
+ const maxDays: number = Math.min(
1158
+ 4000,
1159
+ Math.max(
1160
+ 62,
1161
+ OneUptimeDate.getDaysBetweenTwoDates(
1162
+ data.eventStartTime,
1163
+ data.eventEndTime,
1164
+ ) + 3,
1165
+ ),
1166
+ );
741
1167
 
742
1168
  while (
743
1169
  OneUptimeDate.isOnOrBefore(currentDayStart, absoluteEventEnd) &&
@@ -748,19 +1174,26 @@ export default class LayerUtil {
748
1174
  const segmentNightStart: Date = OneUptimeDate.keepTimeButMoveDay(
749
1175
  restrictionStartTime,
750
1176
  currentDayStart,
1177
+ this.timezone,
1178
+ );
1179
+ const segmentNightEnd: Date = OneUptimeDate.getEndOfDay(
1180
+ segmentNightStart,
1181
+ this.timezone,
751
1182
  );
752
- const segmentNightEnd: Date =
753
- OneUptimeDate.getEndOfDay(segmentNightStart);
754
1183
 
755
1184
  const nextDayStart: Date = OneUptimeDate.addRemoveDays(
756
1185
  currentDayStart,
757
1186
  1,
1187
+ this.timezone, // wall-clock day step; avoids revisiting a day across fall-back DST (audit L1)
1188
+ );
1189
+ const segmentMorningStart: Date = OneUptimeDate.getStartOfDay(
1190
+ nextDayStart,
1191
+ this.timezone,
758
1192
  );
759
- const segmentMorningStart: Date =
760
- OneUptimeDate.getStartOfDay(nextDayStart);
761
1193
  const segmentMorningEnd: Date = OneUptimeDate.keepTimeButMoveDay(
762
1194
  restrictionEndTime,
763
1195
  nextDayStart,
1196
+ this.timezone,
764
1197
  );
765
1198
 
766
1199
  // helper to add intersection if it overlaps the event window
@@ -803,9 +1236,23 @@ export default class LayerUtil {
803
1236
 
804
1237
  let reachedTheEndOfTheCurrentEvent: boolean = false;
805
1238
 
806
- // create a break clause. This loop executes 100 times at max.
807
-
808
- const maxLoopCount: number = 50;
1239
+ /*
1240
+ * Scale the break clause to the event span. The loop advances one restriction
1241
+ * period (1 day for a Daily restriction, 7 days for a Weekly one) per
1242
+ * iteration, so a single rotation event longer than ~50 days had its later
1243
+ * days silently dropped, leaving no on-call coverage (audit F2). Days-in-event
1244
+ * is a safe upper bound for both the daily (+1/day) and weekly (+7/day) paths.
1245
+ */
1246
+ const maxLoopCount: number = Math.min(
1247
+ 4000,
1248
+ Math.max(
1249
+ 50,
1250
+ OneUptimeDate.getDaysBetweenTwoDates(
1251
+ data.eventStartTime,
1252
+ data.eventEndTime,
1253
+ ) + 10,
1254
+ ),
1255
+ );
809
1256
  let loopCount: number = 0;
810
1257
 
811
1258
  while (!reachedTheEndOfTheCurrentEvent) {
@@ -826,10 +1273,29 @@ export default class LayerUtil {
826
1273
  return trimmedStartAndEndTimes;
827
1274
  }
828
1275
 
829
- // if current event start time is after the restriction end time then we need to return empty array as there is no event.
830
-
1276
+ /*
1277
+ * The event begins after THIS day's restriction window has already ended.
1278
+ * Do NOT drop the whole event — a multi-day rotation event (e.g. a WEEKLY
1279
+ * rotation whose handoff/start is at 20:00, with a 09:00-17:00 daily
1280
+ * restriction) must still be covered on its subsequent days. Advance the
1281
+ * restriction window to the next day/week and re-test instead of returning
1282
+ * empty. Termination is preserved: after at most one advance the window's
1283
+ * end moves past currentStartTime, and the "restrictionStart past
1284
+ * currentEnd" guard above returns once the window moves past the event end
1285
+ * (so a short event entirely after the window still yields no coverage).
1286
+ */
831
1287
  if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionEndTime)) {
832
- return trimmedStartAndEndTimes;
1288
+ restrictionStartTime = OneUptimeDate.addRemoveDays(
1289
+ restrictionStartTime,
1290
+ data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1291
+ this.timezone,
1292
+ );
1293
+ restrictionEndTime = OneUptimeDate.addRemoveDays(
1294
+ restrictionEndTime,
1295
+ data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1296
+ this.timezone,
1297
+ );
1298
+ continue;
833
1299
  }
834
1300
 
835
1301
  // if the restriction end time is before the restriction start time, we need to add one day to the restriction end time
@@ -840,8 +1306,16 @@ export default class LayerUtil {
840
1306
  );
841
1307
  }
842
1308
 
843
- // 1 - if the current event falls within the restriction times, we need to return the current event.
1309
+ /*
1310
+ * The four cases below are mutually exclusive for a given iteration and
1311
+ * are expressed as an if / else-if chain. This matters because cases 2 and
1312
+ * 4 MUTATE currentStartTime / restrictionStartTime / restrictionEndTime and
1313
+ * then continue the loop; without else-if, a later case would re-evaluate
1314
+ * against the freshly-mutated state within the same iteration and emit a
1315
+ * duplicate (or overlapping) window.
1316
+ */
844
1317
 
1318
+ // 1 - the event falls entirely within the restriction window: emit it and finish.
845
1319
  if (
846
1320
  OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
847
1321
  OneUptimeDate.isOnOrAfter(restrictionEndTime, currentEndTime)
@@ -851,14 +1325,18 @@ export default class LayerUtil {
851
1325
  endTime: currentEndTime,
852
1326
  });
853
1327
  reachedTheEndOfTheCurrentEvent = true;
854
- }
855
-
856
- /*
857
- * 2 - Start Restriction: If the current event starts after the restriction start time and ends after the restriction end time, we need to return the current event with the start time of the current event and end time of the restriction
858
- * Use strict isAfter on the end so this branch does not double-fire with case 1 when currentEnd === restrictionEnd.
859
- */
860
-
861
- if (
1328
+ } else if (
1329
+ /*
1330
+ * 2 - Start Restriction: the event starts inside the restriction window
1331
+ * but extends past its end. Emit [currentStart, restrictionEnd], then
1332
+ * ADVANCE to the next restriction day/week and continue, so every
1333
+ * remaining day of a multi-day rotation event is emitted. Previously this
1334
+ * terminated the loop after the first day, dropping on-call coverage for
1335
+ * every subsequent day of the rotation period (e.g. a weekly rotation
1336
+ * with a 09:00-17:00 daily restriction and a handoff at/after 09:00
1337
+ * covered only day 1). This now mirrors case 4's advance-and-continue.
1338
+ * Strict isAfter on the end keeps this exclusive from case 1.
1339
+ */
862
1340
  OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
863
1341
  OneUptimeDate.isAfter(currentEndTime, restrictionEndTime)
864
1342
  ) {
@@ -866,12 +1344,24 @@ export default class LayerUtil {
866
1344
  startTime: currentStartTime,
867
1345
  endTime: restrictionEndTime,
868
1346
  });
869
- reachedTheEndOfTheCurrentEvent = true;
870
- }
871
1347
 
872
- // 3 - End Restriction - If the current event starts before the restriction start time and ends before the restriction end time, we need to return the current event with the start time of the restriction and end time of the current event.
1348
+ currentStartTime = OneUptimeDate.addRemoveSeconds(
1349
+ restrictionEndTime,
1350
+ 1,
1351
+ );
873
1352
 
874
- if (
1353
+ restrictionStartTime = OneUptimeDate.addRemoveDays(
1354
+ restrictionStartTime,
1355
+ data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1356
+ this.timezone, // preserve wall-clock across DST (audit F5)
1357
+ );
1358
+ restrictionEndTime = OneUptimeDate.addRemoveDays(
1359
+ restrictionEndTime,
1360
+ data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1361
+ this.timezone, // preserve wall-clock across DST (audit F5)
1362
+ );
1363
+ } else if (
1364
+ // 3 - End Restriction - the event starts before the window and ends inside it.
875
1365
  OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
876
1366
  OneUptimeDate.isBefore(currentEndTime, restrictionEndTime) &&
877
1367
  OneUptimeDate.isAfter(currentEndTime, restrictionStartTime)
@@ -881,11 +1371,8 @@ export default class LayerUtil {
881
1371
  endTime: currentEndTime,
882
1372
  });
883
1373
  reachedTheEndOfTheCurrentEvent = true;
884
- }
885
-
886
- // 4 - If the current event starts before the restriction start time and ends after the restriction end time, we need to return the current event with the start time of the restriction and end time of the restriction.
887
-
888
- if (
1374
+ } else if (
1375
+ // 4 - the event spans the whole window: emit it, advance a day/week, continue.
889
1376
  OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
890
1377
  OneUptimeDate.isOnOrAfter(currentEndTime, restrictionEndTime)
891
1378
  ) {
@@ -904,10 +1391,12 @@ export default class LayerUtil {
904
1391
  restrictionStartTime = OneUptimeDate.addRemoveDays(
905
1392
  restrictionStartTime,
906
1393
  data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1394
+ this.timezone, // preserve wall-clock across DST (audit F5)
907
1395
  );
908
1396
  restrictionEndTime = OneUptimeDate.addRemoveDays(
909
1397
  restrictionEndTime,
910
1398
  data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
1399
+ this.timezone, // preserve wall-clock across DST (audit F5)
911
1400
  );
912
1401
  }
913
1402
  }
@@ -975,18 +1464,25 @@ export default class LayerUtil {
975
1464
  let layerPriority: number = 1;
976
1465
 
977
1466
  for (const layer of data.layers) {
978
- const layerEvents: Array<CalendarEvent> = this.getEvents(
979
- {
980
- users: layer.users,
981
- startDateTimeOfLayer: layer.startDateTimeOfLayer,
982
- restrictionTimes: layer.restrictionTimes,
983
- handOffTime: layer.handOffTime,
984
- rotation: layer.rotation,
985
- calendarStartDate: data.calendarStartDate,
986
- calendarEndDate: data.calendarEndDate,
987
- },
988
- options,
989
- );
1467
+ /*
1468
+ * Do NOT forward getNumberOfEvents to the per-layer expansion. Capping
1469
+ * each layer to N events before the priority merge can drop a lower-
1470
+ * priority (fallback) layer's post-block coverage: if a higher-priority
1471
+ * layer's restricted block swallows the fallback's first N events, the
1472
+ * fallback's (N+1)-th event — the true "next" on-call after the block —
1473
+ * is never generated, corrupting the merged "next" roster. The cap is
1474
+ * applied only once, after the merge, below.
1475
+ */
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
+ });
990
1486
 
991
1487
  // add priority to each event
992
1488
 
@@ -1094,11 +1590,40 @@ export default class LayerUtil {
1094
1590
  */
1095
1591
  const tempFinalEventEnd: Date = finalEvent.end;
1096
1592
 
1593
+ /*
1594
+ * Reconstruct the trailing tail FIRST, before the front-collapse
1595
+ * removal below. If the lower-priority (fallback) event originally
1596
+ * extended past the higher-priority event, the portion AFTER the
1597
+ * higher-priority window must survive as its own segment — even when
1598
+ * the FRONT of the final event collapses to zero/negative length
1599
+ * (which happens when the higher-priority event starts at or before
1600
+ * the final event's start, e.g. two back-to-back higher-priority
1601
+ * rotation windows over a 24/7 fallback layer). Previously this block
1602
+ * ran only AFTER the collapse checks, whose `continue` skipped it,
1603
+ * silently deleting the fallback layer's coverage after the higher-
1604
+ * priority window and leaving on-call gaps where nobody is paged.
1605
+ */
1606
+ if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
1607
+ // add the trailing segment of the lower-priority event
1608
+ const trimmedEvent: PriorityCalendarEvents = {
1609
+ ...finalEvent,
1610
+ priority: finalEvent.priority,
1611
+ start: OneUptimeDate.addRemoveSeconds(event.end, 1),
1612
+ end: tempFinalEventEnd,
1613
+ };
1614
+
1615
+ // only keep it if it has positive length
1616
+ if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
1617
+ finalEvents.push(trimmedEvent);
1618
+ }
1619
+ }
1620
+
1097
1621
  finalEvent.end = OneUptimeDate.addRemoveSeconds(event.start, -1);
1098
1622
 
1099
1623
  /*
1100
1624
  * check if the final event end time is before the start time of the current event
1101
1625
  * if it is, we need to remove the final event from the final events array
1626
+ * (the trailing tail, if any, was already preserved above)
1102
1627
  */
1103
1628
  if (OneUptimeDate.isBefore(finalEvent.end, finalEvent.start)) {
1104
1629
  finalEvents.splice(i, 1);
@@ -1112,28 +1637,21 @@ export default class LayerUtil {
1112
1637
  i--; // Adjust index after removal
1113
1638
  continue;
1114
1639
  }
1115
-
1116
- // final event was originally ending after the current event, so we need to add the trimmed event to the final events array
1117
- if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
1118
- // add the trimmed event to the final events array
1119
- const trimmedEvent: PriorityCalendarEvents = {
1120
- ...finalEvent,
1121
- priority: finalEvent.priority,
1122
- start: OneUptimeDate.addRemoveSeconds(event.end, 1),
1123
- end: tempFinalEventEnd,
1124
- };
1125
-
1126
- // check if the event end time is before the start time of the trimmed event
1127
- if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
1128
- finalEvents.push(trimmedEvent);
1129
- }
1130
- }
1131
1640
  } else {
1132
1641
  /*
1133
- * trim the current event based on the final event
1134
- * start time of the current event will be the end time of the final event + 1 second
1642
+ * Trim the current (lower-priority) event: push its start past this
1643
+ * higher-priority window. Use getGreaterDate (a monotonic max)
1644
+ * instead of a bare assignment so the result does NOT depend on the
1645
+ * order finalEvents are visited. That makes it safe to hoist the
1646
+ * per-iteration finalEvents.sort() out of the loop (audit H2): with
1647
+ * the old in-loop ascending sort, successive overlaps already had
1648
+ * monotonically increasing ends, so max equals the old assignment and
1649
+ * the output is unchanged.
1135
1650
  */
1136
- event.start = OneUptimeDate.addRemoveSeconds(finalEvent.end, 1);
1651
+ event.start = OneUptimeDate.getGreaterDate(
1652
+ event.start,
1653
+ OneUptimeDate.addRemoveSeconds(finalEvent.end, 1),
1654
+ );
1137
1655
  }
1138
1656
  }
1139
1657
  }
@@ -1143,19 +1661,14 @@ export default class LayerUtil {
1143
1661
  finalEvents.push(event);
1144
1662
  }
1145
1663
 
1146
- // sort by start times
1147
-
1148
- finalEvents.sort((a: CalendarEvent, b: CalendarEvent) => {
1149
- if (OneUptimeDate.isBefore(a.start, b.start)) {
1150
- return -1;
1151
- }
1152
-
1153
- if (OneUptimeDate.isAfter(a.start, b.start)) {
1154
- return 1;
1155
- }
1156
-
1157
- return 0;
1158
- });
1664
+ /*
1665
+ * The finalEvents.sort() that used to run HERE — inside the per-event
1666
+ * loop — made the merge O(n^2 log n). It is hoisted to a single sort after
1667
+ * the loop (below). Correctness is preserved because overlap detection and
1668
+ * trimming do not depend on finalEvents being sorted (the current-event
1669
+ * trim above now uses a monotonic max), so sorting once at the end yields
1670
+ * the same result. Audit H2.
1671
+ */
1159
1672
 
1160
1673
  // if an event starts and end at the same time, we need to remove it
1161
1674
 
@@ -1181,6 +1694,23 @@ export default class LayerUtil {
1181
1694
  }
1182
1695
  }
1183
1696
 
1697
+ /*
1698
+ * Single final sort by start time (hoisted out of the per-event loop above,
1699
+ * audit H2). Downstream consumers (getEvents id assignment, the schedule
1700
+ * service's current/next selection) expect events in start order.
1701
+ */
1702
+ finalEvents.sort((a: CalendarEvent, b: CalendarEvent) => {
1703
+ if (OneUptimeDate.isBefore(a.start, b.start)) {
1704
+ return -1;
1705
+ }
1706
+
1707
+ if (OneUptimeDate.isAfter(a.start, b.start)) {
1708
+ return 1;
1709
+ }
1710
+
1711
+ return 0;
1712
+ });
1713
+
1184
1714
  // convert PriorityCalendarEvents to CalendarEvents
1185
1715
 
1186
1716
  const calendarEvents: CalendarEvent[] = [];