@oneuptime/common 13.0.7 → 13.0.8

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 (463) hide show
  1. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +1 -1
  2. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +1 -1
  3. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +1 -1
  4. package/Server/API/AIBillingAPI.ts +1 -0
  5. package/Server/API/AIInsightAPI.ts +1 -4
  6. package/Server/API/AIInvestigationAPI.ts +1 -4
  7. package/Server/API/AIReadinessAPI.ts +4 -5
  8. package/Server/API/AlertAPI.ts +1 -0
  9. package/Server/API/AutoRemediationAPI.ts +1 -4
  10. package/Server/API/BaseAPI.ts +13 -0
  11. package/Server/API/BaseAnalyticsAPI.ts +80 -9
  12. package/Server/API/BillingAPI.ts +2 -0
  13. package/Server/API/BillingInvoiceAPI.ts +1 -0
  14. package/Server/API/BillingPaymentMethodAPI.ts +2 -0
  15. package/Server/API/CodeFixRunAPI.ts +1 -3
  16. package/Server/API/CommonAPI.ts +68 -4
  17. package/Server/API/DashboardAPI.ts +12 -12
  18. package/Server/API/GlobalConfigAPI.ts +17 -0
  19. package/Server/API/IncidentAPI.ts +2 -0
  20. package/Server/API/IncidentEpisodeAPI.ts +1 -0
  21. package/Server/API/NotificationAPI.ts +1 -0
  22. package/Server/API/OnCallCalendarAPI.ts +4 -5
  23. package/Server/API/OnCallDutyPolicyAPI.ts +7 -0
  24. package/Server/API/OnCallReadinessAPI.ts +4 -1
  25. package/Server/API/OnCallScheduleTimelineAPI.ts +731 -0
  26. package/Server/API/ProjectAPI.ts +1 -0
  27. package/Server/API/ScheduledMaintenanceAPI.ts +1 -0
  28. package/Server/API/StatusPageAPI.ts +161 -32
  29. package/Server/API/TeamMemberAPI.ts +3 -0
  30. package/Server/API/UserCallAPI.ts +2 -0
  31. package/Server/API/UserEmailAPI.ts +2 -0
  32. package/Server/API/UserIncomingCallNumberAPI.ts +2 -0
  33. package/Server/API/UserMicrosoftTeamsAPI.ts +1 -0
  34. package/Server/API/UserSlackAPI.ts +1 -0
  35. package/Server/API/UserSmsAPI.ts +2 -0
  36. package/Server/API/UserTotpAuthAPI.ts +1 -0
  37. package/Server/API/UserWebAuthnAPI.ts +2 -0
  38. package/Server/API/UserWebhookAPI.ts +1 -0
  39. package/Server/API/UserWhatsAppAPI.ts +2 -0
  40. package/Server/Middleware/MasterAdminAuthorization.ts +13 -2
  41. package/Server/Middleware/UserAuthorization.ts +79 -5
  42. package/Server/Services/ApiKeyPermissionService.ts +101 -4
  43. package/Server/Services/DatabaseService.ts +245 -0
  44. package/Server/Services/DetectionRuleService.ts +5 -4
  45. package/Server/Services/OnCallDutyPolicyChildService.ts +261 -0
  46. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +2 -2
  47. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +2 -2
  48. package/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.ts +2 -2
  49. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +2 -2
  50. package/Server/Services/PayAsYouGoBillingService.ts +23 -28
  51. package/Server/Services/ProjectService.ts +18 -0
  52. package/Server/Services/RumSessionPinService.ts +274 -22
  53. package/Server/Services/TeamPermissionService.ts +120 -26
  54. package/Server/Services/ThreatIntelFeedService.ts +6 -3
  55. package/Server/Types/Database/Permissions/BasePermission.ts +9 -7
  56. package/Server/Types/Database/Permissions/Index.ts +75 -11
  57. package/Server/Types/Database/Permissions/TablePermission.ts +24 -28
  58. package/Server/Utils/AI/AIChatPrivacyFilter.ts +8 -0
  59. package/Server/Utils/AI/AIRunPrivacyFilter.ts +8 -0
  60. package/Server/Utils/MasterAdminPageAccess.ts +61 -0
  61. package/Server/Utils/Monitor/MonitorAlert.ts +3 -1
  62. package/Server/Utils/Monitor/MonitorIncident.ts +3 -1
  63. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1 -1
  64. package/Server/Utils/Realtime.ts +163 -45
  65. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +11 -28
  66. package/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.ts +170 -0
  67. package/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.ts +1 -1
  68. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.ts +6 -2
  69. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.ts +10 -9
  70. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +1059 -0
  71. package/Tests/App/Dashboard/AffectedResourcesPickerEditForm.test.tsx +376 -0
  72. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +757 -0
  73. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +307 -0
  74. package/Tests/App/Dashboard/ArgumentsFormGroupByUnlistedAttributes.test.tsx +131 -0
  75. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +1 -1
  76. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +9 -1
  77. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +24 -0
  78. package/Tests/App/Dashboard/DashboardVariableSelector.test.tsx +162 -8
  79. package/Tests/App/Dashboard/EntityFilterDropdownArchivedSlos.test.tsx +105 -6
  80. package/Tests/App/Dashboard/EntityFilterDropdownUnlistedSelection.test.tsx +434 -0
  81. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +276 -1
  82. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +9 -1
  83. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +58 -42
  84. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +42 -5
  85. package/Tests/App/Dashboard/ResourceAuditLogsSideMenus.test.tsx +34 -23
  86. package/Tests/App/Dashboard/ScheduleTimeline.test.tsx +1580 -0
  87. package/Tests/App/Dashboard/ScheduleTimelineAPI.test.ts +280 -0
  88. package/Tests/App/Dashboard/ScheduleTimelineModel.test.ts +673 -0
  89. package/Tests/App/Dashboard/SecurityEventConnectionDiagnostics.test.tsx +95 -68
  90. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +86 -3
  91. package/Tests/App/Dashboard/SecurityEventsEmptyState.test.tsx +28 -3
  92. package/Tests/App/Dashboard/SecurityEventsExplorer.test.tsx +761 -0
  93. package/Tests/App/Dashboard/SecurityEventsGuideContent.test.ts +1313 -0
  94. package/Tests/App/Dashboard/SecurityEventsHowItWorksCard.test.tsx +536 -0
  95. package/Tests/App/Dashboard/SecurityEventsNoResults.test.tsx +222 -0
  96. package/Tests/App/Dashboard/SecurityEventsThreatIntelPage.test.tsx +199 -0
  97. package/Tests/App/Dashboard/SecurityEventsVolumeChart.test.tsx +321 -0
  98. package/Tests/App/Dashboard/SloOverviewHero.test.tsx +171 -36
  99. package/Tests/App/Dashboard/SloViewSideMenu.test.tsx +215 -0
  100. package/Tests/App/Dashboard/SloViewSideMenuCounts.test.tsx +40 -3
  101. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +12 -4
  102. package/Tests/App/PublicDashboard/PublicDashboardWidgetListAuth.test.tsx +623 -0
  103. package/Tests/Models/OnCallCalendarFeedModels.test.ts +23 -1
  104. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +138 -5
  105. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +187 -0
  106. package/Tests/Server/API/AIInvestigationAvailabilityAuthorization.test.ts +51 -3
  107. package/Tests/Server/API/AIInvestigationEvidenceAPI.test.ts +55 -2
  108. package/Tests/Server/API/BaseAnalyticsAPIAggregateCache.test.ts +729 -0
  109. package/Tests/Server/API/BillingPayAsYouGoStatus.test.ts +93 -7
  110. package/Tests/Server/API/BillingPaymentMethodAPI.test.ts +36 -3
  111. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +1003 -24
  112. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +6 -3
  113. package/Tests/Server/API/ExpiredSessionAIRoutes.test.ts +708 -0
  114. package/Tests/Server/API/ExpiredSessionCustomRoutes.test.ts +928 -0
  115. package/Tests/Server/API/ExpiredSessionMonitorTemplate.test.ts +319 -0
  116. package/Tests/Server/API/ExpiredSessionOnCallDutyPolicy.test.ts +336 -0
  117. package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +820 -0
  118. package/Tests/Server/API/GlobalConfigLicense.test.ts +140 -0
  119. package/Tests/Server/API/NotificationChannelVerificationAPI.test.ts +115 -12
  120. package/Tests/Server/API/OnCallCalendarAPI.test.ts +375 -8
  121. package/Tests/Server/API/OnCallReadinessAPI.test.ts +175 -17
  122. package/Tests/Server/API/OnCallScheduleTimelineAPI.test.ts +1513 -0
  123. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +119 -7
  124. package/Tests/Server/API/ProjectScopedCustomRouteAuth.test.ts +111 -4
  125. package/Tests/Server/API/PublicRouteInvalidAccessToken.test.ts +615 -0
  126. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +89 -2
  127. package/Tests/Server/API/SessionReplayAPI.test.ts +121 -0
  128. package/Tests/Server/API/StatusPageMasterPasswordRateLimit.test.ts +4 -2
  129. package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +441 -0
  130. package/Tests/Server/API/StatusPageTestEmailReportAuth.test.ts +664 -0
  131. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +7 -0
  132. package/Tests/Server/API/UserIncomingCallNumberApi.test.ts +6 -0
  133. package/Tests/Server/API/UserNotificationSettingAPI.test.ts +65 -4
  134. package/Tests/Server/API/UserProjectsAPI.test.ts +63 -3
  135. package/Tests/Server/API/UserTotpAuthAPI.test.ts +48 -2
  136. package/Tests/Server/API/UserWebAuthnAuthenticationOptionsRoute.test.ts +11 -2
  137. package/Tests/Server/API/UserWebAuthnBackupCodeMinting.test.ts +46 -3
  138. package/Tests/Server/API/UserWebAuthnPasskeyAPI.test.ts +18 -2
  139. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +24 -7
  140. package/Tests/Server/Middleware/ExpiredSessionUserMiddleware.test.ts +563 -0
  141. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +299 -19
  142. package/Tests/Server/Middleware/PublicRouteUserMiddleware.test.ts +453 -0
  143. package/Tests/Server/Services/AIChatPrivacyPin.test.ts +24 -0
  144. package/Tests/Server/Services/ApiKeyPermissionSecurity.test.ts +2 -0
  145. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +3 -3
  146. package/Tests/Server/Services/DatabaseServiceCreateSuppliedId.test.ts +147 -0
  147. package/Tests/Server/Services/DatabaseServiceLoginBeforeHooks.test.ts +1078 -0
  148. package/Tests/Server/Services/DatabaseServiceTenantRelationGuard.test.ts +259 -0
  149. package/Tests/Server/Services/DatabaseServiceWriteOperatorValues.test.ts +147 -0
  150. package/Tests/Server/Services/MonitorPayAsYouGoBilling.test.ts +115 -1
  151. package/Tests/Server/Services/OnCallChildCreateAuthorizationPostgres.test.ts +711 -0
  152. package/Tests/Server/Services/OnCallDutyPolicyChildService.test.ts +733 -0
  153. package/Tests/Server/Services/PayAsYouGoBillingService.test.ts +190 -140
  154. package/Tests/Server/Services/PayAsYouGoStripeRateLimitBudget.test.ts +4 -9
  155. package/Tests/Server/Services/PermissionBlockDeletion.test.ts +780 -0
  156. package/Tests/Server/Services/PermissionBlockDeletionPostgres.test.ts +628 -0
  157. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +77 -1
  158. package/Tests/Server/Services/RumSessionPinCreatePermission.test.ts +715 -0
  159. package/Tests/Server/Services/TeamMemberMultiTenantReadPostgres.test.ts +394 -0
  160. package/Tests/Server/Services/TeamPrivilegeEscalation.test.ts +6 -0
  161. package/Tests/Server/Services/TelemetryPayAsYouGoBilling.test.ts +131 -0
  162. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +13 -2
  163. package/Tests/Server/Types/Database/Permissions/ModelPermissionAnonymousRefusal.test.ts +518 -0
  164. package/Tests/Server/Types/Database/Permissions/MultiTenantPermissions.test.ts +350 -0
  165. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +282 -25
  166. package/Tests/Server/Types/Database/Permissions/TableLevelBlockPermission.test.ts +459 -0
  167. package/Tests/Server/Utils/AI/AIRunPrivacyFilter.test.ts +101 -9
  168. package/Tests/Server/Utils/AI/ChatAgentHelpers.test.ts +88 -4
  169. package/Tests/Server/Utils/AI/Toolbox/ExceptionTools.test.ts +459 -0
  170. package/Tests/Server/Utils/AI/Toolbox/LogTools.test.ts +794 -0
  171. package/Tests/Server/Utils/AI/Toolbox/RecentChangesTools.test.ts +574 -0
  172. package/Tests/Server/Utils/AI/Toolbox/SecurityEventTools.test.ts +596 -0
  173. package/Tests/Server/Utils/CaptchaRequestAndLogging.test.ts +395 -0
  174. package/Tests/Server/Utils/MasterAdminPageAccess.test.ts +251 -0
  175. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +616 -0
  176. package/Tests/Server/Utils/Realtime.test.ts +502 -0
  177. package/Tests/Server/Utils/SecurityEvent/Connectors/GoogleSecOps/GoogleSecOpsConnectorSeverity.test.ts +216 -0
  178. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +142 -0
  179. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.test.ts +140 -0
  180. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepairClickhouseIntegration.test.ts +542 -0
  181. package/Tests/Server/Utils/Telemetry/ChangeEventRow.test.ts +743 -0
  182. package/Tests/Server/Utils/Telemetry/RuntimeMetrics.test.ts +465 -0
  183. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/Alert.test.ts +202 -0
  184. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/Incident.test.ts +194 -0
  185. package/Tests/Server/Utils/Workspace/MicrosoftTeams/Messages/ScheduledMaintenance.test.ts +231 -0
  186. package/Tests/Server/Utils/Workspace/Slack/Messages/Alert.test.ts +194 -0
  187. package/Tests/Server/Utils/Workspace/Slack/Messages/AlertEpisode.test.ts +194 -0
  188. package/Tests/Server/Utils/Workspace/Slack/Messages/Incident.test.ts +194 -0
  189. package/Tests/Server/Utils/Workspace/Slack/Messages/IncidentEpisode.test.ts +194 -0
  190. package/Tests/Server/Utils/Workspace/Slack/Messages/ScheduledMaintenance.test.ts +247 -0
  191. package/Tests/Types/AI/AIInsightEvidence.test.ts +206 -0
  192. package/Tests/Types/BaseDatabase/DatabaseCommonInteractionPropsUtilAnonymous.test.ts +144 -0
  193. package/Tests/Types/Call/CallProvider.test.ts +528 -0
  194. package/Tests/Types/Kubernetes/KubernetesCostIngest.test.ts +236 -0
  195. package/Tests/Types/OnCallDutyPolicy/ScheduleTimeline.test.ts +736 -0
  196. package/Tests/Types/OnCallDutyPolicy/ScheduleTimelineLayout.test.ts +996 -0
  197. package/Tests/Types/SecurityEvent/SigmaRule.test.ts +142 -0
  198. package/Tests/Types/Workflow/Components/JSON.test.ts +182 -0
  199. package/Tests/UI/Components/BasicFormDropdownNormalization.test.tsx +116 -1
  200. package/Tests/UI/Components/Dropdown/DropdownAsyncOptions.test.tsx +81 -0
  201. package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +89 -1
  202. package/Tests/UI/Components/EventAttachmentList.test.tsx +174 -0
  203. package/Tests/UI/Components/Forms/BasicFormBurnRateTemplateDefaults.test.tsx +395 -0
  204. package/Tests/UI/Components/Forms/BasicFormCollapsibleSections.test.tsx +286 -0
  205. package/Tests/UI/Components/Forms/FormSummary.test.tsx +128 -0
  206. package/Tests/UI/Components/ModelTable/BaseModelTableQueryChange.test.tsx +365 -0
  207. package/Tests/UI/Components/OpenAuthenticatedUrl.test.ts +358 -0
  208. package/Tests/UI/Components/SessionAwareImage.test.tsx +496 -0
  209. package/Tests/UI/Components/TelemetryHistogramZeroBuckets.test.tsx +190 -0
  210. package/Tests/UI/Components/TooltipInteractive.test.tsx +97 -0
  211. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardInstallGuidance.test.tsx +1 -1
  212. package/Tests/UI/Monitor/EvaluationLogListTimestamps.test.tsx +367 -0
  213. package/Tests/UI/Utils/API/BaseAPIRefreshSession.test.ts +1087 -0
  214. package/Tests/UI/Utils/API/DashboardHost.ts +12 -0
  215. package/Tests/UI/Utils/API/FakeAxiosServer.ts +183 -0
  216. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  217. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  218. package/Tests/UI/Utils/Cookie.test.ts +440 -0
  219. package/Tests/UI/Utils/ModelAPIRequestClient.test.ts +278 -0
  220. package/Tests/UI/Utils/Navigation.test.ts +33 -0
  221. package/Tests/UI/Utils/PricingPlan.test.ts +295 -0
  222. package/Tests/UI/Utils/Realtime.test.ts +759 -0
  223. package/Tests/UI/Utils/Telemetry/Telemetry.test.ts +500 -0
  224. package/Tests/Utils/APIInstanceDispatch.test.ts +311 -0
  225. package/Tests/Utils/SecurityEvent/GoogleSecOpsAlertNormalizerSeverity.test.ts +565 -0
  226. package/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.ts +40 -0
  227. package/Types/Monitor/MonitorEvaluationSummary.ts +3 -0
  228. package/Types/OnCallDutyPolicy/ScheduleTimeline.ts +507 -0
  229. package/Types/OnCallDutyPolicy/ScheduleTimelineLayout.ts +589 -0
  230. package/Types/Realtime/EventName.ts +11 -0
  231. package/Types/SecurityEvent/DetectionFindingConstants.ts +14 -0
  232. package/Types/SecurityEvent/OcsfSeverity.ts +31 -26
  233. package/Types/SecurityEvent/SigmaRule.ts +51 -0
  234. package/Types/SecurityEvent/ThreatIntelConstants.ts +18 -0
  235. package/UI/Components/AttachmentList/EventAttachmentList.tsx +30 -2
  236. package/UI/Components/Dashboard/DashboardVariableControl.tsx +12 -2
  237. package/UI/Components/Dropdown/Dropdown.tsx +6 -1
  238. package/UI/Components/EditionLabel/EditionLabel.tsx +12 -0
  239. package/UI/Components/EventItem/EventItem.tsx +12 -1
  240. package/UI/Components/Forms/BasicForm.tsx +151 -126
  241. package/UI/Components/Forms/CollapsibleFormSection.tsx +63 -0
  242. package/UI/Components/Forms/Fields/FormField.tsx +10 -4
  243. package/UI/Components/Forms/FormSummary.tsx +7 -0
  244. package/UI/Components/Forms/Types/Field.ts +10 -0
  245. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +2 -1
  246. package/UI/Components/Markdown.tsx/SessionAwareImage.tsx +122 -0
  247. package/UI/Components/ModelTable/BaseModelTable.tsx +34 -5
  248. package/UI/Components/ModelTable/TableView.tsx +1 -1
  249. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +8 -1
  250. package/UI/Components/Tooltip/Tooltip.tsx +8 -1
  251. package/UI/Utils/API/API.ts +173 -12
  252. package/UI/Utils/API/RequestOptions.ts +11 -0
  253. package/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts +31 -7
  254. package/UI/Utils/ModelAPI/ModelAPI.ts +30 -9
  255. package/UI/Utils/Navigation.ts +9 -0
  256. package/UI/Utils/OpenAuthenticatedUrl.ts +154 -0
  257. package/UI/Utils/Realtime.ts +176 -30
  258. package/Utils/API.ts +14 -5
  259. package/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.ts +157 -7
  260. package/Utils/SecurityEvent/Sigma/SigmaRuleParser.ts +9 -17
  261. package/Utils/Slo/SloBurnRateTemplate.ts +2 -2
  262. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +1 -1
  263. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -1
  264. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +1 -1
  265. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -1
  266. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +1 -1
  267. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -1
  268. package/build/dist/Server/API/AIBillingAPI.js +1 -1
  269. package/build/dist/Server/API/AIBillingAPI.js.map +1 -1
  270. package/build/dist/Server/API/AIInsightAPI.js +1 -4
  271. package/build/dist/Server/API/AIInsightAPI.js.map +1 -1
  272. package/build/dist/Server/API/AIInvestigationAPI.js +1 -4
  273. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  274. package/build/dist/Server/API/AIReadinessAPI.js +1 -3
  275. package/build/dist/Server/API/AIReadinessAPI.js.map +1 -1
  276. package/build/dist/Server/API/AlertAPI.js +1 -1
  277. package/build/dist/Server/API/AlertAPI.js.map +1 -1
  278. package/build/dist/Server/API/AutoRemediationAPI.js +1 -4
  279. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  280. package/build/dist/Server/API/BaseAPI.js +12 -0
  281. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  282. package/build/dist/Server/API/BaseAnalyticsAPI.js +55 -10
  283. package/build/dist/Server/API/BaseAnalyticsAPI.js.map +1 -1
  284. package/build/dist/Server/API/BillingAPI.js +2 -2
  285. package/build/dist/Server/API/BillingAPI.js.map +1 -1
  286. package/build/dist/Server/API/BillingInvoiceAPI.js +1 -1
  287. package/build/dist/Server/API/BillingInvoiceAPI.js.map +1 -1
  288. package/build/dist/Server/API/BillingPaymentMethodAPI.js +2 -2
  289. package/build/dist/Server/API/BillingPaymentMethodAPI.js.map +1 -1
  290. package/build/dist/Server/API/CodeFixRunAPI.js +1 -3
  291. package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
  292. package/build/dist/Server/API/CommonAPI.js +50 -5
  293. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  294. package/build/dist/Server/API/DashboardAPI.js +12 -12
  295. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  296. package/build/dist/Server/API/GlobalConfigAPI.js +12 -0
  297. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  298. package/build/dist/Server/API/IncidentAPI.js +2 -2
  299. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  300. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  301. package/build/dist/Server/API/IncidentEpisodeAPI.js.map +1 -1
  302. package/build/dist/Server/API/NotificationAPI.js +1 -1
  303. package/build/dist/Server/API/NotificationAPI.js.map +1 -1
  304. package/build/dist/Server/API/OnCallCalendarAPI.js +1 -4
  305. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -1
  306. package/build/dist/Server/API/OnCallDutyPolicyAPI.js +6 -0
  307. package/build/dist/Server/API/OnCallDutyPolicyAPI.js.map +1 -1
  308. package/build/dist/Server/API/OnCallReadinessAPI.js +4 -1
  309. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -1
  310. package/build/dist/Server/API/OnCallScheduleTimelineAPI.js +494 -0
  311. package/build/dist/Server/API/OnCallScheduleTimelineAPI.js.map +1 -0
  312. package/build/dist/Server/API/ProjectAPI.js +1 -1
  313. package/build/dist/Server/API/ProjectAPI.js.map +1 -1
  314. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  315. package/build/dist/Server/API/ScheduledMaintenanceAPI.js.map +1 -1
  316. package/build/dist/Server/API/StatusPageAPI.js +125 -33
  317. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  318. package/build/dist/Server/API/TeamMemberAPI.js +3 -3
  319. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  320. package/build/dist/Server/API/UserCallAPI.js +2 -2
  321. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  322. package/build/dist/Server/API/UserEmailAPI.js +2 -2
  323. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  324. package/build/dist/Server/API/UserIncomingCallNumberAPI.js +2 -2
  325. package/build/dist/Server/API/UserIncomingCallNumberAPI.js.map +1 -1
  326. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js +1 -1
  327. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js.map +1 -1
  328. package/build/dist/Server/API/UserSlackAPI.js +1 -1
  329. package/build/dist/Server/API/UserSlackAPI.js.map +1 -1
  330. package/build/dist/Server/API/UserSmsAPI.js +2 -2
  331. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  332. package/build/dist/Server/API/UserTotpAuthAPI.js +1 -1
  333. package/build/dist/Server/API/UserTotpAuthAPI.js.map +1 -1
  334. package/build/dist/Server/API/UserWebAuthnAPI.js +2 -2
  335. package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
  336. package/build/dist/Server/API/UserWebhookAPI.js +1 -1
  337. package/build/dist/Server/API/UserWebhookAPI.js.map +1 -1
  338. package/build/dist/Server/API/UserWhatsAppAPI.js +2 -2
  339. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  340. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -2
  341. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  342. package/build/dist/Server/Middleware/UserAuthorization.js +64 -4
  343. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  344. package/build/dist/Server/Services/ApiKeyPermissionService.js +80 -4
  345. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  346. package/build/dist/Server/Services/DatabaseService.js +171 -0
  347. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  348. package/build/dist/Server/Services/DetectionRuleService.js +4 -4
  349. package/build/dist/Server/Services/DetectionRuleService.js.map +1 -1
  350. package/build/dist/Server/Services/OnCallDutyPolicyChildService.js +170 -0
  351. package/build/dist/Server/Services/OnCallDutyPolicyChildService.js.map +1 -0
  352. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +2 -2
  353. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  354. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +2 -2
  355. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  356. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.js +2 -2
  357. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleTeamService.js.map +1 -1
  358. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +2 -2
  359. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  360. package/build/dist/Server/Services/PayAsYouGoBillingService.js +20 -27
  361. package/build/dist/Server/Services/PayAsYouGoBillingService.js.map +1 -1
  362. package/build/dist/Server/Services/ProjectService.js +17 -0
  363. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  364. package/build/dist/Server/Services/RumSessionPinService.js +180 -16
  365. package/build/dist/Server/Services/RumSessionPinService.js.map +1 -1
  366. package/build/dist/Server/Services/TeamPermissionService.js +89 -27
  367. package/build/dist/Server/Services/TeamPermissionService.js.map +1 -1
  368. package/build/dist/Server/Services/ThreatIntelFeedService.js +5 -4
  369. package/build/dist/Server/Services/ThreatIntelFeedService.js.map +1 -1
  370. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +9 -6
  371. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  372. package/build/dist/Server/Types/Database/Permissions/Index.js +60 -6
  373. package/build/dist/Server/Types/Database/Permissions/Index.js.map +1 -1
  374. package/build/dist/Server/Types/Database/Permissions/TablePermission.js +14 -13
  375. package/build/dist/Server/Types/Database/Permissions/TablePermission.js.map +1 -1
  376. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js +7 -0
  377. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js.map +1 -1
  378. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js +7 -0
  379. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js.map +1 -1
  380. package/build/dist/Server/Utils/MasterAdminPageAccess.js +39 -0
  381. package/build/dist/Server/Utils/MasterAdminPageAccess.js.map +1 -0
  382. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +3 -1
  383. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  384. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +3 -1
  385. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  386. package/build/dist/Server/Utils/Realtime.js +126 -37
  387. package/build/dist/Server/Utils/Realtime.js.map +1 -1
  388. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +7 -25
  389. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  390. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.js +127 -0
  391. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOpsSeverityRepair.js.map +1 -0
  392. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js +1 -1
  393. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js.map +1 -1
  394. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js +3 -2
  395. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js.map +1 -1
  396. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js +4 -8
  397. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js.map +1 -1
  398. package/build/dist/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.js +31 -1
  399. package/build/dist/Types/BaseDatabase/DatabaseCommonInteractionPropsUtil.js.map +1 -1
  400. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimeline.js +321 -0
  401. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimeline.js.map +1 -0
  402. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimelineLayout.js +372 -0
  403. package/build/dist/Types/OnCallDutyPolicy/ScheduleTimelineLayout.js.map +1 -0
  404. package/build/dist/Types/Realtime/EventName.js +10 -0
  405. package/build/dist/Types/Realtime/EventName.js.map +1 -1
  406. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +11 -0
  407. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -1
  408. package/build/dist/Types/SecurityEvent/OcsfSeverity.js +30 -25
  409. package/build/dist/Types/SecurityEvent/OcsfSeverity.js.map +1 -1
  410. package/build/dist/Types/SecurityEvent/SigmaRule.js +47 -0
  411. package/build/dist/Types/SecurityEvent/SigmaRule.js.map +1 -1
  412. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js +14 -0
  413. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js.map +1 -1
  414. package/build/dist/UI/Components/AttachmentList/EventAttachmentList.js +9 -4
  415. package/build/dist/UI/Components/AttachmentList/EventAttachmentList.js.map +1 -1
  416. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +10 -2
  417. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -1
  418. package/build/dist/UI/Components/Dropdown/Dropdown.js +5 -1
  419. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  420. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +11 -0
  421. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  422. package/build/dist/UI/Components/EventItem/EventItem.js +2 -2
  423. package/build/dist/UI/Components/EventItem/EventItem.js.map +1 -1
  424. package/build/dist/UI/Components/Forms/BasicForm.js +70 -64
  425. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  426. package/build/dist/UI/Components/Forms/CollapsibleFormSection.js +27 -0
  427. package/build/dist/UI/Components/Forms/CollapsibleFormSection.js.map +1 -0
  428. package/build/dist/UI/Components/Forms/Fields/FormField.js +10 -7
  429. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  430. package/build/dist/UI/Components/Forms/FormSummary.js +7 -0
  431. package/build/dist/UI/Components/Forms/FormSummary.js.map +1 -1
  432. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +2 -1
  433. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  434. package/build/dist/UI/Components/Markdown.tsx/SessionAwareImage.js +84 -0
  435. package/build/dist/UI/Components/Markdown.tsx/SessionAwareImage.js.map +1 -0
  436. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +14 -1
  437. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  438. package/build/dist/UI/Components/ModelTable/TableView.js +1 -1
  439. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  440. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +8 -1
  441. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  442. package/build/dist/UI/Components/Tooltip/Tooltip.js +2 -1
  443. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  444. package/build/dist/UI/Utils/API/API.js +143 -19
  445. package/build/dist/UI/Utils/API/API.js.map +1 -1
  446. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js +21 -7
  447. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js.map +1 -1
  448. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +26 -9
  449. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  450. package/build/dist/UI/Utils/Navigation.js +8 -0
  451. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  452. package/build/dist/UI/Utils/OpenAuthenticatedUrl.js +92 -0
  453. package/build/dist/UI/Utils/OpenAuthenticatedUrl.js.map +1 -0
  454. package/build/dist/UI/Utils/Realtime.js +120 -15
  455. package/build/dist/UI/Utils/Realtime.js.map +1 -1
  456. package/build/dist/Utils/API.js +13 -5
  457. package/build/dist/Utils/API.js.map +1 -1
  458. package/build/dist/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.js +123 -6
  459. package/build/dist/Utils/SecurityEvent/GoogleSecOpsAlertNormalizer.js.map +1 -1
  460. package/build/dist/Utils/SecurityEvent/Sigma/SigmaRuleParser.js +4 -18
  461. package/build/dist/Utils/SecurityEvent/Sigma/SigmaRuleParser.js.map +1 -1
  462. package/build/dist/Utils/Slo/SloBurnRateTemplate.js +2 -2
  463. package/package.json +1 -1
