@oneuptime/common 12.0.29 → 12.0.31

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 (664) hide show
  1. package/Models/AnalyticsModels/SecurityEvent.ts +48 -16
  2. package/Models/AnalyticsModels/ThreatIntelIndicator.ts +10 -5
  3. package/Models/DatabaseModels/AlertSeverity.ts +44 -0
  4. package/Models/DatabaseModels/DetectionRule.ts +16 -5
  5. package/Models/DatabaseModels/GoogleSecOpsConnection.ts +14 -5
  6. package/Models/DatabaseModels/IncidentSeverity.ts +44 -0
  7. package/Models/DatabaseModels/Index.ts +13 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +113 -3
  9. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +88 -0
  10. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +22 -0
  11. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +499 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  14. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  15. package/Models/DatabaseModels/ThreatIntelFeed.ts +16 -5
  16. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  17. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  18. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  19. package/Server/API/GlobalConfigAPI.ts +362 -183
  20. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  21. package/Server/API/TelemetryAPI.ts +42 -4
  22. package/Server/EnvironmentConfig.ts +250 -0
  23. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.ts +59 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.ts +25 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  28. package/Server/Middleware/MultipartFormData.ts +118 -32
  29. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  30. package/Server/Services/AlertEpisodeLabelRuleEngineService.ts +9 -1
  31. package/Server/Services/AlertEpisodeOnCallRuleEngineService.ts +9 -1
  32. package/Server/Services/AlertEpisodeOwnerRuleEngineService.ts +9 -1
  33. package/Server/Services/AlertEpisodePrivacyRuleEngineService.ts +9 -1
  34. package/Server/Services/AlertGroupingEngineService.ts +9 -1
  35. package/Server/Services/AlertLabelRuleEngineService.ts +9 -1
  36. package/Server/Services/AlertOnCallRuleEngineService.ts +9 -1
  37. package/Server/Services/AlertOwnerRuleEngineService.ts +9 -1
  38. package/Server/Services/AlertPrivacyRuleEngineService.ts +9 -1
  39. package/Server/Services/AlertReminderRuleService.ts +9 -1
  40. package/Server/Services/AutoRemediationRuleEngineService.ts +9 -1
  41. package/Server/Services/CephClusterLabelRuleEngineService.ts +9 -1
  42. package/Server/Services/CephClusterOwnerRuleEngineService.ts +9 -1
  43. package/Server/Services/CloudResourceLabelRuleEngineService.ts +9 -1
  44. package/Server/Services/CloudResourceOwnerRuleEngineService.ts +9 -1
  45. package/Server/Services/DashboardLabelRuleEngineService.ts +9 -1
  46. package/Server/Services/DashboardOwnerRuleEngineService.ts +9 -1
  47. package/Server/Services/DockerHostLabelRuleEngineService.ts +9 -1
  48. package/Server/Services/DockerHostOwnerRuleEngineService.ts +9 -1
  49. package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +9 -1
  50. package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +9 -1
  51. package/Server/Services/HostLabelRuleEngineService.ts +9 -1
  52. package/Server/Services/HostOwnerRuleEngineService.ts +9 -1
  53. package/Server/Services/IncidentEpisodeLabelRuleEngineService.ts +9 -1
  54. package/Server/Services/IncidentEpisodeOnCallRuleEngineService.ts +9 -1
  55. package/Server/Services/IncidentEpisodeOwnerRuleEngineService.ts +9 -1
  56. package/Server/Services/IncidentEpisodePrivacyRuleEngineService.ts +9 -1
  57. package/Server/Services/IncidentGroupingEngineService.ts +9 -1
  58. package/Server/Services/IncidentLabelRuleEngineService.ts +9 -1
  59. package/Server/Services/IncidentOnCallRuleEngineService.ts +9 -1
  60. package/Server/Services/IncidentOwnerRuleEngineService.ts +9 -1
  61. package/Server/Services/IncidentPrivacyRuleEngineService.ts +9 -1
  62. package/Server/Services/IncidentReminderRuleService.ts +9 -1
  63. package/Server/Services/IncidentSlaRuleService.ts +9 -1
  64. package/Server/Services/IncomingCallPolicyLabelRuleEngineService.ts +9 -1
  65. package/Server/Services/IncomingCallPolicyOwnerRuleEngineService.ts +9 -1
  66. package/Server/Services/Index.ts +14 -0
  67. package/Server/Services/IoTFleetLabelRuleEngineService.ts +9 -1
  68. package/Server/Services/IoTFleetOwnerRuleEngineService.ts +9 -1
  69. package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +9 -1
  70. package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +9 -1
  71. package/Server/Services/KubernetesResourceService.ts +5 -1
  72. package/Server/Services/MonitorLabelRuleEngineService.ts +9 -1
  73. package/Server/Services/MonitorOwnerRuleEngineService.ts +9 -1
  74. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +35 -0
  75. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +96 -0
  76. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +9 -1
  77. package/Server/Services/NetworkDeviceOidTemplateService.ts +201 -0
  78. package/Server/Services/NetworkDeviceOwnerRuleEngineService.ts +9 -1
  79. package/Server/Services/NetworkDeviceService.ts +209 -1
  80. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  81. package/Server/Services/OnCallDutyPolicyLabelRuleEngineService.ts +9 -1
  82. package/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.ts +9 -1
  83. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  84. package/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.ts +9 -1
  85. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  86. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  87. package/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.ts +9 -1
  88. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  89. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  90. package/Server/Services/PodmanHostLabelRuleEngineService.ts +9 -1
  91. package/Server/Services/PodmanHostOwnerRuleEngineService.ts +9 -1
  92. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  93. package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +9 -1
  94. package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +9 -1
  95. package/Server/Services/RumApplicationLabelRuleEngineService.ts +9 -1
  96. package/Server/Services/RumApplicationOwnerRuleEngineService.ts +9 -1
  97. package/Server/Services/RunbookLabelRuleEngineService.ts +9 -1
  98. package/Server/Services/RunbookOwnerRuleEngineService.ts +9 -1
  99. package/Server/Services/RunbookRuleEngineService.ts +9 -1
  100. package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +9 -1
  101. package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +9 -1
  102. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +9 -1
  103. package/Server/Services/ServerlessFunctionLabelRuleEngineService.ts +9 -1
  104. package/Server/Services/ServerlessFunctionOwnerRuleEngineService.ts +9 -1
  105. package/Server/Services/ServiceLabelRuleEngineService.ts +9 -1
  106. package/Server/Services/ServiceOwnerRuleEngineService.ts +9 -1
  107. package/Server/Services/StatusPageLabelRuleEngineService.ts +9 -1
  108. package/Server/Services/StatusPageMonitorRuleEngineService.ts +7 -0
  109. package/Server/Services/StatusPageOwnerRuleEngineService.ts +9 -1
  110. package/Server/Services/TeamMemberService.ts +489 -1
  111. package/Server/Services/TeamService.ts +50 -0
  112. package/Server/Services/TelemetrySourceMapService.ts +266 -23
  113. package/Server/Services/UserNotificationSettingService.ts +44 -1
  114. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  115. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  116. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  117. package/Server/Services/UserService.ts +27 -0
  118. package/Server/Services/WorkflowLabelRuleEngineService.ts +9 -1
  119. package/Server/Services/WorkflowOwnerRuleEngineService.ts +9 -1
  120. package/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.ts +186 -0
  121. package/Server/Utils/Monitor/CapturedMetricAttributeUtil.ts +245 -0
  122. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +21 -2
  123. package/Server/Utils/Monitor/MonitorMetricUtil.ts +55 -21
  124. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +32 -0
  125. package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +15 -2
  126. package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +18 -1
  127. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +10 -0
  128. package/Server/Utils/Monitor/SeriesResourceLabels.ts +34 -0
  129. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  130. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  131. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  132. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  133. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  134. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  135. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  136. package/Server/Utils/Response.ts +134 -0
  137. package/Server/Utils/Rules/RuleEngineRuleRead.ts +48 -0
  138. package/Server/Utils/StartServer.ts +10 -0
  139. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  140. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +14 -1
  141. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +14 -2
  142. package/Server/Utils/Telemetry/SourceMapResolver.ts +16 -5
  143. package/Server/Utils/Telemetry/StackTraceParser.ts +399 -0
  144. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +125 -0
  145. package/Tests/App/Dashboard/HeaderImageAssets.test.tsx +93 -0
  146. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  147. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  148. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  149. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  150. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  151. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3 -3
  152. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  153. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  154. package/Tests/App/Dashboard/UserElementEmail.test.tsx +12 -4
  155. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +1 -1
  156. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  157. package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +14 -0
  158. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +469 -0
  159. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  160. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  161. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  162. package/Tests/Models/SecurityDomainAccessControl.test.ts +429 -0
  163. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  164. package/Tests/Server/API/GlobalConfigLicense.test.ts +657 -0
  165. package/Tests/Server/API/Helpers.ts +8 -0
  166. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  167. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +84 -6
  168. package/Tests/Server/API/TelemetryResolveStackTraceAPI.test.ts +805 -0
  169. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  170. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  171. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +394 -0
  172. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +1144 -0
  173. package/Tests/Server/Middleware/MultipartFormData.test.ts +618 -3
  174. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  175. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  176. package/Tests/Server/Services/MonitorLabelRuleEngineScale.test.ts +520 -0
  177. package/Tests/Server/Services/NetworkDeviceOidTemplateService.test.ts +332 -0
  178. package/Tests/Server/Services/NetworkDeviceOidTemplateWriteGuards.test.ts +409 -0
  179. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  180. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  181. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  182. package/Tests/Server/Services/RuleEngineRuleFetchLimit.test.ts +239 -0
  183. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  184. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  185. package/Tests/Server/Services/TelemetrySourceMapService.test.ts +1210 -2
  186. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  187. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  188. package/Tests/Server/Services/UserServiceSeatLimit.test.ts +182 -0
  189. package/Tests/Server/Types/AnalyticsDatabase/SecurityEventModelPermission.test.ts +255 -0
  190. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  191. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +212 -0
  192. package/Tests/Server/Types/Database/Permissions/SecurityRoleAccess.test.ts +413 -0
  193. package/Tests/Server/Utils/CodeRepository.test.ts +689 -0
  194. package/Tests/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.test.ts +495 -0
  195. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +77 -0
  196. package/Tests/Server/Utils/Monitor/CapturedMetricAttributeUtil.test.ts +284 -0
  197. package/Tests/Server/Utils/Monitor/MonitorCustomMetricAttributes.test.ts +503 -0
  198. package/Tests/Server/Utils/Monitor/MonitorTemplatePrototypePollution.test.ts +106 -0
  199. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +33 -4
  200. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +14 -4
  201. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +44 -0
  202. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  203. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  204. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  205. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  206. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  207. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  208. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  209. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  210. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  211. package/Tests/Server/Utils/OpenAPISpec.test.ts +536 -0
  212. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  213. package/Tests/Server/Utils/Rules/RuleEngineRuleRead.test.ts +115 -0
  214. package/Tests/Server/Utils/StackTraceParser.test.ts +610 -0
  215. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  216. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  217. package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
  218. package/Tests/Server/Utils/Telemetry/SourceMapLimits.test.ts +656 -0
  219. package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +100 -0
  220. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  221. package/Tests/Types/Monitor/SnmpMonitor/SnmpOidListUtil.test.ts +454 -0
  222. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  223. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  224. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  225. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  226. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  227. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  228. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  229. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  230. package/Tests/Types/SecurityRolePermissions.test.ts +193 -0
  231. package/Tests/UI/Components/Charts/AnnotationLayout.test.ts +773 -0
  232. package/Tests/UI/Components/Charts/ChartAnnotationRail.test.tsx +789 -0
  233. package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +254 -0
  234. package/Tests/UI/Components/Charts/TimeAnnotation.test.ts +471 -0
  235. package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +405 -0
  236. package/Tests/UI/Components/ErrorMessageRefreshControl.test.tsx +83 -0
  237. package/Tests/UI/Components/MarkdownConverters.test.ts +686 -0
  238. package/Tests/UI/Components/ModelTable/BaseModelTableDeleteAndEmptyStates.test.tsx +341 -0
  239. package/Tests/UI/Components/OrderedStatesList.test.tsx +5 -5
  240. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +1 -1
  241. package/Tests/UI/Components/TableSortAndMobileColumns.test.tsx +195 -0
  242. package/Tests/UI/Components/XAxis.test.ts +270 -5
  243. package/Tests/UI/Components/XAxisDaylightSaving.test.ts +113 -0
  244. package/Tests/UI/Styles/ChartFocusRing.test.ts +153 -0
  245. package/Tests/UI/Styles/ChartFocusRingCoverage.test.tsx +377 -0
  246. package/Tests/UI/Styles/ThemeStylesheet.ts +162 -0
  247. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  248. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  249. package/Tests/Utils/EnterpriseLicenseSeats.test.ts +507 -0
  250. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +951 -1
  251. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +238 -0
  252. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +1056 -0
  253. package/Tests/Utils/NetworkDiscovery/ReverseDnsNameUtil.test.ts +882 -0
  254. package/Types/Calendar/ICalendar.ts +442 -0
  255. package/Types/Date.ts +39 -0
  256. package/Types/Email/EmailTemplateType.ts +2 -0
  257. package/Types/Monitor/SnmpMonitor/SnmpOidListUtil.ts +333 -0
  258. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  259. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  260. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  261. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  262. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  263. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  264. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  265. package/Types/Permission.ts +96 -0
  266. package/Types/Rum/SessionReplay.ts +21 -0
  267. package/Types/Telemetry/SourceMap.ts +8 -0
  268. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.tsx +176 -0
  269. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.ts +745 -0
  270. package/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.tsx +576 -0
  271. package/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.tsx +204 -0
  272. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +491 -544
  273. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +324 -372
  274. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +487 -540
  275. package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +7 -3
  276. package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +14 -3
  277. package/UI/Components/Charts/Types/ChartEventKind.ts +13 -0
  278. package/UI/Components/Charts/Types/ReferenceRegionProps.ts +6 -0
  279. package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +20 -3
  280. package/UI/Components/Charts/Utils/TimeAnnotation.ts +69 -35
  281. package/UI/Components/Charts/Utils/XAxis.ts +125 -18
  282. package/UI/Components/EditionLabel/EditionLabel.tsx +280 -26
  283. package/UI/Components/ErrorMessage/ErrorMessage.tsx +21 -10
  284. package/UI/Components/Feed/FeedItem.tsx +4 -5
  285. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -2
  286. package/UI/Components/Header/UserProfile/UserProfile.tsx +1 -2
  287. package/UI/Components/Image/Image.tsx +20 -3
  288. package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
  289. package/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +1 -2
  290. package/UI/Components/ModelTable/BaseModelTable.tsx +150 -24
  291. package/UI/Components/Table/TableHeader.tsx +64 -48
  292. package/UI/Components/Table/TableRow.tsx +13 -3
  293. package/UI/Styles/Theme.css +38 -0
  294. package/Utils/EnterpriseLicense/EnterpriseLicenseSeats.ts +271 -0
  295. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +6 -0
  296. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +141 -10
  297. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +55 -3
  298. package/Utils/NetworkDiscovery/ReverseDnsNameUtil.ts +151 -0
  299. package/Utils/Rules/RuleEngineLimits.ts +32 -0
  300. package/build/dist/Models/AnalyticsModels/SecurityEvent.js +48 -16
  301. package/build/dist/Models/AnalyticsModels/SecurityEvent.js.map +1 -1
  302. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +10 -5
  303. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -1
  304. package/build/dist/Models/DatabaseModels/AlertSeverity.js +46 -1
  305. package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
  306. package/build/dist/Models/DatabaseModels/DetectionRule.js +16 -5
  307. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  308. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +14 -5
  309. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
  310. package/build/dist/Models/DatabaseModels/IncidentSeverity.js +46 -1
  311. package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
  312. package/build/dist/Models/DatabaseModels/Index.js +13 -0
  313. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  314. package/build/dist/Models/DatabaseModels/NetworkDevice.js +115 -3
  315. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  316. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +88 -0
  317. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
  318. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  319. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +519 -0
  320. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -0
  321. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  322. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  323. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  324. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  325. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  326. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  327. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js +16 -5
  328. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -1
  329. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  330. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  331. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  332. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  333. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  334. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  335. package/build/dist/Server/API/GlobalConfigAPI.js +293 -176
  336. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  337. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  338. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  339. package/build/dist/Server/API/TelemetryAPI.js +31 -3
  340. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  341. package/build/dist/Server/EnvironmentConfig.js +185 -0
  342. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  343. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  344. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  345. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  346. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  347. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js +26 -0
  348. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js.map +1 -0
  349. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js +14 -0
  350. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js.map +1 -0
  351. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  352. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  353. package/build/dist/Server/Middleware/MultipartFormData.js +93 -28
  354. package/build/dist/Server/Middleware/MultipartFormData.js.map +1 -1
  355. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  356. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  357. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js +8 -1
  358. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js.map +1 -1
  359. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js +8 -1
  360. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js.map +1 -1
  361. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js +8 -1
  362. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js.map +1 -1
  363. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js +8 -1
  364. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js.map +1 -1
  365. package/build/dist/Server/Services/AlertGroupingEngineService.js +8 -1
  366. package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
  367. package/build/dist/Server/Services/AlertLabelRuleEngineService.js +8 -1
  368. package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
  369. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js +8 -1
  370. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js.map +1 -1
  371. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +8 -1
  372. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
  373. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js +8 -1
  374. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js.map +1 -1
  375. package/build/dist/Server/Services/AlertReminderRuleService.js +8 -1
  376. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  377. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +8 -1
  378. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  379. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +8 -1
  380. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
  381. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +8 -1
  382. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
  383. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +8 -1
  384. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
  385. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +8 -1
  386. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
  387. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js +8 -1
  388. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js.map +1 -1
  389. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js +8 -1
  390. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js.map +1 -1
  391. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +8 -1
  392. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
  393. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +8 -1
  394. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
  395. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +8 -1
  396. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
  397. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +8 -1
  398. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
  399. package/build/dist/Server/Services/HostLabelRuleEngineService.js +8 -1
  400. package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
  401. package/build/dist/Server/Services/HostOwnerRuleEngineService.js +8 -1
  402. package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
  403. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js +8 -1
  404. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js.map +1 -1
  405. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js +8 -1
  406. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js.map +1 -1
  407. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js +8 -1
  408. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js.map +1 -1
  409. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js +8 -1
  410. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js.map +1 -1
  411. package/build/dist/Server/Services/IncidentGroupingEngineService.js +8 -1
  412. package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
  413. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +8 -1
  414. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
  415. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js +8 -1
  416. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js.map +1 -1
  417. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +8 -1
  418. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
  419. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js +8 -1
  420. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js.map +1 -1
  421. package/build/dist/Server/Services/IncidentReminderRuleService.js +8 -1
  422. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  423. package/build/dist/Server/Services/IncidentSlaRuleService.js +8 -1
  424. package/build/dist/Server/Services/IncidentSlaRuleService.js.map +1 -1
  425. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js +8 -1
  426. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js.map +1 -1
  427. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js +8 -1
  428. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js.map +1 -1
  429. package/build/dist/Server/Services/Index.js +14 -0
  430. package/build/dist/Server/Services/Index.js.map +1 -1
  431. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +8 -1
  432. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -1
  433. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +8 -1
  434. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -1
  435. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +8 -1
  436. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
  437. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +8 -1
  438. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
  439. package/build/dist/Server/Services/KubernetesResourceService.js +5 -1
  440. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  441. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js +8 -1
  442. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js.map +1 -1
  443. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js +8 -1
  444. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js.map +1 -1
  445. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +30 -21
  446. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  447. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +64 -0
  448. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
  449. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +8 -1
  450. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  451. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js +180 -0
  452. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js.map +1 -0
  453. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js +8 -1
  454. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js.map +1 -1
  455. package/build/dist/Server/Services/NetworkDeviceService.js +154 -1
  456. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  457. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  458. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  459. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js +8 -1
  460. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js.map +1 -1
  461. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js +8 -1
  462. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js.map +1 -1
  463. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  464. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  465. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js +8 -1
  466. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js.map +1 -1
  467. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  468. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  469. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  470. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  471. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js +8 -1
  472. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js.map +1 -1
  473. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  474. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  475. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  476. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  477. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js +8 -1
  478. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
  479. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +8 -1
  480. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js.map +1 -1
  481. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  482. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  483. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js +8 -1
  484. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
  485. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +8 -1
  486. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
  487. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js +8 -1
  488. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js.map +1 -1
  489. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js +8 -1
  490. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js.map +1 -1
  491. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js +8 -1
  492. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js.map +1 -1
  493. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js +8 -1
  494. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js.map +1 -1
  495. package/build/dist/Server/Services/RunbookRuleEngineService.js +8 -1
  496. package/build/dist/Server/Services/RunbookRuleEngineService.js.map +1 -1
  497. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +8 -1
  498. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
  499. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +8 -1
  500. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
  501. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +8 -1
  502. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  503. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js +8 -1
  504. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js.map +1 -1
  505. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js +8 -1
  506. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js.map +1 -1
  507. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +8 -1
  508. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
  509. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +8 -1
  510. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
  511. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js +8 -1
  512. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js.map +1 -1
  513. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +6 -0
  514. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -1
  515. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js +8 -1
  516. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js.map +1 -1
  517. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  518. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  519. package/build/dist/Server/Services/TeamService.js +43 -0
  520. package/build/dist/Server/Services/TeamService.js.map +1 -1
  521. package/build/dist/Server/Services/TelemetrySourceMapService.js +213 -19
  522. package/build/dist/Server/Services/TelemetrySourceMapService.js.map +1 -1
  523. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  524. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  525. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  526. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  527. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  528. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  529. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  530. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  531. package/build/dist/Server/Services/UserService.js +25 -0
  532. package/build/dist/Server/Services/UserService.js.map +1 -1
  533. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js +8 -1
  534. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js.map +1 -1
  535. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js +8 -1
  536. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js.map +1 -1
  537. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js +145 -0
  538. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js.map +1 -0
  539. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js +201 -0
  540. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js.map +1 -0
  541. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +19 -2
  542. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  543. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +42 -23
  544. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  545. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +29 -0
  546. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  547. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +11 -2
  548. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -1
  549. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +16 -1
  550. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
  551. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +10 -0
  552. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  553. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +33 -0
  554. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  555. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  556. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  557. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  558. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  559. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  560. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  561. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  562. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  563. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  564. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  565. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  566. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  567. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  568. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  569. package/build/dist/Server/Utils/Response.js +97 -0
  570. package/build/dist/Server/Utils/Response.js.map +1 -1
  571. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js +37 -0
  572. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js.map +1 -0
  573. package/build/dist/Server/Utils/StartServer.js +9 -1
  574. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  575. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  576. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  577. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +12 -1
  578. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -1
  579. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +14 -2
  580. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  581. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +15 -5
  582. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -1
  583. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +307 -0
  584. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js.map +1 -1
  585. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  586. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  587. package/build/dist/Types/Date.js +27 -0
  588. package/build/dist/Types/Date.js.map +1 -1
  589. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  590. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  591. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js +253 -0
  592. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js.map +1 -0
  593. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  594. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  595. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  596. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  597. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  598. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  599. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  600. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  601. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  602. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  603. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  604. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  605. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  606. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  607. package/build/dist/Types/Permission.js +86 -0
  608. package/build/dist/Types/Permission.js.map +1 -1
  609. package/build/dist/Types/Rum/SessionReplay.js +19 -0
  610. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  611. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js +83 -0
  612. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js.map +1 -0
  613. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js +531 -0
  614. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js.map +1 -0
  615. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js +264 -0
  616. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js.map +1 -0
  617. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js +122 -0
  618. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js.map +1 -0
  619. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +213 -233
  620. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  621. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +139 -152
  622. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  623. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +212 -232
  624. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  625. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js +14 -0
  626. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js.map +1 -0
  627. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +57 -28
  628. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
  629. package/build/dist/UI/Components/Charts/Utils/XAxis.js +103 -17
  630. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  631. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +198 -23
  632. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  633. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js +15 -5
  634. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js.map +1 -1
  635. package/build/dist/UI/Components/Feed/FeedItem.js +4 -5
  636. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  637. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -2
  638. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  639. package/build/dist/UI/Components/Header/UserProfile/UserProfile.js.map +1 -1
  640. package/build/dist/UI/Components/Image/Image.js +6 -1
  641. package/build/dist/UI/Components/Image/Image.js.map +1 -1
  642. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
  643. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
  644. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js +1 -2
  645. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js.map +1 -1
  646. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +104 -23
  647. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  648. package/build/dist/UI/Components/Table/TableHeader.js +20 -22
  649. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  650. package/build/dist/UI/Components/Table/TableRow.js +13 -4
  651. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  652. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js +153 -0
  653. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js.map +1 -0
  654. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
  655. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +127 -7
  656. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
  657. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +50 -3
  658. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  659. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js +132 -0
  660. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js.map +1 -0
  661. package/build/dist/Utils/Rules/RuleEngineLimits.js +31 -0
  662. package/build/dist/Utils/Rules/RuleEngineLimits.js.map +1 -0
  663. package/jest.config.json +1 -0
  664. package/package.json +2 -2