@@ -0,0 +1,1580 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ waitFor,
17
+ within,
18
+ } from "@testing-library/react";
19
+ import React, { ReactElement } from "react";
20
+ import { MemoryRouter } from "react-router-dom";
21
+ import getJestMockFunction, { MockFunction } from "../../MockType";
22
+
23
+ /*
24
+ * The schedule timeline page as a reader uses it: what loads, what each row
25
+ * says about right now, how the week / month / today controls move the
26
+ * window the server is asked for, what every filter and toggle does to the
27
+ * rows, and the empty, error and "answer arrived late" states.
28
+ *
29
+ * The API is stubbed at API.get and answers from a table keyed on the
30
+ * requested window, so each test states exactly what the server would send.
31
+ */
32
+
33
+ const getMock: MockFunction = getJestMockFunction();
34
+
35
+ jest.mock("../../../UI/Utils/API/API", () => {
36
+ return {
37
+ __esModule: true,
38
+ default: {
39
+ get: (...args: Array<any>) => {
40
+ return getMock(...args);
41
+ },
42
+ getFriendlyMessage: (error: unknown): string => {
43
+ if (error && typeof error === "object" && "message" in error) {
44
+ return String((error as { message: unknown }).message);
45
+ }
46
+
47
+ return "Something went wrong";
48
+ },
49
+ },
50
+ };
51
+ });
52
+
53
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
54
+ return {
55
+ __esModule: true,
56
+ default: {
57
+ getCommonHeaders: (): Record<string, string> => {
58
+ return {};
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ jest.mock("react-i18next", () => {
65
+ return {
66
+ useTranslation: () => {
67
+ return {
68
+ t: (key: string, options?: { defaultValue?: string }): string => {
69
+ return options?.defaultValue ?? key;
70
+ },
71
+ };
72
+ },
73
+ };
74
+ });
75
+
76
+ /*
77
+ * The real picker opens a modal with a searchable dropdown; the page only
78
+ * cares about the zone it hands back, so each test clicks straight to one.
79
+ */
80
+ jest.mock(
81
+ "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/OnCallScheduleLayer/TimezoneSelectButton",
82
+ () => {
83
+ return {
84
+ __esModule: true,
85
+ default: (props: {
86
+ value?: string;
87
+ onChange: (value: string | undefined) => void;
88
+ }) => {
89
+ return (
90
+ <span data-testid="timezone-picker" data-value={props.value}>
91
+ {["Asia/Tokyo", "America/New_York", "UTC"].map((zone: string) => {
92
+ return (
93
+ <button
94
+ key={zone}
95
+ type="button"
96
+ data-testid={`timezone-to-${zone}`}
97
+ onClick={() => {
98
+ props.onChange(zone);
99
+ }}
100
+ >
101
+ {zone}
102
+ </button>
103
+ );
104
+ })}
105
+ </span>
106
+ );
107
+ },
108
+ };
109
+ },
110
+ );
111
+
112
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
113
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
114
+ import URL from "../../../Types/API/URL";
115
+ import OneUptimeDate from "../../../Types/Date";
116
+ import { JSONObject } from "../../../Types/JSON";
117
+ import ObjectID from "../../../Types/ObjectID";
118
+ import {
119
+ ScheduleTimelineResponse,
120
+ ScheduleTimelineScheduleJson,
121
+ } from "../../../Types/OnCallDutyPolicy/ScheduleTimeline";
122
+ import ScheduleTimeline, {
123
+ GROUP_BY_TEAM_STORAGE_KEY,
124
+ VIEW_MODE_STORAGE_KEY,
125
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/ScheduleTimeline/ScheduleTimeline";
126
+ import TimelineRow from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/ScheduleTimeline/TimelineRow";
127
+ import TimelineGrid from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/ScheduleTimeline/TimelineGrid";
128
+ import TimelineModel, {
129
+ TimelineData,
130
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/ScheduleTimeline/TimelineModel";
131
+ import ScheduleTimelineLayout, {
132
+ TimelineRange,
133
+ TimelineViewMode,
134
+ } from "../../../Types/OnCallDutyPolicy/ScheduleTimelineLayout";
135
+
136
+ // Thursday. The week on screen is Mon 14 - Sun 20 September 2026 (UTC).
137
+ const NOW: Date = new Date("2026-09-17T12:00:00.000Z");
138
+ const WEEK_FROM: string = "2026-09-14T00:00:00.000Z";
139
+ const WEEK_TO: string = "2026-09-21T00:00:00.000Z";
140
+
141
+ const PAYMENTS: ScheduleTimelineScheduleJson = {
142
+ scheduleId: "s-pay",
143
+ scheduleName: "Payments primary",
144
+ scheduleTimezone: "Europe/Stockholm",
145
+ ownerTeamIds: ["t-pay"],
146
+ isCurrentUserOnRoster: false,
147
+ truncated: false,
148
+ shifts: [
149
+ {
150
+ shiftKey: "pay-1",
151
+ userId: "u-alice",
152
+ userName: "Alice Andersson",
153
+ start: "2026-09-10T09:00:00.000Z",
154
+ end: "2026-09-17T09:00:00.000Z",
155
+ layerId: "l-1",
156
+ layerName: "Weekly",
157
+ override: null,
158
+ },
159
+ {
160
+ shiftKey: "pay-2",
161
+ userId: "u-bob",
162
+ userName: "Bob Berg",
163
+ start: "2026-09-17T09:00:00.000Z",
164
+ end: "2026-09-24T09:00:00.000Z",
165
+ layerId: "l-1",
166
+ layerName: "Weekly",
167
+ override: null,
168
+ },
169
+ ],
170
+ };
171
+
172
+ // Uncovered from Wednesday 00:00 to Friday 00:00, so nobody is on call now.
173
+ const SRE: ScheduleTimelineScheduleJson = {
174
+ scheduleId: "s-sre",
175
+ scheduleName: "SRE primary",
176
+ scheduleTimezone: null,
177
+ ownerTeamIds: ["t-sre", "t-pay"],
178
+ isCurrentUserOnRoster: true,
179
+ truncated: false,
180
+ shifts: [
181
+ {
182
+ shiftKey: "sre-1",
183
+ userId: "u-carol",
184
+ userName: "Carol Chen",
185
+ start: "2026-09-14T00:00:00.000Z",
186
+ end: "2026-09-16T00:00:00.000Z",
187
+ layerId: null,
188
+ layerName: null,
189
+ override: null,
190
+ },
191
+ {
192
+ shiftKey: "sre-2",
193
+ userId: "u-dan",
194
+ userName: "Dan Diaz",
195
+ start: "2026-09-18T00:00:00.000Z",
196
+ end: "2026-09-21T00:00:00.000Z",
197
+ layerId: null,
198
+ layerName: null,
199
+ override: {
200
+ originalUserId: "u-carol",
201
+ originalUserName: "Carol Chen",
202
+ overrideStartsAt: "2026-09-18T00:00:00.000Z",
203
+ overrideEndsAt: "2026-09-21T00:00:00.000Z",
204
+ onCallDutyPolicyId: null,
205
+ },
206
+ },
207
+ ],
208
+ };
209
+
210
+ const LEGACY: ScheduleTimelineScheduleJson = {
211
+ scheduleId: "s-legacy",
212
+ scheduleName: "Legacy pager",
213
+ scheduleTimezone: null,
214
+ ownerTeamIds: [],
215
+ isCurrentUserOnRoster: false,
216
+ truncated: false,
217
+ shifts: [],
218
+ };
219
+
220
+ function timeline(
221
+ overrides?: Partial<ScheduleTimelineResponse>,
222
+ ): ScheduleTimelineResponse {
223
+ return {
224
+ from: WEEK_FROM,
225
+ to: WEEK_TO,
226
+ generatedAt: NOW.toISOString(),
227
+ truncated: false,
228
+ totalScheduleCount: 3,
229
+ schedulesTruncated: false,
230
+ schedules: [PAYMENTS, SRE, LEGACY],
231
+ teams: [
232
+ { teamId: "t-pay", teamName: "Payments", isCurrentUserMember: false },
233
+ { teamId: "t-sre", teamName: "SRE", isCurrentUserMember: true },
234
+ ],
235
+ ...(overrides || {}),
236
+ };
237
+ }
238
+
239
+ function ok(body: ScheduleTimelineResponse): HTTPResponse<JSONObject> {
240
+ return new HTTPResponse<JSONObject>(
241
+ 200,
242
+ JSON.parse(JSON.stringify(body)) as JSONObject,
243
+ {},
244
+ );
245
+ }
246
+
247
+ interface RequestedWindow {
248
+ from: string;
249
+ to: string;
250
+ teamId: string | null;
251
+ }
252
+
253
+ function requested(call: number): RequestedWindow {
254
+ const options: { url: URL } = getMock.mock.calls[call]?.[0] as { url: URL };
255
+ const parsed: globalThis.URL = new globalThis.URL(options.url.toString());
256
+
257
+ return {
258
+ from: parsed.searchParams.get("from") || "",
259
+ to: parsed.searchParams.get("to") || "",
260
+ teamId: parsed.searchParams.get("teamId"),
261
+ };
262
+ }
263
+
264
+ function lastRequested(): RequestedWindow {
265
+ return requested(getMock.mock.calls.length - 1);
266
+ }
267
+
268
+ /*
269
+ * Answers every request with `body`, but with `from` / `to` set to the
270
+ * window that was asked for, as the server would.
271
+ */
272
+ function answerWith(body: ScheduleTimelineResponse): void {
273
+ getMock.mockImplementation(async (options: { url: URL }) => {
274
+ const parsed: globalThis.URL = new globalThis.URL(options.url.toString());
275
+
276
+ return ok({
277
+ ...body,
278
+ from: parsed.searchParams.get("from") || body.from,
279
+ to: parsed.searchParams.get("to") || body.to,
280
+ });
281
+ });
282
+ }
283
+
284
+ function renderTimeline(
285
+ props?: React.ComponentProps<typeof ScheduleTimeline>,
286
+ ): void {
287
+ render(
288
+ <MemoryRouter>
289
+ <ScheduleTimeline {...(props || {})} />
290
+ </MemoryRouter>,
291
+ );
292
+ }
293
+
294
+ function rowIds(): Array<string> {
295
+ return screen
296
+ .queryAllByTestId("timeline-schedule-row")
297
+ .map((row: HTMLElement) => {
298
+ return row.getAttribute("data-schedule-id") || "";
299
+ });
300
+ }
301
+
302
+ function row(scheduleId: string, index: number = 0): HTMLElement {
303
+ const rows: Array<HTMLElement> = screen
304
+ .queryAllByTestId("timeline-schedule-row")
305
+ .filter((element: HTMLElement) => {
306
+ return element.getAttribute("data-schedule-id") === scheduleId;
307
+ });
308
+
309
+ const found: HTMLElement | undefined = rows[index];
310
+
311
+ if (!found) {
312
+ throw new Error(`No row for ${scheduleId}`);
313
+ }
314
+
315
+ return found;
316
+ }
317
+
318
+ async function loaded(): Promise<void> {
319
+ await waitFor(() => {
320
+ expect(screen.queryAllByTestId("timeline-schedule-row").length).toBe(4);
321
+ });
322
+ }
323
+
324
+ beforeEach(() => {
325
+ getMock.mockReset();
326
+ window.localStorage.clear();
327
+
328
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(NOW);
329
+ jest
330
+ .spyOn(OneUptimeDate, "getCurrentTimezone")
331
+ .mockReturnValue(
332
+ "UTC" as ReturnType<typeof OneUptimeDate.getCurrentTimezone>,
333
+ );
334
+ jest
335
+ .spyOn(OneUptimeDate, "getUserPrefers12HourFormat")
336
+ .mockReturnValue(false);
337
+
338
+ answerWith(timeline());
339
+ });
340
+
341
+ afterEach(() => {
342
+ cleanup();
343
+ jest.restoreAllMocks();
344
+ });
345
+
346
+ describe("first load", () => {
347
+ test("asks for the current ISO week in the viewer's zone", async () => {
348
+ renderTimeline();
349
+ await loaded();
350
+
351
+ expect(getMock).toHaveBeenCalledTimes(1);
352
+ expect(requested(0)).toEqual({
353
+ from: WEEK_FROM,
354
+ to: WEEK_TO,
355
+ teamId: null,
356
+ });
357
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
358
+ "September 14 – 20, 2026",
359
+ );
360
+ });
361
+
362
+ test("shows skeleton rows until the answer arrives", async () => {
363
+ let resolve: (value: HTTPResponse<JSONObject>) => void = () => {};
364
+
365
+ getMock.mockImplementation(() => {
366
+ return new Promise<HTTPResponse<JSONObject>>(
367
+ (done: (value: HTTPResponse<JSONObject>) => void) => {
368
+ resolve = done;
369
+ },
370
+ );
371
+ });
372
+
373
+ renderTimeline();
374
+
375
+ expect(screen.getByTestId("timeline-skeleton")).toBeInTheDocument();
376
+ expect(screen.queryByTestId("timeline-summary")).not.toBeInTheDocument();
377
+
378
+ await act(async () => {
379
+ resolve(ok(timeline()));
380
+ });
381
+
382
+ await loaded();
383
+ expect(screen.queryByTestId("timeline-skeleton")).not.toBeInTheDocument();
384
+ });
385
+
386
+ test("seven day columns, today marked", async () => {
387
+ renderTimeline();
388
+ await loaded();
389
+
390
+ const headers: Array<HTMLElement> = screen.getAllByTestId(
391
+ "timeline-day-header",
392
+ );
393
+
394
+ expect(headers).toHaveLength(7);
395
+ expect(
396
+ headers.map((header: HTMLElement) => {
397
+ return header.getAttribute("data-today");
398
+ }),
399
+ ).toEqual(["false", "false", "false", "true", "false", "false", "false"]);
400
+ expect(screen.getByTestId("timeline-now-line")).toBeInTheDocument();
401
+ });
402
+
403
+ test("rows are grouped by owner team; a shared schedule appears under each team", async () => {
404
+ renderTimeline();
405
+ await loaded();
406
+
407
+ expect(
408
+ screen
409
+ .getAllByTestId("timeline-group-header")
410
+ .map((header: HTMLElement) => {
411
+ return header.textContent;
412
+ }),
413
+ ).toEqual(["Payments2", "SRE1Your team", "No owner team1"]);
414
+
415
+ expect(rowIds()).toEqual(["s-pay", "s-sre", "s-sre", "s-legacy"]);
416
+ });
417
+
418
+ test("schedule names link to the schedule's page", async () => {
419
+ renderTimeline();
420
+ await loaded();
421
+
422
+ const link: HTMLElement = within(row("s-pay")).getByRole("link", {
423
+ name: "Payments primary",
424
+ });
425
+
426
+ expect(link.getAttribute("href")).toContain(
427
+ "/on-call-duty/schedules/s-pay",
428
+ );
429
+ });
430
+
431
+ test("each row says who is on call now, or that nobody is", async () => {
432
+ renderTimeline();
433
+ await loaded();
434
+
435
+ expect(
436
+ within(row("s-pay")).getByTestId("timeline-on-call-now"),
437
+ ).toHaveTextContent("Bob Bergon call now");
438
+ expect(
439
+ within(row("s-sre")).getByTestId("timeline-uncovered-now"),
440
+ ).toHaveTextContent("No one on call now");
441
+ expect(
442
+ within(row("s-legacy")).getByTestId("timeline-uncovered-now"),
443
+ ).toBeInTheDocument();
444
+ });
445
+
446
+ test("the caller's own schedules are tagged", async () => {
447
+ renderTimeline();
448
+ await loaded();
449
+
450
+ expect(within(row("s-sre")).getByText("You")).toBeInTheDocument();
451
+ expect(within(row("s-pay")).queryByText("You")).not.toBeInTheDocument();
452
+ });
453
+
454
+ test("bars for shifts, hatched gaps, and the overridden person underneath", async () => {
455
+ renderTimeline();
456
+ await loaded();
457
+
458
+ const payBars: Array<HTMLElement> = within(row("s-pay")).getAllByTestId(
459
+ "timeline-shift-bar",
460
+ );
461
+
462
+ expect(
463
+ payBars.map((bar: HTMLElement) => {
464
+ return [
465
+ bar.getAttribute("data-user-id"),
466
+ bar.getAttribute("data-active"),
467
+ ];
468
+ }),
469
+ ).toEqual([
470
+ ["u-alice", "false"],
471
+ ["u-bob", "true"],
472
+ ]);
473
+ expect(within(row("s-pay")).queryAllByTestId("timeline-gap")).toHaveLength(
474
+ 0,
475
+ );
476
+
477
+ const sreRow: HTMLElement = row("s-sre");
478
+
479
+ expect(within(sreRow).getAllByTestId("timeline-gap")).toHaveLength(1);
480
+ expect(
481
+ within(sreRow)
482
+ .getAllByTestId("timeline-gap")[0]
483
+ ?.getAttribute("aria-label"),
484
+ ).toBe("No one on call, Wed, Sep 16, 00:00 → Fri, Sep 18, 00:00");
485
+
486
+ const overrideBar: HTMLElement | undefined = within(sreRow)
487
+ .getAllByTestId("timeline-shift-bar")
488
+ .find((bar: HTMLElement) => {
489
+ return bar.getAttribute("data-override") === "true";
490
+ });
491
+
492
+ expect(overrideBar).toHaveTextContent("⇄ Dan Diaz");
493
+ expect(overrideBar?.getAttribute("aria-label")).toContain(
494
+ "covering for Carol Chen",
495
+ );
496
+
497
+ const overridden: HTMLElement = within(sreRow).getByTestId(
498
+ "timeline-overridden-segment",
499
+ );
500
+
501
+ expect(overridden).toHaveTextContent("Carol Chen (overridden)");
502
+
503
+ // Nobody at all: the whole week is one gap.
504
+ expect(within(row("s-legacy")).getAllByTestId("timeline-gap")).toHaveLength(
505
+ 1,
506
+ );
507
+ });
508
+
509
+ test("the summary counts schedules, coverage now, and gaps", async () => {
510
+ renderTimeline();
511
+ await loaded();
512
+
513
+ const summary: HTMLElement = screen.getByTestId("timeline-summary");
514
+
515
+ expect(summary).toHaveTextContent("3schedules");
516
+ expect(
517
+ screen.getByTestId("timeline-summary-covered-now"),
518
+ ).toHaveTextContent("1covered now");
519
+ expect(
520
+ screen.getByTestId("timeline-summary-uncovered-now"),
521
+ ).toHaveTextContent("2with no one on call now");
522
+ expect(screen.getByTestId("timeline-summary-gaps")).toHaveTextContent(
523
+ "2with coverage gaps this week",
524
+ );
525
+ });
526
+
527
+ test("the legend lists everyone on call this week, heaviest load first", async () => {
528
+ renderTimeline();
529
+ await loaded();
530
+
531
+ const people: Array<string> = screen
532
+ .getAllByTestId("timeline-person")
533
+ .map((chip: HTMLElement) => {
534
+ return chip.getAttribute("data-user-id") || "";
535
+ });
536
+
537
+ // Bob 3d15h, Alice 3d9h, Dan 3d, Carol 2d.
538
+ expect(people).toEqual(["u-bob", "u-alice", "u-dan", "u-carol"]);
539
+ expect(screen.getByTestId("timeline-legend")).toHaveTextContent(
540
+ "Times in UTC.",
541
+ );
542
+ });
543
+ });
544
+
545
+ describe("navigation", () => {
546
+ test("next, previous and today move the requested window a week at a time", async () => {
547
+ renderTimeline();
548
+ await loaded();
549
+
550
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
551
+
552
+ await waitFor(() => {
553
+ expect(lastRequested().from).toBe("2026-09-21T00:00:00.000Z");
554
+ });
555
+ expect(lastRequested().to).toBe("2026-09-28T00:00:00.000Z");
556
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
557
+ "September 21 – 27, 2026",
558
+ );
559
+
560
+ // Not this week any more: no red line, no "now" statements.
561
+ await waitFor(() => {
562
+ expect(screen.queryByTestId("timeline-now-line")).not.toBeInTheDocument();
563
+ });
564
+ expect(
565
+ screen.queryByTestId("timeline-summary-covered-now"),
566
+ ).not.toBeInTheDocument();
567
+
568
+ fireEvent.click(screen.getByTestId("timeline-previous-button"));
569
+ fireEvent.click(screen.getByTestId("timeline-previous-button"));
570
+
571
+ await waitFor(() => {
572
+ expect(lastRequested().from).toBe("2026-09-07T00:00:00.000Z");
573
+ });
574
+
575
+ fireEvent.click(screen.getByTestId("timeline-today-button"));
576
+
577
+ await waitFor(() => {
578
+ expect(lastRequested().from).toBe(WEEK_FROM);
579
+ });
580
+ });
581
+
582
+ test("outside the current week the rows describe the period instead of 'now'", async () => {
583
+ renderTimeline();
584
+ await loaded();
585
+
586
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
587
+
588
+ await waitFor(() => {
589
+ expect(
590
+ within(row("s-legacy")).getByText("No one on call this week"),
591
+ ).toBeInTheDocument();
592
+ });
593
+ });
594
+
595
+ test("the month view asks for the calendar month and is remembered", async () => {
596
+ renderTimeline();
597
+ await loaded();
598
+
599
+ fireEvent.click(screen.getByTestId("timeline-mode-month"));
600
+
601
+ await waitFor(() => {
602
+ expect(lastRequested()).toEqual({
603
+ from: "2026-09-01T00:00:00.000Z",
604
+ to: "2026-10-01T00:00:00.000Z",
605
+ teamId: null,
606
+ });
607
+ });
608
+
609
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
610
+ "September 2026",
611
+ );
612
+ expect(screen.getByTestId("timeline-mode-month")).toHaveAttribute(
613
+ "aria-checked",
614
+ "true",
615
+ );
616
+
617
+ await waitFor(() => {
618
+ expect(screen.getAllByTestId("timeline-day-header")).toHaveLength(30);
619
+ });
620
+
621
+ expect(window.localStorage.getItem(VIEW_MODE_STORAGE_KEY)).toBe("month");
622
+ });
623
+
624
+ test("a remembered month view is used on the next visit", async () => {
625
+ window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, "month");
626
+
627
+ renderTimeline();
628
+
629
+ await waitFor(() => {
630
+ expect(requested(0).from).toBe("2026-09-01T00:00:00.000Z");
631
+ });
632
+ });
633
+
634
+ test("the back button stops where the server stops computing", async () => {
635
+ renderTimeline();
636
+ await loaded();
637
+
638
+ fireEvent.click(screen.getByTestId("timeline-mode-month"));
639
+
640
+ const back: HTMLElement = screen.getByTestId("timeline-previous-button");
641
+
642
+ /*
643
+ * 180 days before 17 Sep 2026 is 21 Mar 2026: March is reachable,
644
+ * February is not.
645
+ */
646
+ for (let step: number = 0; step < 6; step++) {
647
+ expect(back).not.toBeDisabled();
648
+ fireEvent.click(back);
649
+ }
650
+
651
+ await waitFor(() => {
652
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
653
+ "March 2026",
654
+ );
655
+ });
656
+ expect(back).toBeDisabled();
657
+ expect(screen.getByTestId("timeline-next-button")).not.toBeDisabled();
658
+ });
659
+
660
+ test("a slower answer for a window the reader already left is ignored", async () => {
661
+ const pending: Array<(value: HTTPResponse<JSONObject>) => void> = [];
662
+
663
+ getMock.mockImplementation(() => {
664
+ return new Promise<HTTPResponse<JSONObject>>(
665
+ (done: (value: HTTPResponse<JSONObject>) => void) => {
666
+ pending.push(done);
667
+ },
668
+ );
669
+ });
670
+
671
+ renderTimeline();
672
+
673
+ await waitFor(() => {
674
+ expect(pending).toHaveLength(1);
675
+ });
676
+
677
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
678
+
679
+ await waitFor(() => {
680
+ expect(pending).toHaveLength(2);
681
+ });
682
+
683
+ // The next week answers first...
684
+ await act(async () => {
685
+ pending[1]!(
686
+ ok(
687
+ timeline({
688
+ from: "2026-09-21T00:00:00.000Z",
689
+ to: "2026-09-28T00:00:00.000Z",
690
+ schedules: [LEGACY],
691
+ teams: [],
692
+ }),
693
+ ),
694
+ );
695
+ });
696
+
697
+ // ...then the stale current week.
698
+ await act(async () => {
699
+ pending[0]!(ok(timeline()));
700
+ });
701
+
702
+ await waitFor(() => {
703
+ expect(rowIds()).toEqual(["s-legacy"]);
704
+ });
705
+ });
706
+ });
707
+
708
+ describe("filters", () => {
709
+ test("search matches schedules, teams and people", async () => {
710
+ renderTimeline();
711
+ await loaded();
712
+
713
+ fireEvent.change(screen.getByTestId("timeline-search"), {
714
+ target: { value: "dan" },
715
+ });
716
+
717
+ expect(rowIds()).toEqual(["s-sre", "s-sre"]);
718
+
719
+ fireEvent.change(screen.getByTestId("timeline-search"), {
720
+ target: { value: "legacy" },
721
+ });
722
+
723
+ expect(rowIds()).toEqual(["s-legacy"]);
724
+ });
725
+
726
+ test("the team picker offers My teams and every owner team", async () => {
727
+ renderTimeline();
728
+ await loaded();
729
+
730
+ const select: HTMLSelectElement = screen.getByTestId(
731
+ "timeline-team-filter",
732
+ ) as HTMLSelectElement;
733
+
734
+ expect(
735
+ Array.from(select.options).map((option: HTMLOptionElement) => {
736
+ return option.textContent;
737
+ }),
738
+ ).toEqual(["All teams", "My teams", "Payments", "SRE"]);
739
+
740
+ fireEvent.change(select, { target: { value: "t-sre" } });
741
+
742
+ // Only the chosen team's group, even though SRE primary is co-owned.
743
+ expect(rowIds()).toEqual(["s-sre"]);
744
+ expect(
745
+ screen
746
+ .getAllByTestId("timeline-group-header")
747
+ .map((header: HTMLElement) => {
748
+ return header.textContent;
749
+ }),
750
+ ).toEqual(["SRE1Your team"]);
751
+
752
+ fireEvent.change(select, { target: { value: "t-pay" } });
753
+
754
+ expect(rowIds()).toEqual(["s-pay", "s-sre"]);
755
+ });
756
+
757
+ test("My teams keeps the schedules a team the caller is in owns", async () => {
758
+ renderTimeline();
759
+ await loaded();
760
+
761
+ fireEvent.change(screen.getByTestId("timeline-team-filter"), {
762
+ target: { value: "__my_teams__" },
763
+ });
764
+
765
+ expect(rowIds()).toEqual(["s-sre"]);
766
+ expect(
767
+ screen
768
+ .getAllByTestId("timeline-group-header")
769
+ .map((header: HTMLElement) => {
770
+ return header.textContent;
771
+ }),
772
+ ).toEqual(["SRE1Your team"]);
773
+ });
774
+
775
+ test("Schedules I'm on", async () => {
776
+ renderTimeline();
777
+ await loaded();
778
+
779
+ const toggle: HTMLElement = screen.getByTestId("timeline-only-mine");
780
+
781
+ fireEvent.click(toggle);
782
+
783
+ expect(toggle).toHaveAttribute("aria-pressed", "true");
784
+ expect(new Set(rowIds())).toEqual(new Set(["s-sre"]));
785
+
786
+ fireEvent.click(toggle);
787
+
788
+ expect(rowIds()).toHaveLength(4);
789
+ });
790
+
791
+ test("the 'no one on call now' count filters to those schedules and back", async () => {
792
+ renderTimeline();
793
+ await loaded();
794
+
795
+ const chip: HTMLElement = screen.getByTestId(
796
+ "timeline-summary-uncovered-now",
797
+ );
798
+
799
+ fireEvent.click(chip);
800
+
801
+ expect(chip).toHaveAttribute("aria-pressed", "true");
802
+ expect(rowIds()).toEqual(["s-sre", "s-sre", "s-legacy"]);
803
+
804
+ // The counts do not move under the reader's feet.
805
+ expect(
806
+ screen.getByTestId("timeline-summary-covered-now"),
807
+ ).toHaveTextContent("1covered now");
808
+
809
+ fireEvent.click(chip);
810
+
811
+ expect(rowIds()).toHaveLength(4);
812
+ });
813
+
814
+ test("the 'coverage gaps' count filters too", async () => {
815
+ renderTimeline();
816
+ await loaded();
817
+
818
+ fireEvent.click(screen.getByTestId("timeline-summary-gaps"));
819
+
820
+ expect(new Set(rowIds())).toEqual(new Set(["s-sre", "s-legacy"]));
821
+ });
822
+
823
+ test("no match: a message and a way back", async () => {
824
+ renderTimeline();
825
+ await loaded();
826
+
827
+ fireEvent.change(screen.getByTestId("timeline-search"), {
828
+ target: { value: "zzz" },
829
+ });
830
+
831
+ expect(screen.getByTestId("timeline-no-matches")).toHaveTextContent(
832
+ "No schedules match these filters",
833
+ );
834
+
835
+ fireEvent.click(
836
+ within(screen.getByTestId("timeline-no-matches")).getByText(
837
+ "Clear filters",
838
+ ),
839
+ );
840
+
841
+ expect(rowIds()).toHaveLength(4);
842
+ expect(screen.getByTestId("timeline-search")).toHaveValue("");
843
+ });
844
+
845
+ test("Clear filters resets every filter", async () => {
846
+ renderTimeline();
847
+ await loaded();
848
+
849
+ expect(
850
+ screen.queryByTestId("timeline-clear-filters"),
851
+ ).not.toBeInTheDocument();
852
+
853
+ fireEvent.click(screen.getByTestId("timeline-only-mine"));
854
+ fireEvent.change(screen.getByTestId("timeline-search"), {
855
+ target: { value: "sre" },
856
+ });
857
+
858
+ fireEvent.click(screen.getByTestId("timeline-clear-filters"));
859
+
860
+ expect(rowIds()).toHaveLength(4);
861
+ expect(screen.getByTestId("timeline-only-mine")).toHaveAttribute(
862
+ "aria-pressed",
863
+ "false",
864
+ );
865
+ });
866
+
867
+ test("grouping can be turned off, and that is remembered", async () => {
868
+ renderTimeline();
869
+ await loaded();
870
+
871
+ fireEvent.click(screen.getByTestId("timeline-group-by-team"));
872
+
873
+ expect(screen.queryAllByTestId("timeline-group-header")).toHaveLength(0);
874
+ expect(rowIds()).toEqual(["s-pay", "s-sre", "s-legacy"]);
875
+ expect(
876
+ JSON.parse(
877
+ window.localStorage.getItem(GROUP_BY_TEAM_STORAGE_KEY) || "null",
878
+ ),
879
+ ).toBe(false);
880
+ });
881
+
882
+ test("a group collapses and expands", async () => {
883
+ renderTimeline();
884
+ await loaded();
885
+
886
+ const header: HTMLElement = screen.getAllByTestId(
887
+ "timeline-group-header",
888
+ )[0] as HTMLElement;
889
+ const toggle: HTMLElement = within(header).getByRole("button");
890
+
891
+ fireEvent.click(toggle);
892
+
893
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
894
+ expect(rowIds()).toEqual(["s-sre", "s-legacy"]);
895
+
896
+ fireEvent.click(toggle);
897
+
898
+ expect(rowIds()).toHaveLength(4);
899
+ });
900
+ });
901
+
902
+ describe("highlighting a person", () => {
903
+ test("from the legend: their bars are marked everywhere, and it toggles off", async () => {
904
+ renderTimeline();
905
+ await loaded();
906
+
907
+ const chip: HTMLElement = screen
908
+ .getAllByTestId("timeline-person")
909
+ .find((element: HTMLElement) => {
910
+ return element.getAttribute("data-user-id") === "u-dan";
911
+ }) as HTMLElement;
912
+
913
+ fireEvent.click(chip);
914
+
915
+ expect(chip).toHaveAttribute("aria-pressed", "true");
916
+
917
+ const pressed: Array<string> = screen
918
+ .getAllByTestId("timeline-shift-bar")
919
+ .filter((bar: HTMLElement) => {
920
+ return bar.getAttribute("aria-pressed") === "true";
921
+ })
922
+ .map((bar: HTMLElement) => {
923
+ return bar.getAttribute("data-user-id") || "";
924
+ });
925
+
926
+ // Dan's one shift, drawn under both groups SRE primary appears in.
927
+ expect(pressed).toEqual(["u-dan", "u-dan"]);
928
+
929
+ fireEvent.click(screen.getByTestId("timeline-clear-highlight"));
930
+
931
+ expect(chip).toHaveAttribute("aria-pressed", "false");
932
+ });
933
+
934
+ test("from a bar", async () => {
935
+ renderTimeline();
936
+ await loaded();
937
+
938
+ const bar: HTMLElement = within(row("s-pay"))
939
+ .getAllByTestId("timeline-shift-bar")
940
+ .find((element: HTMLElement) => {
941
+ return element.getAttribute("data-user-id") === "u-bob";
942
+ }) as HTMLElement;
943
+
944
+ fireEvent.click(bar);
945
+
946
+ expect(bar).toHaveAttribute("aria-pressed", "true");
947
+ expect(
948
+ screen.getAllByTestId("timeline-person").find((element: HTMLElement) => {
949
+ return element.getAttribute("data-user-id") === "u-bob";
950
+ }),
951
+ ).toHaveAttribute("aria-pressed", "true");
952
+
953
+ fireEvent.click(bar);
954
+
955
+ expect(bar).toHaveAttribute("aria-pressed", "false");
956
+ });
957
+ });
958
+
959
+ describe("locked to a team", () => {
960
+ test("asks for that team, and hides the team picker and grouping", async () => {
961
+ const teamId: ObjectID = ObjectID.generate();
962
+
963
+ renderTimeline({ teamId });
964
+
965
+ // Ungrouped: each schedule once.
966
+ await waitFor(() => {
967
+ expect(rowIds()).toEqual(["s-pay", "s-sre", "s-legacy"]);
968
+ });
969
+
970
+ expect(requested(0).teamId).toBe(teamId.toString());
971
+ expect(
972
+ screen.queryByTestId("timeline-team-filter"),
973
+ ).not.toBeInTheDocument();
974
+ expect(
975
+ screen.queryByTestId("timeline-group-by-team"),
976
+ ).not.toBeInTheDocument();
977
+ expect(screen.queryAllByTestId("timeline-group-header")).toHaveLength(0);
978
+ expect(screen.getByText("On-Call Schedules")).toBeInTheDocument();
979
+ });
980
+
981
+ test("a team with no schedules is told how to add one", async () => {
982
+ answerWith(timeline({ schedules: [], teams: [], totalScheduleCount: 0 }));
983
+
984
+ renderTimeline({ teamId: ObjectID.generate() });
985
+
986
+ await waitFor(() => {
987
+ expect(
988
+ screen.getByText("This team does not own any on-call schedules yet"),
989
+ ).toBeInTheDocument();
990
+ });
991
+ });
992
+ });
993
+
994
+ describe("empty, error and partial answers", () => {
995
+ test("a project without schedules gets an empty state with a way forward", async () => {
996
+ answerWith(timeline({ schedules: [], teams: [], totalScheduleCount: 0 }));
997
+
998
+ renderTimeline();
999
+
1000
+ await waitFor(() => {
1001
+ expect(screen.getByText("No on-call schedules yet")).toBeInTheDocument();
1002
+ });
1003
+ expect(screen.getByText("Go to On-Call Schedules")).toBeInTheDocument();
1004
+ expect(screen.queryByTestId("timeline-summary")).not.toBeInTheDocument();
1005
+ });
1006
+
1007
+ test("an error shows the server's message, and retry asks again", async () => {
1008
+ getMock.mockResolvedValueOnce(
1009
+ new HTTPErrorResponse(
1010
+ 422,
1011
+ { message: "You do not have permission to read schedules." },
1012
+ {},
1013
+ ),
1014
+ );
1015
+
1016
+ renderTimeline();
1017
+
1018
+ await waitFor(() => {
1019
+ expect(
1020
+ screen.getByText("You do not have permission to read schedules."),
1021
+ ).toBeInTheDocument();
1022
+ });
1023
+
1024
+ fireEvent.click(screen.getByTestId("refresh-button"));
1025
+
1026
+ await loaded();
1027
+ expect(getMock).toHaveBeenCalledTimes(2);
1028
+ });
1029
+
1030
+ test("a capped schedule list says so", async () => {
1031
+ answerWith(timeline({ totalScheduleCount: 412, schedulesTruncated: true }));
1032
+
1033
+ renderTimeline();
1034
+ await loaded();
1035
+
1036
+ expect(
1037
+ screen.getByTestId("timeline-schedules-truncated"),
1038
+ ).toHaveTextContent("Showing the first 3 of 412 schedules");
1039
+ });
1040
+
1041
+ test("an incomplete rotation says so", async () => {
1042
+ answerWith(timeline({ truncated: true }));
1043
+
1044
+ renderTimeline();
1045
+ await loaded();
1046
+
1047
+ expect(screen.getByTestId("timeline-engine-truncated")).toBeInTheDocument();
1048
+ });
1049
+
1050
+ test("a partly served window is shaded and explained, not drawn as 'nobody on call'", async () => {
1051
+ getMock.mockResolvedValue(
1052
+ ok(timeline({ from: "2026-09-16T00:00:00.000Z", to: WEEK_TO })),
1053
+ );
1054
+
1055
+ renderTimeline();
1056
+ await loaded();
1057
+
1058
+ expect(screen.getByTestId("timeline-outside-range")).toBeInTheDocument();
1059
+ expect(screen.getAllByTestId("timeline-unavailable")).toHaveLength(1);
1060
+
1061
+ // Legacy has no shifts: its gap starts where the served window starts.
1062
+ expect(
1063
+ within(row("s-legacy"))
1064
+ .getByTestId("timeline-gap")
1065
+ .getAttribute("aria-label"),
1066
+ ).toBe("No one on call, Wed, Sep 16, 00:00 → Mon, Sep 21, 00:00");
1067
+ });
1068
+ });
1069
+
1070
+ // -- Behaviour found in review -------------------------------------------------
1071
+
1072
+ function pendingAnswers(): Array<(value: HTTPResponse<JSONObject>) => void> {
1073
+ const pending: Array<(value: HTTPResponse<JSONObject>) => void> = [];
1074
+
1075
+ getMock.mockImplementation(() => {
1076
+ return new Promise<HTTPResponse<JSONObject>>(
1077
+ (done: (value: HTTPResponse<JSONObject>) => void) => {
1078
+ pending.push(done);
1079
+ },
1080
+ );
1081
+ });
1082
+
1083
+ return pending;
1084
+ }
1085
+
1086
+ function dayKeys(): Array<string> {
1087
+ return screen
1088
+ .getAllByTestId("timeline-day-header")
1089
+ .map((header: HTMLElement) => {
1090
+ return header.getAttribute("title") || "";
1091
+ });
1092
+ }
1093
+
1094
+ describe("while the next range loads", () => {
1095
+ test("the previous week stays drawn, dimmed, against its own days", async () => {
1096
+ const pending: Array<(value: HTTPResponse<JSONObject>) => void> =
1097
+ pendingAnswers();
1098
+
1099
+ renderTimeline();
1100
+
1101
+ await waitFor(() => {
1102
+ expect(pending).toHaveLength(1);
1103
+ });
1104
+
1105
+ await act(async () => {
1106
+ pending[0]!(ok(timeline()));
1107
+ });
1108
+
1109
+ await loaded();
1110
+
1111
+ const rowsBefore: Array<string> = rowIds();
1112
+ const gapsBefore: number = screen.getAllByTestId("timeline-gap").length;
1113
+
1114
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1115
+
1116
+ await waitFor(() => {
1117
+ expect(pending).toHaveLength(2);
1118
+ });
1119
+
1120
+ // The label follows the request...
1121
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
1122
+ "September 21 – 27, 2026",
1123
+ );
1124
+ expect(screen.getByTestId("timeline-refreshing")).toBeInTheDocument();
1125
+
1126
+ // ...the grid stays on the week its data belongs to, gaps and all.
1127
+ expect(dayKeys()[0]).toBe("2026-09-14");
1128
+ expect(rowIds()).toEqual(rowsBefore);
1129
+ expect(screen.getAllByTestId("timeline-gap")).toHaveLength(gapsBefore);
1130
+ expect(screen.getByTestId("timeline-now-line")).toBeInTheDocument();
1131
+ expect(
1132
+ screen.queryByTestId("timeline-unavailable"),
1133
+ ).not.toBeInTheDocument();
1134
+ expect(
1135
+ screen.queryByTestId("timeline-outside-range"),
1136
+ ).not.toBeInTheDocument();
1137
+
1138
+ await act(async () => {
1139
+ pending[1]!(
1140
+ ok(
1141
+ timeline({
1142
+ from: "2026-09-21T00:00:00.000Z",
1143
+ to: "2026-09-28T00:00:00.000Z",
1144
+ }),
1145
+ ),
1146
+ );
1147
+ });
1148
+
1149
+ await waitFor(() => {
1150
+ expect(dayKeys()[0]).toBe("2026-09-21");
1151
+ });
1152
+ expect(screen.queryByTestId("timeline-refreshing")).not.toBeInTheDocument();
1153
+ expect(screen.queryByTestId("timeline-now-line")).not.toBeInTheDocument();
1154
+ });
1155
+
1156
+ test("an empty team page stays on its empty state", async () => {
1157
+ const pending: Array<(value: HTTPResponse<JSONObject>) => void> =
1158
+ pendingAnswers();
1159
+
1160
+ renderTimeline({ teamId: ObjectID.generate() });
1161
+
1162
+ await waitFor(() => {
1163
+ expect(pending).toHaveLength(1);
1164
+ });
1165
+
1166
+ await act(async () => {
1167
+ pending[0]!(ok(timeline({ schedules: [], teams: [] })));
1168
+ });
1169
+
1170
+ await waitFor(() => {
1171
+ expect(
1172
+ screen.getByText("This team does not own any on-call schedules yet"),
1173
+ ).toBeInTheDocument();
1174
+ });
1175
+
1176
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1177
+
1178
+ await waitFor(() => {
1179
+ expect(pending).toHaveLength(2);
1180
+ });
1181
+
1182
+ expect(
1183
+ screen.getByText("This team does not own any on-call schedules yet"),
1184
+ ).toBeInTheDocument();
1185
+ expect(screen.queryByTestId("timeline-summary")).not.toBeInTheDocument();
1186
+ expect(screen.queryByTestId("timeline-no-matches")).not.toBeInTheDocument();
1187
+ });
1188
+
1189
+ test("leaving the page stops a request that is waiting to retry", async () => {
1190
+ getMock.mockResolvedValue(
1191
+ new HTTPErrorResponse(503, { message: "busy" }, { "retry-after": "1" }),
1192
+ );
1193
+
1194
+ jest.useFakeTimers();
1195
+
1196
+ try {
1197
+ renderTimeline();
1198
+
1199
+ await act(async () => {
1200
+ await Promise.resolve();
1201
+ });
1202
+
1203
+ expect(getMock).toHaveBeenCalledTimes(1);
1204
+
1205
+ cleanup();
1206
+
1207
+ await act(async () => {
1208
+ jest.advanceTimersByTime(5000);
1209
+ await Promise.resolve();
1210
+ });
1211
+
1212
+ expect(getMock).toHaveBeenCalledTimes(1);
1213
+ } finally {
1214
+ jest.useRealTimers();
1215
+ }
1216
+ });
1217
+ });
1218
+
1219
+ describe("changing the time zone", () => {
1220
+ test("keeps the same dates on screen, even across the date line", async () => {
1221
+ // Sunday 22:00 UTC: already Monday in Tokyo.
1222
+ jest
1223
+ .spyOn(OneUptimeDate, "getCurrentDate")
1224
+ .mockReturnValue(new Date("2026-09-20T22:00:00.000Z"));
1225
+
1226
+ renderTimeline();
1227
+
1228
+ await loaded();
1229
+
1230
+ expect(dayKeys()[0]).toBe("2026-09-14");
1231
+
1232
+ fireEvent.click(screen.getByTestId("timezone-to-Asia/Tokyo"));
1233
+
1234
+ // The week of 14 September, now laid out in Tokyo.
1235
+ await waitFor(() => {
1236
+ expect(lastRequested().from).toBe("2026-09-13T15:00:00.000Z");
1237
+ });
1238
+ expect(lastRequested().to).toBe("2026-09-20T15:00:00.000Z");
1239
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
1240
+ "September 14 – 20, 2026",
1241
+ );
1242
+ });
1243
+
1244
+ test("after paging forward, a zone to the west does not page back", async () => {
1245
+ renderTimeline();
1246
+
1247
+ await loaded();
1248
+
1249
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1250
+
1251
+ await waitFor(() => {
1252
+ expect(lastRequested().from).toBe("2026-09-21T00:00:00.000Z");
1253
+ });
1254
+
1255
+ fireEvent.click(screen.getByTestId("timezone-to-America/New_York"));
1256
+
1257
+ await waitFor(() => {
1258
+ expect(lastRequested().from).toBe("2026-09-21T04:00:00.000Z");
1259
+ });
1260
+ expect(screen.getByTestId("timeline-range-label")).toHaveTextContent(
1261
+ "September 21 – 27, 2026",
1262
+ );
1263
+ });
1264
+ });
1265
+
1266
+ describe("the Week / Month switch", () => {
1267
+ test("is one tab stop, and the arrow keys move the selection", async () => {
1268
+ renderTimeline();
1269
+
1270
+ await loaded();
1271
+
1272
+ const weekButton: HTMLElement = screen.getByTestId("timeline-mode-week");
1273
+ const monthButton: HTMLElement = screen.getByTestId("timeline-mode-month");
1274
+
1275
+ expect(weekButton).toHaveAttribute("tabindex", "0");
1276
+ expect(monthButton).toHaveAttribute("tabindex", "-1");
1277
+
1278
+ weekButton.focus();
1279
+ fireEvent.keyDown(weekButton, { key: "ArrowRight" });
1280
+
1281
+ await waitFor(() => {
1282
+ expect(lastRequested().from).toBe("2026-09-01T00:00:00.000Z");
1283
+ });
1284
+
1285
+ expect(monthButton).toHaveAttribute("aria-checked", "true");
1286
+ expect(monthButton).toHaveAttribute("tabindex", "0");
1287
+ expect(document.activeElement).toBe(monthButton);
1288
+
1289
+ fireEvent.keyDown(monthButton, { key: "Home" });
1290
+
1291
+ await waitFor(() => {
1292
+ expect(weekButton).toHaveAttribute("aria-checked", "true");
1293
+ });
1294
+ expect(document.activeElement).toBe(weekButton);
1295
+
1296
+ // Other keys are left alone.
1297
+ fireEvent.keyDown(weekButton, { key: "a" });
1298
+ expect(weekButton).toHaveAttribute("aria-checked", "true");
1299
+ });
1300
+ });
1301
+
1302
+ describe("attention filters", () => {
1303
+ test("'no one on call now' is dropped when the reader pages away from now", async () => {
1304
+ renderTimeline();
1305
+
1306
+ await loaded();
1307
+
1308
+ fireEvent.click(screen.getByTestId("timeline-summary-uncovered-now"));
1309
+
1310
+ expect(rowIds()).toEqual(["s-sre", "s-sre", "s-legacy"]);
1311
+
1312
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1313
+
1314
+ // Next week cannot say who is uncovered "now": every schedule is back.
1315
+ await waitFor(() => {
1316
+ expect(dayKeys()[0]).toBe("2026-09-21");
1317
+ });
1318
+ await waitFor(() => {
1319
+ expect(rowIds()).toHaveLength(4);
1320
+ });
1321
+ expect(screen.queryByTestId("timeline-no-matches")).not.toBeInTheDocument();
1322
+ });
1323
+
1324
+ test("an active 'coverage gaps' chip stays visible even at zero, so it can be turned off", async () => {
1325
+ const covered: ScheduleTimelineScheduleJson = {
1326
+ ...PAYMENTS,
1327
+ shifts: [
1328
+ {
1329
+ ...PAYMENTS.shifts[0]!,
1330
+ start: "2026-09-01T00:00:00.000Z",
1331
+ end: "2026-10-15T00:00:00.000Z",
1332
+ },
1333
+ ],
1334
+ };
1335
+
1336
+ getMock.mockImplementation(async (options: { url: URL }) => {
1337
+ const parsed: globalThis.URL = new globalThis.URL(options.url.toString());
1338
+ const from: string = parsed.searchParams.get("from") || WEEK_FROM;
1339
+ const to: string = parsed.searchParams.get("to") || WEEK_TO;
1340
+
1341
+ return ok(
1342
+ from === WEEK_FROM
1343
+ ? timeline()
1344
+ : timeline({ from, to, schedules: [covered] }),
1345
+ );
1346
+ });
1347
+
1348
+ renderTimeline();
1349
+
1350
+ await loaded();
1351
+
1352
+ fireEvent.click(screen.getByTestId("timeline-summary-gaps"));
1353
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1354
+
1355
+ // Next week has no gaps at all; the chip must stay, at zero and pressed.
1356
+ await waitFor(() => {
1357
+ expect(screen.getByTestId("timeline-summary-gaps")).toHaveTextContent(
1358
+ "0with coverage gaps this week",
1359
+ );
1360
+ });
1361
+
1362
+ const chip: HTMLElement = screen.getByTestId("timeline-summary-gaps");
1363
+
1364
+ expect(chip).toHaveTextContent("0with coverage gaps this week");
1365
+ expect(chip).toHaveAttribute("aria-pressed", "true");
1366
+ expect(screen.getByTestId("timeline-no-matches")).toBeInTheDocument();
1367
+
1368
+ fireEvent.click(chip);
1369
+
1370
+ expect(rowIds()).toEqual(["s-pay"]);
1371
+ expect(
1372
+ screen.queryByTestId("timeline-summary-gaps"),
1373
+ ).not.toBeInTheDocument();
1374
+ });
1375
+ });
1376
+
1377
+ describe("layout details", () => {
1378
+ test("'no schedules match' is drawn outside the scrolling grid", async () => {
1379
+ renderTimeline();
1380
+
1381
+ await loaded();
1382
+
1383
+ fireEvent.change(screen.getByTestId("timeline-search"), {
1384
+ target: { value: "zzz" },
1385
+ });
1386
+
1387
+ expect(screen.getByTestId("timeline-no-matches")).toBeInTheDocument();
1388
+ expect(
1389
+ screen.queryByTestId("schedule-timeline-grid"),
1390
+ ).not.toBeInTheDocument();
1391
+ });
1392
+
1393
+ test("a row reads in time order, each override lane right after its shift", async () => {
1394
+ renderTimeline();
1395
+
1396
+ await loaded();
1397
+
1398
+ const order: Array<string> = within(row("s-sre"))
1399
+ .getAllByTestId(/timeline-(shift-bar|gap|overridden-segment)/)
1400
+ .map((element: HTMLElement) => {
1401
+ const kind: string = element.getAttribute("data-testid") || "";
1402
+ return kind === "timeline-shift-bar"
1403
+ ? `shift:${element.getAttribute("data-user-id")}`
1404
+ : kind.replace("timeline-", "");
1405
+ });
1406
+
1407
+ expect(order).toEqual([
1408
+ "shift:u-carol",
1409
+ "gap",
1410
+ "shift:u-dan",
1411
+ "overridden-segment",
1412
+ ]);
1413
+ });
1414
+
1415
+ test("a served window wholly outside the range shades the whole range", async () => {
1416
+ getMock.mockResolvedValue(
1417
+ ok(
1418
+ timeline({
1419
+ from: "2027-09-17T12:00:00.000Z",
1420
+ to: "2027-09-18T12:00:00.000Z",
1421
+ }),
1422
+ ),
1423
+ );
1424
+
1425
+ renderTimeline();
1426
+
1427
+ await loaded();
1428
+
1429
+ const bands: Array<HTMLElement> = screen.getAllByTestId(
1430
+ "timeline-unavailable",
1431
+ );
1432
+
1433
+ expect(bands).toHaveLength(1);
1434
+ expect(bands[0]?.style.left).toBe("0%");
1435
+ expect(bands[0]?.style.width).toBe("100%");
1436
+ expect(screen.getByTestId("timeline-outside-range")).toBeInTheDocument();
1437
+ expect(screen.queryAllByTestId("timeline-gap")).toHaveLength(0);
1438
+ });
1439
+
1440
+ test("keyboard focus uses an outline, which inline shadows and opacity cannot hide", async () => {
1441
+ renderTimeline();
1442
+
1443
+ await loaded();
1444
+
1445
+ for (const element of [
1446
+ ...screen.getAllByTestId("timeline-shift-bar"),
1447
+ ...screen.getAllByTestId("timeline-gap"),
1448
+ ...screen.getAllByTestId("timeline-overridden-segment"),
1449
+ ]) {
1450
+ expect(element.className).toContain("focus-visible:outline-2");
1451
+ expect(element.className).toContain("focus-visible:!opacity-100");
1452
+ expect(element.className).not.toContain("focus-visible:ring");
1453
+ }
1454
+ });
1455
+
1456
+ test("rows are memoized", () => {
1457
+ expect((TimelineRow as unknown as { $$typeof: symbol }).$$typeof).toBe(
1458
+ Symbol.for("react.memo"),
1459
+ );
1460
+ });
1461
+ });
1462
+
1463
+ describe("review round two", () => {
1464
+ test("paging away with 'no one on call now' on never flashes 'No schedules match'", async () => {
1465
+ const pending: Array<(value: HTTPResponse<JSONObject>) => void> =
1466
+ pendingAnswers();
1467
+
1468
+ renderTimeline();
1469
+
1470
+ await waitFor(() => {
1471
+ expect(pending).toHaveLength(1);
1472
+ });
1473
+ await act(async () => {
1474
+ pending[0]!(ok(timeline()));
1475
+ });
1476
+ await loaded();
1477
+
1478
+ fireEvent.click(screen.getByTestId("timeline-summary-uncovered-now"));
1479
+ fireEvent.click(screen.getByTestId("timeline-next-button"));
1480
+
1481
+ await waitFor(() => {
1482
+ expect(pending).toHaveLength(2);
1483
+ });
1484
+
1485
+ await act(async () => {
1486
+ pending[1]!(
1487
+ ok(
1488
+ timeline({
1489
+ from: "2026-09-21T00:00:00.000Z",
1490
+ to: "2026-09-28T00:00:00.000Z",
1491
+ }),
1492
+ ),
1493
+ );
1494
+ });
1495
+
1496
+ // The very first render of next week already shows every schedule.
1497
+ expect(screen.queryByTestId("timeline-no-matches")).not.toBeInTheDocument();
1498
+ expect(dayKeys()[0]).toBe("2026-09-21");
1499
+ expect(rowIds()).toHaveLength(4);
1500
+ });
1501
+
1502
+ test("a 'now' frozen on an earlier visit never marks a week the clock has left", () => {
1503
+ const data: TimelineData = TimelineModel.fromResponse(timeline());
1504
+ const groups: ReturnType<typeof TimelineModel.groupSchedules> =
1505
+ TimelineModel.groupSchedules({
1506
+ schedules: data.schedules,
1507
+ teams: data.teams,
1508
+ groupByTeam: false,
1509
+ });
1510
+
1511
+ const weekOf: (anchor: string) => TimelineRange = (
1512
+ anchor: string,
1513
+ ): TimelineRange => {
1514
+ return ScheduleTimelineLayout.getRange({
1515
+ mode: TimelineViewMode.Week,
1516
+ anchor: new Date(anchor),
1517
+ timezone: "UTC",
1518
+ });
1519
+ };
1520
+
1521
+ const thisWeek: TimelineRange = weekOf("2026-09-17T12:00:00.000Z");
1522
+ const lastWeek: TimelineRange = weekOf("2026-09-10T12:00:00.000Z");
1523
+
1524
+ const renderGrid: (range: TimelineRange, now: Date) => ReactElement = (
1525
+ range: TimelineRange,
1526
+ now: Date,
1527
+ ): ReactElement => {
1528
+ return (
1529
+ <MemoryRouter>
1530
+ <TimelineGrid
1531
+ range={range}
1532
+ groups={groups}
1533
+ now={now}
1534
+ computedWindow={{ start: range.start, end: range.end }}
1535
+ servedWindow={{ start: range.start, end: range.end }}
1536
+ showGroupHeaders={false}
1537
+ collapsedGroupKeys={new Set<string>()}
1538
+ onToggleGroup={() => {}}
1539
+ highlightedUserId={null}
1540
+ onToggleHighlight={() => {}}
1541
+ />
1542
+ </MemoryRouter>
1543
+ );
1544
+ };
1545
+
1546
+ // Thursday of this week: someone is on call now.
1547
+ const { rerender } = render(
1548
+ renderGrid(thisWeek, new Date("2026-09-17T12:00:00.000Z")),
1549
+ );
1550
+
1551
+ expect(
1552
+ screen.getAllByTestId("timeline-on-call-now").length,
1553
+ ).toBeGreaterThan(0);
1554
+
1555
+ // Page back; the clock is still Thursday.
1556
+ rerender(renderGrid(lastWeek, new Date("2026-09-17T12:00:00.000Z")));
1557
+
1558
+ // Come back to "this" week after the clock has moved on to the next one.
1559
+ rerender(renderGrid(thisWeek, new Date("2026-09-22T09:00:00.000Z")));
1560
+
1561
+ // The rows now describe the week, not a "now" inside it...
1562
+ expect(screen.queryAllByTestId("timeline-on-call-now")).toHaveLength(0);
1563
+ expect(screen.queryAllByTestId("timeline-uncovered-now")).toHaveLength(0);
1564
+
1565
+ /*
1566
+ * ...and the only active bar is the one that really contains the new
1567
+ * now: Bob's shift runs from Thursday into next week.
1568
+ */
1569
+ expect(
1570
+ screen
1571
+ .getAllByTestId("timeline-shift-bar")
1572
+ .filter((bar: HTMLElement) => {
1573
+ return bar.getAttribute("data-active") === "true";
1574
+ })
1575
+ .map((bar: HTMLElement) => {
1576
+ return bar.getAttribute("data-user-id");
1577
+ }),
1578
+ ).toEqual(["u-bob"]);
1579
+ });
1580
+ });