@@ -0,0 +1,1986 @@
1
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
2
+ import ICalendar, {
3
+ ICalendarDocument,
4
+ ICalendarEvent,
5
+ } from "../../../Types/Calendar/ICalendar";
6
+ import EventInterval from "../../../Types/Events/EventInterval";
7
+ import Timezone from "../../../Types/Timezone";
8
+ import {
9
+ MAX_EVENTS,
10
+ MAX_GAP_EVENTS,
11
+ } from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
12
+ import LayerUtil, { LayerProps } from "../../../Types/OnCallDutyPolicy/Layer";
13
+ import {
14
+ MaterializedShift,
15
+ MaterializedShiftPolicy,
16
+ } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
17
+ import OnCallCalendarFeedUtil, {
18
+ CoverageEnvelopeResult,
19
+ CoverageGapEventsResult,
20
+ FeedRenderResult,
21
+ OnCallCalendarFeedKind,
22
+ PolicyVariantIndex,
23
+ WindowShrinkResult,
24
+ } from "../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
25
+ import ScheduleShiftUtil, {
26
+ OnCallShift,
27
+ } from "../../../Types/OnCallDutyPolicy/ScheduleShiftUtil";
28
+ import ShiftSeamUtil, {
29
+ TimeSegment,
30
+ } from "../../../Types/OnCallDutyPolicy/ShiftSeamUtil";
31
+ import UserOverrideUtil from "../../../Types/OnCallDutyPolicy/UserOverrideUtil";
32
+ import {
33
+ DASHBOARD_URL,
34
+ DEFAULT_LAST_MODIFIED,
35
+ DEFAULT_POLICY,
36
+ at,
37
+ blockProperty,
38
+ businessHoursRestriction,
39
+ dailyRestriction,
40
+ eventBlocks,
41
+ hhmm,
42
+ logicalLines,
43
+ materialize,
44
+ noRestriction,
45
+ physicalLines,
46
+ properties,
47
+ property,
48
+ rotation,
49
+ shift,
50
+ tzInstant,
51
+ user,
52
+ } from "./CalendarFeedTestFixtures";
53
+
54
+ const CRLF: string = "\r\n";
55
+ const UTC: string = "UTC";
56
+
57
+ const Kind: typeof OnCallCalendarFeedKind = OnCallCalendarFeedKind;
58
+
59
+ function personalContext(viewerTimezone?: string | undefined): {
60
+ kind: OnCallCalendarFeedKind;
61
+ dashboardUrl: string;
62
+ viewerTimezone?: string | undefined;
63
+ } {
64
+ return {
65
+ kind: Kind.Personal,
66
+ dashboardUrl: DASHBOARD_URL,
67
+ ...(viewerTimezone !== undefined ? { viewerTimezone } : {}),
68
+ };
69
+ }
70
+
71
+ function renderPersonal(
72
+ shifts: Array<MaterializedShift>,
73
+ viewerTimezone?: string | undefined,
74
+ ): string {
75
+ return OnCallCalendarFeedUtil.render({
76
+ ...personalContext(viewerTimezone),
77
+ shifts,
78
+ }).body;
79
+ }
80
+
81
+ function renderSchedule(
82
+ shifts: Array<MaterializedShift>,
83
+ name: string,
84
+ ): string {
85
+ return OnCallCalendarFeedUtil.render({
86
+ kind: Kind.Schedule,
87
+ dashboardUrl: DASHBOARD_URL,
88
+ shifts,
89
+ scheduleName: name,
90
+ }).body;
91
+ }
92
+
93
+ function epoch(date: Date): number {
94
+ return Math.floor(date.getTime() / 1000);
95
+ }
96
+
97
+ // LayerUtil -> overrides -> feed grouping -> seams -> MaterializedShift[].
98
+ function materializeLayers(
99
+ layers: Array<LayerProps>,
100
+ windowStart: Date,
101
+ windowEnd: Date,
102
+ overrides?: Array<{
103
+ overrideUserId: string;
104
+ routeAlertsToUserId: string;
105
+ startsAt: Date;
106
+ endsAt: Date;
107
+ }>,
108
+ extra?: Partial<MaterializedShift> | undefined,
109
+ ): Array<MaterializedShift> {
110
+ let events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
111
+ layers,
112
+ calendarStartDate: windowStart,
113
+ calendarEndDate: windowEnd,
114
+ });
115
+
116
+ if (overrides && overrides.length > 0) {
117
+ events = UserOverrideUtil.applyOverridesToEvents({ events, overrides });
118
+ }
119
+
120
+ const shifts: Array<OnCallShift> = ScheduleShiftUtil.groupEventsIntoShifts(
121
+ events,
122
+ { groupKey: ScheduleShiftUtil.groupKeyByUserOverrideAndLayer },
123
+ );
124
+
125
+ return materialize(ShiftSeamUtil.normalizeSeams(shifts), extra);
126
+ }
127
+
128
+ describe("OnCallCalendarFeedUtil: byte-exact personal feed", () => {
129
+ const stockholmShift: MaterializedShift = shift({
130
+ start: tzInstant("2026-09-01 09:00", "Europe/Stockholm"),
131
+ end: tzInstant("2026-09-01 17:00", "Europe/Stockholm"),
132
+ });
133
+
134
+ test("logical lines of a one-shift personal feed match exactly", () => {
135
+ const body: string = renderPersonal([stockholmShift], "America/New_York");
136
+ const uid: string = `oncall-sched-1-${epoch(stockholmShift.start)}@oneuptime`;
137
+
138
+ const expected: Array<string> = [
139
+ "BEGIN:VCALENDAR",
140
+ "VERSION:2.0",
141
+ "PRODID:-//OneUptime//On-Call Calendar Feed//EN",
142
+ "CALSCALE:GREGORIAN",
143
+ "NAME:OneUptime On-Call",
144
+ "X-WR-CALNAME:OneUptime On-Call",
145
+ "X-WR-CALDESC:Your on-call shifts from OneUptime. Calendar apps refresh subscribed feeds on their own schedule (Google Calendar every 12-24 h\\, Outlook on the web about every 3 h\\, Apple Calendar per its fetch setting)\\, so recent changes can lag.",
146
+ "X-WR-TIMEZONE:America/New_York",
147
+ "REFRESH-INTERVAL;VALUE=DURATION:PT1H",
148
+ "X-PUBLISHED-TTL:PT1H",
149
+ "LAST-MODIFIED:20260801T100000Z",
150
+ "BEGIN:VEVENT",
151
+ `UID:${uid}`,
152
+ "DTSTAMP:20260801T100000Z",
153
+ "LAST-MODIFIED:20260801T100000Z",
154
+ "SEQUENCE:3",
155
+ "DTSTART:20260901T070000Z",
156
+ "DTEND:20260901T150000Z",
157
+ "SUMMARY:On-call · Payments · Payments Policy",
158
+ "DESCRIPTION:Who: Alice Andersson\\nSchedule: Payments (Europe/Stockholm)\\nShift: Sep 01 2026\\, 09:00 CEST → Sep 01 2026\\, 17:00 CEST (Europe/Stockholm — schedule zone)\\nShift in UTC: Sep 01 2026\\, 07:00 UTC → Sep 01 2026\\, 15:00 UTC\\nShift in America/New_York (your zone): Sep 01 2026\\, 03:00 EDT → Sep 01 2026\\, 11:00 EDT\\nPages you via: Payments Policy › Primary (step 1)\\nNeed cover? https://oneuptime.example.com/dashboard/proj-1/on-call-duty/user-overrides\\nChanges appear after your calendar app next refreshes (Google Calendar: up to 24 h).",
159
+ "URL:https://oneuptime.example.com/dashboard/proj-1/on-call-duty/schedules/sched-1",
160
+ "STATUS:CONFIRMED",
161
+ "TRANSP:TRANSPARENT",
162
+ "CATEGORIES:On-Call",
163
+ "END:VEVENT",
164
+ "END:VCALENDAR",
165
+ ];
166
+
167
+ expect(logicalLines(body)).toEqual(expected);
168
+ expect(ICalendar.unfold(body)).toBe(expected.join(CRLF) + CRLF);
169
+ });
170
+
171
+ test("the physical body is CRLF-terminated and folded at 75 octets", () => {
172
+ const body: string = renderPersonal([stockholmShift], "America/New_York");
173
+
174
+ expect(body.endsWith(CRLF)).toBe(true);
175
+ for (const line of physicalLines(body)) {
176
+ expect(Buffer.byteLength(line, "utf8")).toBeLessThanOrEqual(75);
177
+ }
178
+ // The DESCRIPTION is long enough that folding actually happened.
179
+ expect(physicalLines(body).length).toBeGreaterThan(
180
+ logicalLines(body).length,
181
+ );
182
+ });
183
+
184
+ test("rendering the same input twice is byte-identical", () => {
185
+ expect(renderPersonal([stockholmShift], "America/New_York")).toBe(
186
+ renderPersonal([stockholmShift], "America/New_York"),
187
+ );
188
+ });
189
+ });
190
+
191
+ describe("OnCallCalendarFeedUtil: UID identity", () => {
192
+ const start: Date = at("2026-09-01T07:00:00Z");
193
+ const end: Date = at("2026-09-01T15:00:00Z");
194
+
195
+ test("UID depends only on schedule and normalised start", () => {
196
+ const a: MaterializedShift = shift({ start, end });
197
+ const b: MaterializedShift = shift({
198
+ start,
199
+ end: at("2026-09-01T20:00:00Z"),
200
+ userId: "user-b",
201
+ userName: "Bob",
202
+ shiftConfigVersion: 9,
203
+ lastModifiedAt: at("2026-08-20T00:00:00Z"),
204
+ });
205
+
206
+ expect(OnCallCalendarFeedUtil.getShiftUid(a)).toBe(
207
+ `oncall-sched-1-${epoch(start)}@oneuptime`,
208
+ );
209
+ expect(OnCallCalendarFeedUtil.getShiftUid(b)).toBe(
210
+ OnCallCalendarFeedUtil.getShiftUid(a),
211
+ );
212
+ expect(
213
+ OnCallCalendarFeedUtil.getShiftUid(
214
+ shift({ start, end, scheduleId: "other" }),
215
+ ),
216
+ ).not.toBe(OnCallCalendarFeedUtil.getShiftUid(a));
217
+ });
218
+
219
+ test("UID is stable across window shifts (different feed windows, notes and calendar kinds)", () => {
220
+ const one: MaterializedShift = shift({ start, end });
221
+
222
+ const personal: string = renderPersonal([one]);
223
+ const withNotes: string = OnCallCalendarFeedUtil.render({
224
+ ...personalContext(),
225
+ shifts: [one],
226
+ notes: ["window shrunk"],
227
+ }).body;
228
+ const scheduleFeed: string = renderSchedule([one], "Payments");
229
+ const projectFeed: string = OnCallCalendarFeedUtil.render({
230
+ kind: Kind.Project,
231
+ dashboardUrl: DASHBOARD_URL,
232
+ shifts: [one],
233
+ projectName: "Acme",
234
+ }).body;
235
+
236
+ const expectedUid: string = `oncall-sched-1-${epoch(start)}@oneuptime`;
237
+ for (const body of [personal, withNotes, scheduleFeed, projectFeed]) {
238
+ expect(properties(body, "UID")).toEqual([expectedUid]);
239
+ }
240
+ });
241
+
242
+ test("an override swap keeps the same UID but changes SUMMARY", () => {
243
+ const original: MaterializedShift = shift({ start, end });
244
+ const swapped: MaterializedShift = shift({
245
+ start,
246
+ end,
247
+ userId: "user-b",
248
+ userName: "Bob Berg",
249
+ override: {
250
+ originalUserId: "user-a",
251
+ originalUserName: "Alice Andersson",
252
+ overrideStartsAt: at("2026-09-01T00:00:00Z"),
253
+ overrideEndsAt: at("2026-09-02T00:00:00Z"),
254
+ },
255
+ });
256
+
257
+ const before: Array<Array<string>> = eventBlocks(
258
+ renderPersonal([original]),
259
+ );
260
+ const after: Array<Array<string>> = eventBlocks(renderPersonal([swapped]));
261
+
262
+ expect(blockProperty(after[0]!, "UID")).toBe(
263
+ blockProperty(before[0]!, "UID"),
264
+ );
265
+ expect(blockProperty(before[0]!, "SUMMARY")).toBe(
266
+ "On-call · Payments · Payments Policy",
267
+ );
268
+ expect(blockProperty(after[0]!, "SUMMARY")).toBe(
269
+ "On-call · Payments · Payments Policy (covering for Alice Andersson)",
270
+ );
271
+ expect(blockProperty(after[0]!, "DESCRIPTION")).toContain(
272
+ "Who: Bob Berg\\, covering for Alice Andersson",
273
+ );
274
+ expect(blockProperty(after[0]!, "DESCRIPTION")).toContain(
275
+ "Override: Alice Andersson → Bob Berg from Sep 01 2026\\, 02:00 CEST to Sep 02 2026\\, 02:00 CEST (global override)",
276
+ );
277
+ });
278
+
279
+ test("an override split yields three UIDs whose DTEND/DTSTART touch", () => {
280
+ const windowStart: Date = at("2026-03-02T00:00:00Z");
281
+ const windowEnd: Date = at("2026-03-03T00:00:00Z");
282
+
283
+ const shifts: Array<MaterializedShift> = materializeLayers(
284
+ [
285
+ {
286
+ users: [user("user-a")],
287
+ startDateTimeOfLayer: windowStart,
288
+ restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
289
+ handOffTime: at("2026-03-02T09:00:00Z"),
290
+ rotation: rotation(EventInterval.Week, 1),
291
+ timezone: UTC,
292
+ },
293
+ ],
294
+ windowStart,
295
+ windowEnd,
296
+ [
297
+ {
298
+ overrideUserId: "user-a",
299
+ routeAlertsToUserId: "user-b",
300
+ startsAt: at("2026-03-02T12:00:00Z"),
301
+ endsAt: at("2026-03-02T13:00:00Z"),
302
+ },
303
+ ],
304
+ { scheduleTimezone: UTC },
305
+ );
306
+
307
+ expect(
308
+ shifts.map((entry: MaterializedShift) => {
309
+ return entry.userId;
310
+ }),
311
+ ).toEqual(["user-a", "user-b", "user-a"]);
312
+
313
+ const blocks: Array<Array<string>> = eventBlocks(
314
+ renderSchedule(shifts, "Payments"),
315
+ );
316
+ expect(blocks).toHaveLength(3);
317
+
318
+ const uids: Array<string> = blocks.map((block: Array<string>) => {
319
+ return blockProperty(block, "UID")!;
320
+ });
321
+ expect(new Set(uids).size).toBe(3);
322
+
323
+ expect(blockProperty(blocks[0]!, "DTSTART")).toBe("20260302T090000Z");
324
+ expect(blockProperty(blocks[0]!, "DTEND")).toBe("20260302T120000Z");
325
+ expect(blockProperty(blocks[1]!, "DTSTART")).toBe("20260302T120000Z");
326
+ expect(blockProperty(blocks[1]!, "DTEND")).toBe("20260302T130000Z");
327
+ expect(blockProperty(blocks[2]!, "DTSTART")).toBe("20260302T130000Z");
328
+ expect(blockProperty(blocks[2]!, "DTEND")).toBe("20260302T170000Z");
329
+
330
+ expect(blockProperty(blocks[1]!, "SUMMARY")).toBe(
331
+ "Name of user-b · On-call · Payments (covering for Name of user-a)",
332
+ );
333
+ });
334
+
335
+ test("gap UIDs live in their own namespace", () => {
336
+ expect(OnCallCalendarFeedUtil.getGapUid("sched-1", start)).toBe(
337
+ `oncall-gap-sched-1-${epoch(start)}@oneuptime`,
338
+ );
339
+ });
340
+ });
341
+
342
+ describe("OnCallCalendarFeedUtil: SEQUENCE, DTSTAMP and forbidden properties", () => {
343
+ const one: MaterializedShift = shift({
344
+ start: at("2026-09-01T07:00:00Z"),
345
+ end: at("2026-09-01T15:00:00Z"),
346
+ shiftConfigVersion: 7,
347
+ lastModifiedAt: at("2026-08-15T12:34:56Z"),
348
+ });
349
+
350
+ test("SEQUENCE is the shiftConfigVersion and DTSTAMP/LAST-MODIFIED the input's lastModifiedAt", () => {
351
+ const block: Array<string> = eventBlocks(renderPersonal([one]))[0]!;
352
+
353
+ expect(blockProperty(block, "SEQUENCE")).toBe("7");
354
+ expect(blockProperty(block, "DTSTAMP")).toBe("20260815T123456Z");
355
+ expect(blockProperty(block, "LAST-MODIFIED")).toBe("20260815T123456Z");
356
+ });
357
+
358
+ test("a negative or fractional version never produces an invalid SEQUENCE", () => {
359
+ const negative: MaterializedShift = { ...one, shiftConfigVersion: -3 };
360
+ const fractional: MaterializedShift = { ...one, shiftConfigVersion: 2.9 };
361
+
362
+ expect(
363
+ blockProperty(eventBlocks(renderPersonal([negative]))[0]!, "SEQUENCE"),
364
+ ).toBe("0");
365
+ expect(
366
+ blockProperty(eventBlocks(renderPersonal([fractional]))[0]!, "SEQUENCE"),
367
+ ).toBe("2");
368
+ });
369
+
370
+ test("the calendar LAST-MODIFIED is the latest shift modification", () => {
371
+ const older: MaterializedShift = shift({
372
+ start: at("2026-09-02T07:00:00Z"),
373
+ end: at("2026-09-02T15:00:00Z"),
374
+ lastModifiedAt: at("2026-07-01T00:00:00Z"),
375
+ });
376
+
377
+ const result: FeedRenderResult = OnCallCalendarFeedUtil.render({
378
+ ...personalContext(),
379
+ shifts: [older, one],
380
+ });
381
+
382
+ expect(property(result.body, "LAST-MODIFIED")).toBe("20260815T123456Z");
383
+ expect(result.lastModifiedAt!.toISOString()).toBe(
384
+ "2026-08-15T12:34:56.000Z",
385
+ );
386
+ expect(result.eventCount).toBe(2);
387
+ });
388
+
389
+ test("neither feed kind emits METHOD, CLASS, VALARM, RRULE or VTIMEZONE", () => {
390
+ for (const body of [
391
+ renderPersonal([one]),
392
+ renderSchedule([one], "Payments"),
393
+ ]) {
394
+ for (const forbidden of [
395
+ "METHOD",
396
+ "CLASS",
397
+ "VALARM",
398
+ "RRULE",
399
+ "VTIMEZONE",
400
+ "TZID",
401
+ ]) {
402
+ expect(body).not.toContain(`${forbidden}:`);
403
+ expect(body).not.toContain(`BEGIN:${forbidden}`);
404
+ }
405
+ expect(body).toContain("TRANSP:TRANSPARENT");
406
+ expect(body).toContain("STATUS:CONFIRMED");
407
+ expect(body).toContain("CATEGORIES:On-Call");
408
+ }
409
+ });
410
+
411
+ test("every DTSTART/DTEND is an absolute UTC instant", () => {
412
+ const body: string = renderPersonal([one]);
413
+ for (const value of [
414
+ ...properties(body, "DTSTART"),
415
+ ...properties(body, "DTEND"),
416
+ ]) {
417
+ expect(value).toMatch(/^\d{8}T\d{6}Z$/);
418
+ }
419
+ });
420
+ });
421
+
422
+ describe("OnCallCalendarFeedUtil: calendar names and descriptions", () => {
423
+ test("personal feed without and with a schedule filter", () => {
424
+ expect(
425
+ OnCallCalendarFeedUtil.buildCalendarName({ kind: Kind.Personal }),
426
+ ).toEqual({ name: "OneUptime On-Call", displayName: "OneUptime On-Call" });
427
+
428
+ expect(
429
+ OnCallCalendarFeedUtil.buildCalendarName({
430
+ kind: Kind.Personal,
431
+ filterScheduleName: "Payments",
432
+ }),
433
+ ).toEqual({
434
+ name: "OneUptime On-Call · Payments",
435
+ displayName: "OneUptime On-Call · Payments",
436
+ });
437
+
438
+ const body: string = OnCallCalendarFeedUtil.render({
439
+ ...personalContext(),
440
+ shifts: [],
441
+ filterScheduleName: "Payments",
442
+ }).body;
443
+ expect(property(body, "X-WR-CALNAME")).toBe("OneUptime On-Call · Payments");
444
+ expect(property(body, "X-WR-CALDESC")).toContain(
445
+ "Your on-call shifts on Payments from OneUptime.",
446
+ );
447
+ });
448
+
449
+ test("schedule feed truncates X-WR-CALNAME to 28 characters but keeps NAME in full", () => {
450
+ const longName: string = "Platform Infrastructure Primary Rotation EMEA";
451
+ expect(Array.from(longName).length).toBeGreaterThan(28);
452
+
453
+ const names: { name: string; displayName: string } =
454
+ OnCallCalendarFeedUtil.buildCalendarName({
455
+ kind: Kind.Schedule,
456
+ scheduleName: longName,
457
+ });
458
+
459
+ expect(names.name).toBe(longName);
460
+ expect(Array.from(names.displayName).length).toBeLessThanOrEqual(28);
461
+ expect(names.displayName.endsWith("…")).toBe(true);
462
+ expect(names.displayName).toBe("Platform Infrastructure Pri…");
463
+
464
+ const body: string = renderSchedule([], longName);
465
+ expect(property(body, "NAME")).toBe(longName);
466
+ expect(property(body, "X-WR-CALNAME")).toBe("Platform Infrastructure Pri…");
467
+ expect(property(body, "X-WR-CALDESC")).toContain(
468
+ `Everyone's on-call shifts on ${longName} from OneUptime.`,
469
+ );
470
+ });
471
+
472
+ test("a short schedule name is not truncated", () => {
473
+ const names: { name: string; displayName: string } =
474
+ OnCallCalendarFeedUtil.buildCalendarName({
475
+ kind: Kind.Schedule,
476
+ scheduleName: "Payments",
477
+ });
478
+ expect(names).toEqual({ name: "Payments", displayName: "Payments" });
479
+ });
480
+
481
+ test("project feed", () => {
482
+ const body: string = OnCallCalendarFeedUtil.render({
483
+ kind: Kind.Project,
484
+ dashboardUrl: DASHBOARD_URL,
485
+ shifts: [],
486
+ projectName: "Acme Corp",
487
+ }).body;
488
+
489
+ expect(property(body, "X-WR-CALNAME")).toBe(
490
+ "OneUptime On-Call · Acme Corp",
491
+ );
492
+ expect(property(body, "X-WR-CALDESC")).toContain(
493
+ "Everyone's on-call shifts across Acme Corp from OneUptime.",
494
+ );
495
+ });
496
+
497
+ test("notes are appended to X-WR-CALDESC and REFRESH hints are always present", () => {
498
+ const body: string = OnCallCalendarFeedUtil.render({
499
+ ...personalContext(),
500
+ shifts: [],
501
+ notes: [" Shortened to 30 days. ", "", "Only 100 gaps shown."],
502
+ }).body;
503
+
504
+ expect(property(body, "X-WR-CALDESC")).toBe(
505
+ `Your on-call shifts from OneUptime. ${ICalendar.escapeText(
506
+ OnCallCalendarFeedUtil.REFRESH_CAVEAT,
507
+ )} Shortened to 30 days. Only 100 gaps shown.`,
508
+ );
509
+ expect(property(body, "REFRESH-INTERVAL")).toBe("PT1H");
510
+ expect(property(body, "X-PUBLISHED-TTL")).toBe("PT1H");
511
+ });
512
+
513
+ test("X-WR-TIMEZONE uses the calendar timezone, then the viewer's, then UTC; invalid zones fall back", () => {
514
+ expect(
515
+ property(
516
+ OnCallCalendarFeedUtil.render({
517
+ ...personalContext("Europe/Stockholm"),
518
+ shifts: [],
519
+ }).body,
520
+ "X-WR-TIMEZONE",
521
+ ),
522
+ ).toBe("Europe/Stockholm");
523
+
524
+ expect(
525
+ property(
526
+ OnCallCalendarFeedUtil.render({
527
+ ...personalContext("Europe/Stockholm"),
528
+ calendarTimezone: "Asia/Kolkata",
529
+ shifts: [],
530
+ }).body,
531
+ "X-WR-TIMEZONE",
532
+ ),
533
+ ).toBe("Asia/Kolkata");
534
+
535
+ expect(
536
+ property(
537
+ OnCallCalendarFeedUtil.render({ ...personalContext(), shifts: [] })
538
+ .body,
539
+ "X-WR-TIMEZONE",
540
+ ),
541
+ ).toBe("UTC");
542
+
543
+ expect(
544
+ property(
545
+ OnCallCalendarFeedUtil.render({
546
+ ...personalContext("Mars/Olympus_Mons"),
547
+ shifts: [],
548
+ }).body,
549
+ "X-WR-TIMEZONE",
550
+ ),
551
+ ).toBe("UTC");
552
+ });
553
+
554
+ test("renderEmpty produces a header-only calendar that explains itself", () => {
555
+ const body: string = OnCallCalendarFeedUtil.renderEmpty({
556
+ kind: Kind.Personal,
557
+ reason: "This feed is disabled.",
558
+ });
559
+
560
+ expect(body).not.toContain("BEGIN:VEVENT");
561
+ expect(property(body, "X-WR-CALNAME")).toBe("OneUptime On-Call");
562
+ expect(property(body, "X-WR-CALDESC")).toContain("This feed is disabled.");
563
+ expect(body).not.toContain("LAST-MODIFIED");
564
+ expect(body.endsWith("END:VCALENDAR\r\n")).toBe(true);
565
+ });
566
+ });
567
+
568
+ describe("OnCallCalendarFeedUtil: SUMMARY and DESCRIPTION rules", () => {
569
+ const start: Date = at("2026-09-01T07:00:00Z");
570
+ const end: Date = at("2026-09-01T15:00:00Z");
571
+
572
+ test("personal SUMMARY adds the policy only when exactly one distinct policy is attached", () => {
573
+ const one: MaterializedShift = shift({ start, end });
574
+ const sameTwice: MaterializedShift = shift({
575
+ start,
576
+ end,
577
+ policies: [
578
+ { ...DEFAULT_POLICY },
579
+ {
580
+ ...DEFAULT_POLICY,
581
+ ruleId: "rule-2",
582
+ ruleName: "Backup",
583
+ ruleOrder: 2,
584
+ },
585
+ ],
586
+ });
587
+ const two: MaterializedShift = shift({
588
+ start,
589
+ end,
590
+ policies: [
591
+ { ...DEFAULT_POLICY },
592
+ {
593
+ policyId: "pol-2",
594
+ policyName: "Billing Policy",
595
+ ruleId: "rule-9",
596
+ ruleName: "Primary",
597
+ ruleOrder: 1,
598
+ },
599
+ ],
600
+ });
601
+ const none: MaterializedShift = shift({ start, end, policies: [] });
602
+
603
+ expect(OnCallCalendarFeedUtil.buildSummary(one, Kind.Personal)).toBe(
604
+ "On-call · Payments · Payments Policy",
605
+ );
606
+ expect(OnCallCalendarFeedUtil.buildSummary(sameTwice, Kind.Personal)).toBe(
607
+ "On-call · Payments · Payments Policy",
608
+ );
609
+ expect(OnCallCalendarFeedUtil.buildSummary(two, Kind.Personal)).toBe(
610
+ "On-call · Payments",
611
+ );
612
+ expect(OnCallCalendarFeedUtil.buildSummary(none, Kind.Personal)).toBe(
613
+ "On-call · Payments",
614
+ );
615
+ });
616
+
617
+ test("schedule and project SUMMARY lead with the user's name and never add the single policy", () => {
618
+ const one: MaterializedShift = shift({ start, end });
619
+
620
+ expect(OnCallCalendarFeedUtil.buildSummary(one, Kind.Schedule)).toBe(
621
+ "Alice Andersson · On-call · Payments",
622
+ );
623
+ expect(OnCallCalendarFeedUtil.buildSummary(one, Kind.Project)).toBe(
624
+ "Alice Andersson · On-call · Payments",
625
+ );
626
+ });
627
+
628
+ test("the schedule feed never shows an email address; the personal feed may", () => {
629
+ const emailUser: MaterializedShift = shift({
630
+ start,
631
+ end,
632
+ userId: "user-e",
633
+ userName: "eve@example.com",
634
+ override: {
635
+ originalUserId: "user-f",
636
+ originalUserName: "frank@example.com",
637
+ overrideStartsAt: start,
638
+ overrideEndsAt: end,
639
+ },
640
+ });
641
+
642
+ const scheduleBody: string = renderSchedule([emailUser], "Payments");
643
+ expect(scheduleBody).not.toContain("eve@example.com");
644
+ expect(scheduleBody).not.toContain("frank@example.com");
645
+ expect(blockProperty(eventBlocks(scheduleBody)[0]!, "SUMMARY")).toBe(
646
+ "Unnamed user · On-call · Payments (covering for Unnamed user)",
647
+ );
648
+
649
+ const personalBody: string = renderPersonal([emailUser]);
650
+ expect(blockProperty(eventBlocks(personalBody)[0]!, "SUMMARY")).toBe(
651
+ "On-call · Payments · Payments Policy (covering for frank@example.com)",
652
+ );
653
+ expect(
654
+ blockProperty(eventBlocks(personalBody)[0]!, "DESCRIPTION"),
655
+ ).toContain("Who: eve@example.com\\, covering for frank@example.com");
656
+ });
657
+
658
+ test("getDisplayName handles blanks, whitespace and emails per feed kind", () => {
659
+ expect(OnCallCalendarFeedUtil.getDisplayName("", Kind.Personal)).toBe(
660
+ "Unnamed user",
661
+ );
662
+ expect(OnCallCalendarFeedUtil.getDisplayName(" ", Kind.Schedule)).toBe(
663
+ "Unnamed user",
664
+ );
665
+ expect(OnCallCalendarFeedUtil.getDisplayName(undefined, Kind.Project)).toBe(
666
+ "Unnamed user",
667
+ );
668
+ expect(
669
+ OnCallCalendarFeedUtil.getDisplayName(" Ada ", Kind.Schedule),
670
+ ).toBe("Ada");
671
+ expect(OnCallCalendarFeedUtil.getDisplayName("a@b.io", Kind.Schedule)).toBe(
672
+ "Unnamed user",
673
+ );
674
+ expect(OnCallCalendarFeedUtil.getDisplayName("a@b.io", Kind.Project)).toBe(
675
+ "Unnamed user",
676
+ );
677
+ expect(OnCallCalendarFeedUtil.getDisplayName("a@b.io", Kind.Personal)).toBe(
678
+ "a@b.io",
679
+ );
680
+ });
681
+
682
+ test("DESCRIPTION lists every policy rule sorted, or says the schedule pages nobody", () => {
683
+ const multi: MaterializedShift = shift({
684
+ start,
685
+ end,
686
+ policies: [
687
+ {
688
+ policyId: "pol-2",
689
+ policyName: "Billing Policy",
690
+ ruleId: "r-b2",
691
+ ruleName: "Backup",
692
+ ruleOrder: 2,
693
+ },
694
+ { ...DEFAULT_POLICY },
695
+ {
696
+ policyId: "pol-2",
697
+ policyName: "Billing Policy",
698
+ ruleId: "r-b1",
699
+ ruleName: "Primary",
700
+ ruleOrder: 1,
701
+ },
702
+ ],
703
+ });
704
+
705
+ const description: string = OnCallCalendarFeedUtil.buildDescription(
706
+ multi,
707
+ personalContext(),
708
+ );
709
+ expect(description).toContain(
710
+ "Pages you via: Billing Policy › Primary (step 1); Billing Policy › Backup (step 2); Payments Policy › Primary (step 1)",
711
+ );
712
+
713
+ const scheduleDescription: string = OnCallCalendarFeedUtil.buildDescription(
714
+ multi,
715
+ {
716
+ kind: Kind.Schedule,
717
+ dashboardUrl: DASHBOARD_URL,
718
+ },
719
+ );
720
+ expect(scheduleDescription).toContain(
721
+ "Pages via: Billing Policy › Primary (step 1)",
722
+ );
723
+
724
+ const orphan: MaterializedShift = shift({ start, end, policies: [] });
725
+ expect(
726
+ OnCallCalendarFeedUtil.buildDescription(orphan, personalContext()),
727
+ ).toContain(OnCallCalendarFeedUtil.NO_POLICY_LINE);
728
+ });
729
+
730
+ test("Layer line appears only when the shift carries a layer name", () => {
731
+ const withLayer: MaterializedShift = shift({
732
+ start,
733
+ end,
734
+ layerId: "layer-1",
735
+ layerName: "Weekday primary",
736
+ });
737
+ const withoutLayer: MaterializedShift = shift({ start, end });
738
+
739
+ expect(
740
+ OnCallCalendarFeedUtil.buildDescription(withLayer, personalContext()),
741
+ ).toContain("\nLayer: Weekday primary\n");
742
+ expect(
743
+ OnCallCalendarFeedUtil.buildDescription(withoutLayer, personalContext()),
744
+ ).not.toContain("Layer:");
745
+ });
746
+
747
+ test("the past-shift line and Time Log link appear only for past shifts", () => {
748
+ const past: MaterializedShift = shift({ start, end, isPast: true });
749
+ const future: MaterializedShift = shift({ start, end, isPast: false });
750
+
751
+ const pastDescription: string = OnCallCalendarFeedUtil.buildDescription(
752
+ past,
753
+ personalContext(),
754
+ );
755
+ expect(pastDescription).toContain(OnCallCalendarFeedUtil.PAST_SHIFT_LINE);
756
+ expect(pastDescription).toContain(
757
+ "https://oneuptime.example.com/dashboard/proj-1/on-call-duty/user-time-logs",
758
+ );
759
+
760
+ expect(
761
+ OnCallCalendarFeedUtil.buildDescription(future, personalContext()),
762
+ ).not.toContain("Past shifts");
763
+ });
764
+
765
+ test("a policy-scoped override names its policy; an unknown policy id degrades gracefully", () => {
766
+ const scoped: MaterializedShift = shift({
767
+ start,
768
+ end,
769
+ userId: "user-b",
770
+ userName: "Bob",
771
+ override: {
772
+ originalUserId: "user-a",
773
+ originalUserName: "Alice",
774
+ overrideStartsAt: start,
775
+ overrideEndsAt: end,
776
+ onCallDutyPolicyId: "pol-1",
777
+ },
778
+ });
779
+
780
+ expect(
781
+ OnCallCalendarFeedUtil.buildDescription(scoped, personalContext()),
782
+ ).toContain("(scoped to Payments Policy)");
783
+
784
+ const unknown: MaterializedShift = {
785
+ ...scoped,
786
+ override: { ...scoped.override!, onCallDutyPolicyId: "pol-missing" },
787
+ };
788
+ expect(
789
+ OnCallCalendarFeedUtil.buildDescription(unknown, personalContext()),
790
+ ).toContain("(scoped to an escalation policy)");
791
+ });
792
+
793
+ test("URL points at the schedule page and a trailing slash on the dashboard URL is tolerated", () => {
794
+ const one: MaterializedShift = shift({ start, end });
795
+ const event: ICalendarEvent = OnCallCalendarFeedUtil.shiftToEvent(one, {
796
+ kind: Kind.Personal,
797
+ dashboardUrl: `${DASHBOARD_URL}/`,
798
+ });
799
+
800
+ expect(event.url).toBe(
801
+ "https://oneuptime.example.com/dashboard/proj-1/on-call-duty/schedules/sched-1",
802
+ );
803
+ expect(event.description).toContain(
804
+ "Need cover? https://oneuptime.example.com/dashboard/proj-1/on-call-duty/user-overrides",
805
+ );
806
+ });
807
+
808
+ test("the viewer zone line is skipped when it equals the schedule zone or UTC", () => {
809
+ const stockholm: MaterializedShift = shift({ start, end });
810
+
811
+ const sameZone: string = OnCallCalendarFeedUtil.buildDescription(
812
+ stockholm,
813
+ personalContext("Europe/Stockholm"),
814
+ );
815
+ expect(sameZone).not.toContain("(your zone)");
816
+ expect(sameZone).toContain("Shift in UTC:");
817
+
818
+ const utcViewer: string = OnCallCalendarFeedUtil.buildDescription(
819
+ stockholm,
820
+ personalContext("UTC"),
821
+ );
822
+ // The viewer's zone IS UTC, so the one UTC line is labelled as theirs.
823
+ expect(utcViewer).toContain("Shift in UTC (your zone):");
824
+ expect(utcViewer.match(/Shift in /g)).toHaveLength(1);
825
+
826
+ const utcSchedule: MaterializedShift = shift({
827
+ start,
828
+ end,
829
+ scheduleTimezone: "UTC",
830
+ });
831
+ const utcScheduleDescription: string =
832
+ OnCallCalendarFeedUtil.buildDescription(
833
+ utcSchedule,
834
+ personalContext("America/New_York"),
835
+ );
836
+ expect(utcScheduleDescription).toContain("(UTC — schedule zone)");
837
+ expect(utcScheduleDescription).not.toContain("Shift in UTC:");
838
+ expect(utcScheduleDescription).toContain(
839
+ "Shift in America/New_York (your zone):",
840
+ );
841
+ });
842
+ });
843
+
844
+ describe("OnCallCalendarFeedUtil: legacy schedules without a timezone", () => {
845
+ test("render in UTC with the explanatory note", () => {
846
+ const legacy: MaterializedShift = shift({
847
+ start: at("2026-09-01T07:00:00Z"),
848
+ end: at("2026-09-01T15:00:00Z"),
849
+ scheduleTimezone: undefined,
850
+ });
851
+
852
+ expect(OnCallCalendarFeedUtil.getScheduleZone(legacy)).toBe(Timezone.UTC);
853
+
854
+ const description: string = OnCallCalendarFeedUtil.buildDescription(
855
+ legacy,
856
+ personalContext("America/New_York"),
857
+ );
858
+
859
+ expect(description).toContain(
860
+ `Schedule: Payments (${OnCallCalendarFeedUtil.LEGACY_TIMEZONE_NOTE})`,
861
+ );
862
+ expect(description).toContain(
863
+ `Shift: Sep 01 2026, 07:00 UTC → Sep 01 2026, 15:00 UTC (UTC — ${OnCallCalendarFeedUtil.LEGACY_TIMEZONE_NOTE})`,
864
+ );
865
+ expect(description).toContain(
866
+ "Shift in America/New_York (your zone): Sep 01 2026, 03:00 EDT → Sep 01 2026, 11:00 EDT",
867
+ );
868
+ });
869
+
870
+ test("an unrecognised schedule timezone is treated the same way", () => {
871
+ const bogus: MaterializedShift = shift({
872
+ start: at("2026-09-01T07:00:00Z"),
873
+ end: at("2026-09-01T15:00:00Z"),
874
+ scheduleTimezone: "Not/AZone",
875
+ });
876
+
877
+ expect(OnCallCalendarFeedUtil.getScheduleZone(bogus)).toBe("UTC");
878
+ expect(
879
+ OnCallCalendarFeedUtil.buildDescription(bogus, personalContext()),
880
+ ).toContain(OnCallCalendarFeedUtil.LEGACY_TIMEZONE_NOTE);
881
+ });
882
+ });
883
+
884
+ describe("OnCallCalendarFeedUtil: DST", () => {
885
+ interface DstCase {
886
+ zone: string;
887
+ day: string;
888
+ abbreviation: string;
889
+ utcStart: string;
890
+ }
891
+
892
+ const cases: Array<DstCase> = [
893
+ // America/New_York springs forward 2026-03-08 02:00 -> 09:00 is EDT (UTC-4).
894
+ {
895
+ zone: "America/New_York",
896
+ day: "2026-03-08",
897
+ abbreviation: "EDT",
898
+ utcStart: "20260308T130000Z",
899
+ },
900
+ // Day before: still EST (UTC-5).
901
+ {
902
+ zone: "America/New_York",
903
+ day: "2026-03-07",
904
+ abbreviation: "EST",
905
+ utcStart: "20260307T140000Z",
906
+ },
907
+ // Falls back 2026-11-01 02:00 -> 09:00 is EST again.
908
+ {
909
+ zone: "America/New_York",
910
+ day: "2026-11-01",
911
+ abbreviation: "EST",
912
+ utcStart: "20261101T140000Z",
913
+ },
914
+ {
915
+ zone: "America/New_York",
916
+ day: "2026-10-31",
917
+ abbreviation: "EDT",
918
+ utcStart: "20261031T130000Z",
919
+ },
920
+ // Europe/Stockholm springs forward 2026-03-29 02:00 -> 09:00 is CEST (UTC+2).
921
+ {
922
+ zone: "Europe/Stockholm",
923
+ day: "2026-03-29",
924
+ abbreviation: "CEST",
925
+ utcStart: "20260329T070000Z",
926
+ },
927
+ {
928
+ zone: "Europe/Stockholm",
929
+ day: "2026-03-28",
930
+ abbreviation: "CET",
931
+ utcStart: "20260328T080000Z",
932
+ },
933
+ // Falls back 2026-10-25 03:00 -> 09:00 is CET (UTC+1).
934
+ {
935
+ zone: "Europe/Stockholm",
936
+ day: "2026-10-25",
937
+ abbreviation: "CET",
938
+ utcStart: "20261025T080000Z",
939
+ },
940
+ {
941
+ zone: "Europe/Stockholm",
942
+ day: "2026-10-24",
943
+ abbreviation: "CEST",
944
+ utcStart: "20261024T070000Z",
945
+ },
946
+ ];
947
+
948
+ test.each(cases)(
949
+ "a 09:00-17:00 shift on $day in $zone renders the right UTC instant and wall clock",
950
+ (dstCase: DstCase) => {
951
+ const oneShift: MaterializedShift = shift({
952
+ start: tzInstant(`${dstCase.day} 09:00`, dstCase.zone),
953
+ end: tzInstant(`${dstCase.day} 17:00`, dstCase.zone),
954
+ scheduleTimezone: dstCase.zone,
955
+ });
956
+
957
+ const block: Array<string> = eventBlocks(renderPersonal([oneShift]))[0]!;
958
+
959
+ expect(blockProperty(block, "DTSTART")).toBe(dstCase.utcStart);
960
+ expect(hhmm(oneShift.start, dstCase.zone)).toBe("09:00");
961
+ expect(hhmm(oneShift.end, dstCase.zone)).toBe("17:00");
962
+
963
+ const description: string = blockProperty(block, "DESCRIPTION")!;
964
+ const [month, dayOfMonth]: Array<string> = [
965
+ [
966
+ "Jan",
967
+ "Feb",
968
+ "Mar",
969
+ "Apr",
970
+ "May",
971
+ "Jun",
972
+ "Jul",
973
+ "Aug",
974
+ "Sep",
975
+ "Oct",
976
+ "Nov",
977
+ "Dec",
978
+ ][Number(dstCase.day.slice(5, 7)) - 1]!,
979
+ dstCase.day.slice(8, 10),
980
+ ];
981
+ expect(description).toContain(
982
+ `Shift: ${month} ${dayOfMonth} 2026\\, 09:00 ${dstCase.abbreviation} → ${month} ${dayOfMonth} 2026\\, 17:00 ${dstCase.abbreviation} (${dstCase.zone} — schedule zone)`,
983
+ );
984
+ },
985
+ );
986
+
987
+ test("a shift straddling the New York spring-forward gap is 23 hours long and still renders both walls", () => {
988
+ const zone: string = "America/New_York";
989
+ const oneShift: MaterializedShift = shift({
990
+ start: tzInstant("2026-03-07 09:00", zone),
991
+ end: tzInstant("2026-03-08 09:00", zone),
992
+ scheduleTimezone: zone,
993
+ });
994
+
995
+ expect((oneShift.end.getTime() - oneShift.start.getTime()) / 3600000).toBe(
996
+ 23,
997
+ );
998
+
999
+ const block: Array<string> = eventBlocks(renderPersonal([oneShift]))[0]!;
1000
+ expect(blockProperty(block, "DTSTART")).toBe("20260307T140000Z");
1001
+ expect(blockProperty(block, "DTEND")).toBe("20260308T130000Z");
1002
+ expect(blockProperty(block, "DESCRIPTION")).toContain(
1003
+ "Shift: Mar 07 2026\\, 09:00 EST → Mar 08 2026\\, 09:00 EDT (America/New_York — schedule zone)",
1004
+ );
1005
+ });
1006
+
1007
+ test("engine-derived Stockholm office hours across the March change keep 09:00-17:00 walls and UTC DTSTARTs move", () => {
1008
+ const zone: string = "Europe/Stockholm";
1009
+ const windowStart: Date = tzInstant("2026-03-27 00:00", zone); // Friday
1010
+ const windowEnd: Date = tzInstant("2026-04-01 00:00", zone);
1011
+
1012
+ const shifts: Array<MaterializedShift> = materializeLayers(
1013
+ [
1014
+ {
1015
+ users: [user("user-a")],
1016
+ startDateTimeOfLayer: windowStart,
1017
+ restrictionTimes: businessHoursRestriction(zone),
1018
+ handOffTime: tzInstant("2026-03-27 09:00", zone),
1019
+ rotation: rotation(EventInterval.Week, 1),
1020
+ timezone: zone,
1021
+ },
1022
+ ],
1023
+ windowStart,
1024
+ windowEnd,
1025
+ undefined,
1026
+ { scheduleTimezone: zone },
1027
+ );
1028
+
1029
+ // Fri 27, Mon 30, Tue 31.
1030
+ expect(shifts).toHaveLength(3);
1031
+ for (const entry of shifts) {
1032
+ expect(hhmm(entry.start, zone)).toBe("09:00");
1033
+ expect(hhmm(entry.end, zone)).toBe("17:00");
1034
+ }
1035
+
1036
+ const blocks: Array<Array<string>> = eventBlocks(renderPersonal(shifts));
1037
+ expect(blockProperty(blocks[0]!, "DTSTART")).toBe("20260327T080000Z"); // CET
1038
+ expect(blockProperty(blocks[1]!, "DTSTART")).toBe("20260330T070000Z"); // CEST
1039
+ expect(blockProperty(blocks[2]!, "DTSTART")).toBe("20260331T070000Z");
1040
+ });
1041
+ });
1042
+
1043
+ describe("OnCallCalendarFeedUtil: policy variants", () => {
1044
+ const start: Date = at("2026-09-01T07:00:00Z");
1045
+ const end: Date = at("2026-09-01T15:00:00Z");
1046
+
1047
+ const global: MaterializedShift = shift({ start, end });
1048
+
1049
+ const variant: MaterializedShift = shift({
1050
+ start,
1051
+ end,
1052
+ userId: "user-b",
1053
+ userName: "Bob Berg",
1054
+ policyVariantOf: {
1055
+ policyId: "pol-1",
1056
+ policyName: "Payments Policy",
1057
+ globalUserId: "user-a",
1058
+ },
1059
+ override: {
1060
+ originalUserId: "user-a",
1061
+ originalUserName: "Alice Andersson",
1062
+ overrideStartsAt: at("2026-09-01T00:00:00Z"),
1063
+ overrideEndsAt: at("2026-09-02T00:00:00Z"),
1064
+ onCallDutyPolicyId: "pol-1",
1065
+ },
1066
+ });
1067
+
1068
+ test("a variant gets a policy-suffixed UID and its own SUMMARY", () => {
1069
+ expect(OnCallCalendarFeedUtil.getShiftUid(variant)).toBe(
1070
+ `oncall-sched-1-${epoch(start)}-pol-1@oneuptime`,
1071
+ );
1072
+ expect(OnCallCalendarFeedUtil.buildSummary(variant, Kind.Personal)).toBe(
1073
+ "On-call · Payments · Payments Policy (covering for Alice Andersson)",
1074
+ );
1075
+ expect(OnCallCalendarFeedUtil.buildSummary(variant, Kind.Schedule)).toBe(
1076
+ "Bob Berg · On-call · Payments · Payments Policy (covering for Alice Andersson)",
1077
+ );
1078
+ });
1079
+
1080
+ test("the variant explains itself and the global event carries the mirror line", () => {
1081
+ const blocks: Array<Array<string>> = eventBlocks(
1082
+ renderSchedule([variant, global], "Payments"),
1083
+ );
1084
+
1085
+ expect(blocks).toHaveLength(2);
1086
+ const globalBlock: Array<string> = blocks.find((block: Array<string>) => {
1087
+ return !blockProperty(block, "UID")!.includes("-pol-1@");
1088
+ })!;
1089
+ const variantBlock: Array<string> = blocks.find((block: Array<string>) => {
1090
+ return blockProperty(block, "UID")!.includes("-pol-1@");
1091
+ })!;
1092
+
1093
+ expect(blockProperty(variantBlock, "DESCRIPTION")).toContain(
1094
+ "For Payments Policy\\, Bob Berg is paged instead of Alice Andersson because of a policy-specific override.",
1095
+ );
1096
+ expect(blockProperty(globalBlock, "DESCRIPTION")).toContain(
1097
+ "For Payments Policy\\, Bob Berg is paged instead from Sep 01 2026\\, 09:00 CEST to Sep 01 2026\\, 17:00 CEST.",
1098
+ );
1099
+ });
1100
+
1101
+ test("the personal feed phrases the variant for the subscriber", () => {
1102
+ const description: string = OnCallCalendarFeedUtil.buildDescription(
1103
+ variant,
1104
+ personalContext(),
1105
+ );
1106
+ expect(description).toContain(
1107
+ "For Payments Policy you are paged instead of Alice Andersson because of a policy-specific override.",
1108
+ );
1109
+ });
1110
+
1111
+ test("mirror lines only come from variants of the same schedule that overlap and swap the person", () => {
1112
+ const unrelated: MaterializedShift = {
1113
+ ...variant,
1114
+ scheduleId: "sched-2",
1115
+ shiftKey: "sched-2:x",
1116
+ };
1117
+ const nonOverlapping: MaterializedShift = {
1118
+ ...variant,
1119
+ start: at("2026-09-01T15:00:00Z"),
1120
+ end: at("2026-09-01T18:00:00Z"),
1121
+ };
1122
+ // A variant that pages the SAME person changes nothing for this event.
1123
+ const samePerson: MaterializedShift = {
1124
+ ...variant,
1125
+ userId: global.userId,
1126
+ userName: global.userName,
1127
+ };
1128
+
1129
+ const description: string = OnCallCalendarFeedUtil.buildDescription(
1130
+ global,
1131
+ { kind: Kind.Schedule, dashboardUrl: DASHBOARD_URL },
1132
+ [global, unrelated, nonOverlapping, samePerson],
1133
+ );
1134
+
1135
+ expect(description).not.toContain("is paged instead from");
1136
+ });
1137
+
1138
+ /*
1139
+ * The materializer records a single globalUserId on a variant — the FIRST
1140
+ * overlapping base user. A variant that runs across a handover takes over
1141
+ * part of the NEXT person's shift too, and that person has to be told: the
1142
+ * mirror line matches on overlap, not on that one recorded id.
1143
+ */
1144
+ test("a variant spanning a handover informs both base shifts, each about its own overlap", () => {
1145
+ const bob: MaterializedShift = shift({
1146
+ start: at("2026-09-01T10:00:00Z"),
1147
+ end: at("2026-09-01T11:00:00Z"),
1148
+ userId: "user-b",
1149
+ userName: "Bob Berg",
1150
+ });
1151
+
1152
+ const alice: MaterializedShift = shift({
1153
+ start: at("2026-09-01T11:00:00Z"),
1154
+ end: at("2026-09-01T15:00:00Z"),
1155
+ });
1156
+
1157
+ // Carol covers 10:00-12:00 UTC for Payments Policy only; recorded against Bob.
1158
+ const carol: MaterializedShift = shift({
1159
+ start: at("2026-09-01T10:00:00Z"),
1160
+ end: at("2026-09-01T12:00:00Z"),
1161
+ userId: "user-c",
1162
+ userName: "Carol Chen",
1163
+ policyVariantOf: {
1164
+ policyId: "pol-1",
1165
+ policyName: "Payments Policy",
1166
+ globalUserId: "user-b",
1167
+ },
1168
+ });
1169
+
1170
+ const all: Array<MaterializedShift> = [bob, alice, carol];
1171
+ const context: { kind: OnCallCalendarFeedKind; dashboardUrl: string } = {
1172
+ kind: Kind.Schedule,
1173
+ dashboardUrl: DASHBOARD_URL,
1174
+ };
1175
+
1176
+ // Bob: the whole of his shift is taken over.
1177
+ expect(
1178
+ OnCallCalendarFeedUtil.buildDescription(bob, context, all),
1179
+ ).toContain(
1180
+ "For Payments Policy, Carol Chen is paged instead from Sep 01 2026, 12:00 CEST to Sep 01 2026, 13:00 CEST.",
1181
+ );
1182
+
1183
+ // Alice: only her first hour, and she is told exactly that.
1184
+ expect(
1185
+ OnCallCalendarFeedUtil.buildDescription(alice, context, all),
1186
+ ).toContain(
1187
+ "For Payments Policy, Carol Chen is paged instead from Sep 01 2026, 13:00 CEST to Sep 01 2026, 14:00 CEST.",
1188
+ );
1189
+ });
1190
+
1191
+ /*
1192
+ * The personal feed renders only the subscriber's own shifts, so the variant
1193
+ * that replaces them for one policy is not among them. contextShifts carries
1194
+ * it in without turning it into an event of its own.
1195
+ */
1196
+ test("the personal feed carries the mirror line through contextShifts", () => {
1197
+ const twoPolicies: Array<MaterializedShiftPolicy> = [
1198
+ { ...DEFAULT_POLICY },
1199
+ {
1200
+ policyId: "pol-2",
1201
+ policyName: "Checkout Policy",
1202
+ ruleId: "rule-2",
1203
+ ruleName: "Primary",
1204
+ ruleOrder: 1,
1205
+ },
1206
+ ];
1207
+
1208
+ const rostered: MaterializedShift = shift({
1209
+ start,
1210
+ end,
1211
+ policies: twoPolicies,
1212
+ });
1213
+
1214
+ const substitute: MaterializedShift = shift({
1215
+ start,
1216
+ end,
1217
+ userId: "user-b",
1218
+ userName: "Bob Berg",
1219
+ policies: twoPolicies,
1220
+ policyVariantOf: {
1221
+ policyId: "pol-1",
1222
+ policyName: "Payments Policy",
1223
+ globalUserId: "user-a",
1224
+ },
1225
+ });
1226
+
1227
+ const withContext: string = OnCallCalendarFeedUtil.render({
1228
+ ...personalContext(),
1229
+ shifts: [rostered],
1230
+ contextShifts: [rostered, substitute],
1231
+ }).body;
1232
+
1233
+ // Only the subscriber's own shift becomes an event...
1234
+ const blocks: Array<Array<string>> = eventBlocks(withContext);
1235
+ expect(blocks).toHaveLength(1);
1236
+
1237
+ // ...but it says who is really paged for pol-1 during it.
1238
+ const description: string = blockProperty(blocks[0]!, "DESCRIPTION")!;
1239
+ expect(description).toContain(
1240
+ "For Payments Policy\\, Bob Berg is paged instead from Sep 01 2026\\, 09:00 CEST to Sep 01 2026\\, 17:00 CEST.",
1241
+ );
1242
+ expect(description).toContain(
1243
+ "Pages you via: Checkout Policy › Primary (step 1)\\; Payments Policy › Primary (step 1)",
1244
+ );
1245
+
1246
+ // Without the context there is nothing to mirror.
1247
+ expect(
1248
+ blockProperty(eventBlocks(renderPersonal([rostered]))[0]!, "DESCRIPTION"),
1249
+ ).not.toContain("is paged instead from");
1250
+ });
1251
+
1252
+ test("a variant lists only the policy it pages through", () => {
1253
+ const twoPolicies: Array<MaterializedShiftPolicy> = [
1254
+ { ...DEFAULT_POLICY },
1255
+ {
1256
+ policyId: "pol-2",
1257
+ policyName: "Checkout Policy",
1258
+ ruleId: "rule-2",
1259
+ ruleName: "Primary",
1260
+ ruleOrder: 1,
1261
+ },
1262
+ ];
1263
+
1264
+ const multiPolicyVariant: MaterializedShift = shift({
1265
+ ...variant,
1266
+ start,
1267
+ end,
1268
+ policies: twoPolicies,
1269
+ });
1270
+
1271
+ const description: string = OnCallCalendarFeedUtil.buildDescription(
1272
+ multiPolicyVariant,
1273
+ personalContext(),
1274
+ );
1275
+
1276
+ expect(description).toContain(
1277
+ "Pages you via: Payments Policy › Primary (step 1)",
1278
+ );
1279
+ expect(description).not.toContain("Checkout Policy");
1280
+ expect(description).toContain(
1281
+ "For Payments Policy you are paged instead of Alice Andersson because of a policy-specific override.",
1282
+ );
1283
+
1284
+ // The base shift on the same schedule still lists both.
1285
+ expect(
1286
+ OnCallCalendarFeedUtil.buildDescription(
1287
+ shift({ start, end, policies: twoPolicies }),
1288
+ personalContext(),
1289
+ ),
1290
+ ).toContain(
1291
+ "Pages you via: Checkout Policy › Primary (step 1); Payments Policy › Primary (step 1)",
1292
+ );
1293
+ });
1294
+
1295
+ test("buildVariantIndex is null without variants and buckets by schedule otherwise", () => {
1296
+ expect(OnCallCalendarFeedUtil.buildVariantIndex([global])).toBeNull();
1297
+ expect(OnCallCalendarFeedUtil.buildVariantIndex([])).toBeNull();
1298
+
1299
+ const otherSchedule: MaterializedShift = {
1300
+ ...variant,
1301
+ scheduleId: "sched-2",
1302
+ shiftKey: "sched-2:x",
1303
+ };
1304
+
1305
+ const index: PolicyVariantIndex | null =
1306
+ OnCallCalendarFeedUtil.buildVariantIndex([
1307
+ global,
1308
+ otherSchedule,
1309
+ variant,
1310
+ ]);
1311
+
1312
+ expect(index).not.toBeNull();
1313
+ expect(index!.size).toBe(2);
1314
+ expect(index!.get("sched-1")).toEqual([variant]);
1315
+ expect(index!.get("sched-2")).toEqual([otherSchedule]);
1316
+ });
1317
+
1318
+ /*
1319
+ * The mirror pass used to rescan the whole shift array for every shift,
1320
+ * which is quadratic at feed sizes near MAX_EVENTS. One index per render.
1321
+ */
1322
+ test("the variant index is built once per render, not once per shift", () => {
1323
+ const many: Array<MaterializedShift> = [];
1324
+ for (let index: number = 0; index < 25; index++) {
1325
+ const shiftStart: Date = new Date(start.getTime() + index * 3600000);
1326
+ many.push(
1327
+ shift({
1328
+ start: shiftStart,
1329
+ end: new Date(shiftStart.getTime() + 3600000),
1330
+ }),
1331
+ );
1332
+ }
1333
+
1334
+ const spy: jest.SpyInstance = jest.spyOn(
1335
+ OnCallCalendarFeedUtil,
1336
+ "buildVariantIndex",
1337
+ );
1338
+
1339
+ try {
1340
+ const body: string = renderSchedule(many, "Payments");
1341
+ expect(eventBlocks(body)).toHaveLength(25);
1342
+ expect(spy).toHaveBeenCalledTimes(1);
1343
+ } finally {
1344
+ spy.mockRestore();
1345
+ }
1346
+ });
1347
+
1348
+ test("mirror lines are ordered deterministically whatever order the context arrives in", () => {
1349
+ const later: MaterializedShift = shift({
1350
+ start: at("2026-09-01T09:00:00Z"),
1351
+ end: at("2026-09-01T12:00:00Z"),
1352
+ userId: "user-c",
1353
+ userName: "Carol Chen",
1354
+ policyVariantOf: {
1355
+ policyId: "pol-2",
1356
+ policyName: "Checkout Policy",
1357
+ globalUserId: "user-a",
1358
+ },
1359
+ });
1360
+
1361
+ const context: { kind: OnCallCalendarFeedKind; dashboardUrl: string } = {
1362
+ kind: Kind.Schedule,
1363
+ dashboardUrl: DASHBOARD_URL,
1364
+ };
1365
+
1366
+ expect(
1367
+ OnCallCalendarFeedUtil.buildDescription(global, context, [
1368
+ variant,
1369
+ later,
1370
+ global,
1371
+ ]),
1372
+ ).toBe(
1373
+ OnCallCalendarFeedUtil.buildDescription(global, context, [
1374
+ later,
1375
+ global,
1376
+ variant,
1377
+ ]),
1378
+ );
1379
+ });
1380
+ });
1381
+
1382
+ describe("OnCallCalendarFeedUtil: window filtering and MAX_EVENTS shrink", () => {
1383
+ const feedStart: Date = at("2026-09-01T00:00:00Z");
1384
+ const feedEnd: Date = at("2026-09-11T00:00:00Z");
1385
+
1386
+ // Two 4-hour shifts per day for ten days = 20 shifts.
1387
+ function twentyShifts(): Array<MaterializedShift> {
1388
+ const shifts: Array<MaterializedShift> = [];
1389
+ for (let day: number = 0; day < 10; day++) {
1390
+ for (const hour of [8, 16]) {
1391
+ const start: Date = new Date(
1392
+ feedStart.getTime() + day * 86400000 + hour * 3600000,
1393
+ );
1394
+ shifts.push(
1395
+ shift({
1396
+ start,
1397
+ end: new Date(start.getTime() + 4 * 3600000),
1398
+ scheduleTimezone: UTC,
1399
+ }),
1400
+ );
1401
+ }
1402
+ }
1403
+ return shifts;
1404
+ }
1405
+
1406
+ test("filterShiftsToWindow keeps overlapping shifts unclipped", () => {
1407
+ const straddlesStart: MaterializedShift = shift({
1408
+ start: at("2026-08-31T20:00:00Z"),
1409
+ end: at("2026-09-01T04:00:00Z"),
1410
+ });
1411
+ const before: MaterializedShift = shift({
1412
+ start: at("2026-08-30T00:00:00Z"),
1413
+ end: at("2026-08-31T00:00:00Z"),
1414
+ });
1415
+ const touchesEnd: MaterializedShift = shift({
1416
+ start: at("2026-09-10T20:00:00Z"),
1417
+ end: at("2026-09-11T04:00:00Z"),
1418
+ });
1419
+ const startsAtEnd: MaterializedShift = shift({
1420
+ start: at("2026-09-11T00:00:00Z"),
1421
+ end: at("2026-09-11T08:00:00Z"),
1422
+ });
1423
+ const endsAtStart: MaterializedShift = shift({
1424
+ start: at("2026-08-31T16:00:00Z"),
1425
+ end: at("2026-09-01T00:00:00Z"),
1426
+ });
1427
+
1428
+ const kept: Array<MaterializedShift> =
1429
+ OnCallCalendarFeedUtil.filterShiftsToWindow(
1430
+ [startsAtEnd, touchesEnd, before, straddlesStart, endsAtStart],
1431
+ feedStart,
1432
+ feedEnd,
1433
+ );
1434
+
1435
+ expect(kept).toEqual([straddlesStart, touchesEnd]);
1436
+ expect(kept[0]!.start.toISOString()).toBe("2026-08-31T20:00:00.000Z");
1437
+ });
1438
+
1439
+ test("a feed under the cap is untouched", () => {
1440
+ const result: WindowShrinkResult = OnCallCalendarFeedUtil.shrinkWindowToFit(
1441
+ {
1442
+ shifts: twentyShifts(),
1443
+ feedStart,
1444
+ feedEnd,
1445
+ maxEvents: 20,
1446
+ },
1447
+ );
1448
+
1449
+ expect(result.truncated).toBe(false);
1450
+ expect(result.shifts).toHaveLength(20);
1451
+ expect(result.feedEnd.getTime()).toBe(feedEnd.getTime());
1452
+ expect(result.daysDropped).toBe(0);
1453
+ });
1454
+
1455
+ test("shrinks whole UTC days off the end until the feed fits", () => {
1456
+ const result: WindowShrinkResult = OnCallCalendarFeedUtil.shrinkWindowToFit(
1457
+ {
1458
+ shifts: twentyShifts(),
1459
+ feedStart,
1460
+ feedEnd,
1461
+ maxEvents: 7,
1462
+ },
1463
+ );
1464
+
1465
+ // 7 events would end mid-day-4; cut at the start of day 4 => 3 days, 6 events.
1466
+ expect(result.truncated).toBe(true);
1467
+ expect(result.feedEnd.toISOString()).toBe("2026-09-04T00:00:00.000Z");
1468
+ expect(result.shifts).toHaveLength(6);
1469
+ expect(result.daysDropped).toBe(7);
1470
+ expect(
1471
+ result.shifts.every((entry: MaterializedShift) => {
1472
+ return entry.start.getTime() < result.feedEnd.getTime();
1473
+ }),
1474
+ ).toBe(true);
1475
+ });
1476
+
1477
+ test("falls back to a hard cut when even the first day exceeds the cap", () => {
1478
+ const sameDay: Array<MaterializedShift> = [];
1479
+ for (let i: number = 0; i < 10; i++) {
1480
+ const start: Date = new Date(feedStart.getTime() + i * 3600000);
1481
+ sameDay.push(shift({ start, end: new Date(start.getTime() + 1800000) }));
1482
+ }
1483
+
1484
+ const result: WindowShrinkResult = OnCallCalendarFeedUtil.shrinkWindowToFit(
1485
+ {
1486
+ shifts: sameDay,
1487
+ feedStart,
1488
+ feedEnd,
1489
+ maxEvents: 4,
1490
+ },
1491
+ );
1492
+
1493
+ expect(result.truncated).toBe(true);
1494
+ expect(result.shifts).toHaveLength(4);
1495
+ expect(result.feedEnd.toISOString()).toBe("2026-09-01T04:00:00.000Z");
1496
+ expect(result.daysDropped).toBe(10);
1497
+ });
1498
+
1499
+ test("defaults to MAX_EVENTS", () => {
1500
+ const result: WindowShrinkResult = OnCallCalendarFeedUtil.shrinkWindowToFit(
1501
+ {
1502
+ shifts: twentyShifts(),
1503
+ feedStart,
1504
+ feedEnd,
1505
+ },
1506
+ );
1507
+ expect(result.truncated).toBe(false);
1508
+ expect(MAX_EVENTS).toBeGreaterThan(20);
1509
+ });
1510
+
1511
+ test("a rendered feed sorts shifts and gap events together by start then UID", () => {
1512
+ const later: MaterializedShift = shift({
1513
+ start: at("2026-09-02T07:00:00Z"),
1514
+ end: at("2026-09-02T15:00:00Z"),
1515
+ });
1516
+ const earlier: MaterializedShift = shift({
1517
+ start: at("2026-09-01T07:00:00Z"),
1518
+ end: at("2026-09-01T15:00:00Z"),
1519
+ });
1520
+ const gap: ICalendarEvent = OnCallCalendarFeedUtil.gapToEvent(
1521
+ { start: at("2026-09-01T15:00:00Z"), end: at("2026-09-02T07:00:00Z") },
1522
+ {
1523
+ scheduleId: "sched-1",
1524
+ scheduleName: "Payments",
1525
+ projectId: "proj-1",
1526
+ lastModifiedAt: DEFAULT_LAST_MODIFIED,
1527
+ shiftConfigVersion: 3,
1528
+ dashboardUrl: DASHBOARD_URL,
1529
+ },
1530
+ );
1531
+
1532
+ const document: ICalendarDocument = OnCallCalendarFeedUtil.buildDocument({
1533
+ kind: Kind.Schedule,
1534
+ dashboardUrl: DASHBOARD_URL,
1535
+ shifts: [later, earlier],
1536
+ scheduleName: "Payments",
1537
+ gapEvents: [gap],
1538
+ });
1539
+
1540
+ expect(
1541
+ document.events.map((event: ICalendarEvent) => {
1542
+ return event.start.toISOString();
1543
+ }),
1544
+ ).toEqual([
1545
+ "2026-09-01T07:00:00.000Z",
1546
+ "2026-09-01T15:00:00.000Z",
1547
+ "2026-09-02T07:00:00.000Z",
1548
+ ]);
1549
+ expect(document.events[1]!.summary).toBe("No coverage · Payments");
1550
+ });
1551
+ });
1552
+
1553
+ describe("OnCallCalendarFeedUtil: coverage envelope and gap events", () => {
1554
+ const feedStart: Date = at("2026-01-05T00:00:00Z"); // Monday
1555
+ const feedEnd: Date = at("2026-01-19T00:00:00Z"); // two weeks
1556
+
1557
+ const gapInput: {
1558
+ scheduleId: string;
1559
+ scheduleName: string;
1560
+ projectId: string;
1561
+ lastModifiedAt: Date;
1562
+ shiftConfigVersion: number;
1563
+ dashboardUrl: string;
1564
+ } = {
1565
+ scheduleId: "sched-1",
1566
+ scheduleName: "Payments",
1567
+ projectId: "proj-1",
1568
+ lastModifiedAt: DEFAULT_LAST_MODIFIED,
1569
+ shiftConfigVersion: 3,
1570
+ dashboardUrl: DASHBOARD_URL,
1571
+ };
1572
+
1573
+ function businessHoursLayer(users: Array<string>): LayerProps {
1574
+ return {
1575
+ users: users.map(user),
1576
+ startDateTimeOfLayer: feedStart,
1577
+ restrictionTimes: businessHoursRestriction(UTC),
1578
+ handOffTime: at("2026-01-05T09:00:00Z"),
1579
+ rotation: rotation(EventInterval.Week, 1),
1580
+ timezone: UTC,
1581
+ };
1582
+ }
1583
+
1584
+ function alwaysOnLayer(users: Array<string>, startsAt: Date): LayerProps {
1585
+ return {
1586
+ users: users.map(user),
1587
+ startDateTimeOfLayer: startsAt,
1588
+ restrictionTimes: noRestriction(),
1589
+ handOffTime: at("2026-01-05T09:00:00Z"),
1590
+ rotation: rotation(EventInterval.Week, 1),
1591
+ timezone: UTC,
1592
+ };
1593
+ }
1594
+
1595
+ test("the envelope of a business-hours layer is its Mon-Fri 09:00-17:00 windows", () => {
1596
+ const envelope: CoverageEnvelopeResult =
1597
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1598
+ layers: [businessHoursLayer(["a"])],
1599
+ windowStart: feedStart,
1600
+ windowEnd: feedEnd,
1601
+ });
1602
+
1603
+ expect(envelope.truncated).toBe(false);
1604
+ expect(envelope.segments).toHaveLength(10);
1605
+ for (const segment of envelope.segments) {
1606
+ expect(hhmm(segment.start, UTC)).toBe("09:00");
1607
+ expect(hhmm(segment.end, UTC)).toBe("17:00");
1608
+ }
1609
+ });
1610
+
1611
+ test("the envelope of an unrestricted layer is the whole window, even when the layer starts later or has nobody", () => {
1612
+ const envelope: CoverageEnvelopeResult =
1613
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1614
+ layers: [alwaysOnLayer([], at("2026-01-08T00:00:00Z"))],
1615
+ windowStart: feedStart,
1616
+ windowEnd: feedEnd,
1617
+ });
1618
+
1619
+ expect(envelope.segments).toHaveLength(1);
1620
+ expect(envelope.segments[0]!.start.getTime()).toBe(feedStart.getTime());
1621
+ expect(envelope.segments[0]!.end.getTime()).toBe(feedEnd.getTime());
1622
+ });
1623
+
1624
+ test("layers are unioned into one envelope", () => {
1625
+ const envelope: CoverageEnvelopeResult =
1626
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1627
+ layers: [businessHoursLayer(["a"]), alwaysOnLayer(["b"], feedStart)],
1628
+ windowStart: feedStart,
1629
+ windowEnd: feedEnd,
1630
+ });
1631
+
1632
+ expect(envelope.segments).toHaveLength(1);
1633
+ });
1634
+
1635
+ test("a business-hours schedule emits ZERO gap events for its off-hours", () => {
1636
+ const shifts: Array<MaterializedShift> = materializeLayers(
1637
+ [businessHoursLayer(["a", "b"])],
1638
+ feedStart,
1639
+ feedEnd,
1640
+ undefined,
1641
+ { scheduleTimezone: UTC },
1642
+ );
1643
+ expect(shifts).toHaveLength(10);
1644
+
1645
+ const envelope: CoverageEnvelopeResult =
1646
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1647
+ layers: [businessHoursLayer(["a", "b"])],
1648
+ windowStart: feedStart,
1649
+ windowEnd: feedEnd,
1650
+ });
1651
+
1652
+ const gaps: CoverageGapEventsResult =
1653
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1654
+ ...gapInput,
1655
+ shifts,
1656
+ feedStart,
1657
+ feedEnd,
1658
+ envelope: envelope.segments,
1659
+ minimumGapSeconds: 60 * 60,
1660
+ });
1661
+
1662
+ expect(gaps.events).toEqual([]);
1663
+ expect(gaps.gaps).toEqual([]);
1664
+ expect(gaps.truncated).toBe(false);
1665
+ });
1666
+
1667
+ test("a 24x7 schedule whose layer starts in the future emits exactly one gap event", () => {
1668
+ const startsAt: Date = at("2026-01-08T00:00:00Z");
1669
+
1670
+ const shifts: Array<MaterializedShift> = materializeLayers(
1671
+ [alwaysOnLayer(["a", "b"], startsAt)],
1672
+ feedStart,
1673
+ feedEnd,
1674
+ undefined,
1675
+ { scheduleTimezone: UTC },
1676
+ );
1677
+ expect(shifts[0]!.start.getTime()).toBe(startsAt.getTime());
1678
+
1679
+ const envelope: CoverageEnvelopeResult =
1680
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1681
+ layers: [alwaysOnLayer(["a", "b"], startsAt)],
1682
+ windowStart: feedStart,
1683
+ windowEnd: feedEnd,
1684
+ });
1685
+
1686
+ const gaps: CoverageGapEventsResult =
1687
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1688
+ ...gapInput,
1689
+ shifts,
1690
+ feedStart,
1691
+ feedEnd,
1692
+ envelope: envelope.segments,
1693
+ minimumGapSeconds: 60 * 60,
1694
+ });
1695
+
1696
+ expect(gaps.events).toHaveLength(1);
1697
+ expect(gaps.gaps[0]!.start.getTime()).toBe(feedStart.getTime());
1698
+ expect(gaps.gaps[0]!.end.getTime()).toBe(startsAt.getTime());
1699
+
1700
+ const event: ICalendarEvent = gaps.events[0]!;
1701
+ expect(event.uid).toBe(`oncall-gap-sched-1-${epoch(feedStart)}@oneuptime`);
1702
+ expect(event.summary).toBe("No coverage · Payments");
1703
+ expect(event.sequence).toBe(3);
1704
+ expect(event.dtStamp.getTime()).toBe(DEFAULT_LAST_MODIFIED.getTime());
1705
+ expect(event.description).toContain(
1706
+ "Fix the rotation: https://oneuptime.example.com/dashboard/proj-1/on-call-duty/schedules/sched-1",
1707
+ );
1708
+ expect(event.categories).toEqual(["On-Call"]);
1709
+ });
1710
+
1711
+ test("a single shift in a 24x7 window emits TWO gap events: before it and after it", () => {
1712
+ const envelope: Array<TimeSegment> = [{ start: feedStart, end: feedEnd }];
1713
+ const shiftStart: Date = at("2026-01-08T09:00:00Z");
1714
+ const shiftEnd: Date = at("2026-01-09T09:00:00Z");
1715
+
1716
+ const gaps: CoverageGapEventsResult =
1717
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1718
+ ...gapInput,
1719
+ shifts: [{ start: shiftStart, end: shiftEnd }],
1720
+ feedStart,
1721
+ feedEnd,
1722
+ envelope,
1723
+ minimumGapSeconds: 60 * 60,
1724
+ });
1725
+
1726
+ expect(
1727
+ gaps.gaps.map((gap: TimeSegment) => {
1728
+ return `${gap.start.toISOString()}/${gap.end.toISOString()}`;
1729
+ }),
1730
+ ).toEqual([
1731
+ "2026-01-05T00:00:00.000Z/2026-01-08T09:00:00.000Z",
1732
+ "2026-01-09T09:00:00.000Z/2026-01-19T00:00:00.000Z",
1733
+ ]);
1734
+ expect(gaps.events).toHaveLength(2);
1735
+ expect(gaps.events[0]!.uid).toBe(
1736
+ `oncall-gap-sched-1-${epoch(feedStart)}@oneuptime`,
1737
+ );
1738
+ expect(gaps.events[1]!.uid).toBe(
1739
+ `oncall-gap-sched-1-${epoch(shiftEnd)}@oneuptime`,
1740
+ );
1741
+ expect(gaps.events[1]!.start.getTime()).toBe(shiftEnd.getTime());
1742
+ expect(gaps.events[1]!.end.getTime()).toBe(feedEnd.getTime());
1743
+ expect(gaps.truncated).toBe(false);
1744
+ });
1745
+
1746
+ test("a rotation that stops mid-window reports its tail only where a layer intended coverage", () => {
1747
+ // Business hours Mon-Fri 09:00-17:00 for two weeks; the rotation ends Wed 14 Jan 17:00.
1748
+ const envelope: CoverageEnvelopeResult =
1749
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1750
+ layers: [businessHoursLayer(["a"])],
1751
+ windowStart: feedStart,
1752
+ windowEnd: feedEnd,
1753
+ });
1754
+
1755
+ const gaps: CoverageGapEventsResult =
1756
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1757
+ ...gapInput,
1758
+ shifts: [{ start: feedStart, end: at("2026-01-14T17:00:00Z") }],
1759
+ feedStart,
1760
+ feedEnd,
1761
+ envelope: envelope.segments,
1762
+ minimumGapSeconds: 60 * 60,
1763
+ });
1764
+
1765
+ // Thu 15 and Fri 16 are uncovered business days; the weekend is off-hours, not a gap.
1766
+ expect(
1767
+ gaps.gaps.map((gap: TimeSegment) => {
1768
+ return `${gap.start.toISOString()}/${gap.end.toISOString()}`;
1769
+ }),
1770
+ ).toEqual([
1771
+ "2026-01-15T09:00:00.000Z/2026-01-15T17:00:00.000Z",
1772
+ "2026-01-16T09:00:00.000Z/2026-01-16T17:00:00.000Z",
1773
+ ]);
1774
+ expect(gaps.events).toHaveLength(2);
1775
+ });
1776
+
1777
+ test("a trailing hole no longer than the engine's seam tolerance is not a gap", () => {
1778
+ const envelope: Array<TimeSegment> = [{ start: feedStart, end: feedEnd }];
1779
+
1780
+ const withinTolerance: CoverageGapEventsResult =
1781
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1782
+ ...gapInput,
1783
+ shifts: [
1784
+ { start: feedStart, end: new Date(feedEnd.getTime() - 5 * 1000) },
1785
+ ],
1786
+ feedStart,
1787
+ feedEnd,
1788
+ envelope,
1789
+ });
1790
+ expect(withinTolerance.gaps).toEqual([]);
1791
+
1792
+ const beyondTolerance: CoverageGapEventsResult =
1793
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1794
+ ...gapInput,
1795
+ shifts: [
1796
+ { start: feedStart, end: new Date(feedEnd.getTime() - 6 * 1000) },
1797
+ ],
1798
+ feedStart,
1799
+ feedEnd,
1800
+ envelope,
1801
+ });
1802
+ expect(beyondTolerance.gaps).toHaveLength(1);
1803
+ expect(beyondTolerance.gaps[0]!.end.getTime()).toBe(feedEnd.getTime());
1804
+
1805
+ // ...and minimumGapSeconds still filters it like any other hole.
1806
+ const filtered: CoverageGapEventsResult =
1807
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1808
+ ...gapInput,
1809
+ shifts: [
1810
+ { start: feedStart, end: new Date(feedEnd.getTime() - 6 * 1000) },
1811
+ ],
1812
+ feedStart,
1813
+ feedEnd,
1814
+ envelope,
1815
+ minimumGapSeconds: 60,
1816
+ });
1817
+ expect(filtered.gaps).toEqual([]);
1818
+ });
1819
+
1820
+ test("a business-hours layer with nobody assigned emits one gap per intended window", () => {
1821
+ const envelope: CoverageEnvelopeResult =
1822
+ OnCallCalendarFeedUtil.computeCoverageEnvelope({
1823
+ layers: [businessHoursLayer([])],
1824
+ windowStart: feedStart,
1825
+ windowEnd: feedEnd,
1826
+ });
1827
+
1828
+ const gaps: CoverageGapEventsResult =
1829
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1830
+ ...gapInput,
1831
+ shifts: [],
1832
+ feedStart,
1833
+ feedEnd,
1834
+ envelope: envelope.segments,
1835
+ });
1836
+
1837
+ expect(gaps.events).toHaveLength(10);
1838
+ for (const gap of gaps.gaps) {
1839
+ expect(hhmm(gap.start, UTC)).toBe("09:00");
1840
+ expect(hhmm(gap.end, UTC)).toBe("17:00");
1841
+ }
1842
+ // Oldest first.
1843
+ expect(gaps.gaps[0]!.start.getTime()).toBe(
1844
+ at("2026-01-05T09:00:00Z").getTime(),
1845
+ );
1846
+ });
1847
+
1848
+ test("a hole inside the envelope is clipped to it and honours minimumGapSeconds", () => {
1849
+ const envelope: Array<TimeSegment> = [
1850
+ { start: at("2026-01-06T09:00:00Z"), end: at("2026-01-06T17:00:00Z") },
1851
+ { start: at("2026-01-07T09:00:00Z"), end: at("2026-01-07T17:00:00Z") },
1852
+ ];
1853
+
1854
+ // Covered until Tue 12:00, then nothing until Wed 16:30 -> Tue 12-17 and Wed 09-16:30.
1855
+ const shifts: Array<TimeSegment> = [
1856
+ { start: at("2026-01-05T00:00:00Z"), end: at("2026-01-06T12:00:00Z") },
1857
+ { start: at("2026-01-07T16:30:00Z"), end: at("2026-01-19T00:00:00Z") },
1858
+ ];
1859
+
1860
+ const all: CoverageGapEventsResult =
1861
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1862
+ ...gapInput,
1863
+ shifts,
1864
+ feedStart,
1865
+ feedEnd,
1866
+ envelope,
1867
+ });
1868
+ expect(
1869
+ all.gaps.map((gap: TimeSegment) => {
1870
+ return `${gap.start.toISOString()}/${gap.end.toISOString()}`;
1871
+ }),
1872
+ ).toEqual([
1873
+ "2026-01-06T12:00:00.000Z/2026-01-06T17:00:00.000Z",
1874
+ "2026-01-07T09:00:00.000Z/2026-01-07T16:30:00.000Z",
1875
+ ]);
1876
+
1877
+ const sixHours: CoverageGapEventsResult =
1878
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1879
+ ...gapInput,
1880
+ shifts,
1881
+ feedStart,
1882
+ feedEnd,
1883
+ envelope,
1884
+ minimumGapSeconds: 6 * 3600,
1885
+ });
1886
+ expect(sixHours.gaps).toHaveLength(1);
1887
+ expect(sixHours.gaps[0]!.start.toISOString()).toBe(
1888
+ "2026-01-07T09:00:00.000Z",
1889
+ );
1890
+ });
1891
+
1892
+ test("adjacent gap pieces merge and the result is capped oldest-first with a truncated flag", () => {
1893
+ const envelope: Array<TimeSegment> = [{ start: feedStart, end: feedEnd }];
1894
+
1895
+ // Nobody on call except one hour a day at 12:00.
1896
+ const shifts: Array<TimeSegment> = [];
1897
+ for (let day: number = 0; day < 14; day++) {
1898
+ const start: Date = new Date(
1899
+ feedStart.getTime() + day * 86400000 + 12 * 3600000,
1900
+ );
1901
+ shifts.push({ start, end: new Date(start.getTime() + 3600000) });
1902
+ }
1903
+
1904
+ const capped: CoverageGapEventsResult =
1905
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1906
+ ...gapInput,
1907
+ shifts,
1908
+ feedStart,
1909
+ feedEnd,
1910
+ envelope,
1911
+ maxGapEvents: 3,
1912
+ });
1913
+
1914
+ expect(capped.truncated).toBe(true);
1915
+ expect(capped.events).toHaveLength(3);
1916
+ expect(capped.gaps[0]!.start.getTime()).toBe(feedStart.getTime());
1917
+ expect(capped.gaps[1]!.start.toISOString()).toBe(
1918
+ "2026-01-05T13:00:00.000Z",
1919
+ );
1920
+ expect(capped.gaps[2]!.start.toISOString()).toBe(
1921
+ "2026-01-06T13:00:00.000Z",
1922
+ );
1923
+
1924
+ const uncapped: CoverageGapEventsResult =
1925
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1926
+ ...gapInput,
1927
+ shifts,
1928
+ feedStart,
1929
+ feedEnd,
1930
+ envelope,
1931
+ });
1932
+ // Leading gap + 13 between-day gaps + the trailing gap after the last hour.
1933
+ expect(uncapped.gaps).toHaveLength(15);
1934
+ expect(uncapped.gaps[14]!.start.toISOString()).toBe(
1935
+ "2026-01-18T13:00:00.000Z",
1936
+ );
1937
+ expect(uncapped.gaps[14]!.end.getTime()).toBe(feedEnd.getTime());
1938
+ expect(uncapped.truncated).toBe(false);
1939
+ expect(MAX_GAP_EVENTS).toBeGreaterThan(15);
1940
+ });
1941
+
1942
+ test("an envelope segment outside the window contributes nothing", () => {
1943
+ const gaps: CoverageGapEventsResult =
1944
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1945
+ ...gapInput,
1946
+ shifts: [],
1947
+ feedStart,
1948
+ feedEnd,
1949
+ envelope: [
1950
+ {
1951
+ start: at("2025-12-01T00:00:00Z"),
1952
+ end: at("2025-12-02T00:00:00Z"),
1953
+ },
1954
+ {
1955
+ start: at("2026-01-06T00:00:00Z"),
1956
+ end: at("2026-01-06T06:00:00Z"),
1957
+ },
1958
+ ],
1959
+ });
1960
+
1961
+ expect(gaps.gaps).toHaveLength(1);
1962
+ expect(gaps.gaps[0]!.start.toISOString()).toBe("2026-01-06T00:00:00.000Z");
1963
+ });
1964
+
1965
+ test("mergeSegments drops empty segments, merges overlaps and touching-within-tolerance, and sorts", () => {
1966
+ const merged: Array<TimeSegment> = OnCallCalendarFeedUtil.mergeSegments(
1967
+ [
1968
+ { start: at("2026-01-06T10:00:00Z"), end: at("2026-01-06T12:00:00Z") },
1969
+ { start: at("2026-01-06T09:00:00Z"), end: at("2026-01-06T09:00:00Z") },
1970
+ { start: at("2026-01-06T12:00:01Z"), end: at("2026-01-06T13:00:00Z") },
1971
+ { start: at("2026-01-06T08:00:00Z"), end: at("2026-01-06T11:00:00Z") },
1972
+ { start: at("2026-01-06T15:00:00Z"), end: at("2026-01-06T16:00:00Z") },
1973
+ ],
1974
+ 1000,
1975
+ );
1976
+
1977
+ expect(
1978
+ merged.map((segment: TimeSegment) => {
1979
+ return `${segment.start.toISOString()}/${segment.end.toISOString()}`;
1980
+ }),
1981
+ ).toEqual([
1982
+ "2026-01-06T08:00:00.000Z/2026-01-06T13:00:00.000Z",
1983
+ "2026-01-06T15:00:00.000Z/2026-01-06T16:00:00.000Z",
1984
+ ]);
1985
+ });
1986
+ });