@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,218 @@
1
+ /*
2
+ * The window a scheduled status page report covers.
3
+ *
4
+ * The reported bug: "is there a way to have the reports be sent out monthly,
5
+ * i.e. to show July 1st - July 31st?" - it was not, because the window was
6
+ * always "the last N days counted back from whenever the cron fired", so a
7
+ * monthly report sent on 1 Aug covered 2 Jul - 1 Aug and never lined up with a
8
+ * calendar month. These tests pin the calendar option that fixes it, the
9
+ * timezone the boundaries are resolved in, and the rolling behaviour that
10
+ * existing status pages keep.
11
+ */
12
+
13
+ import OneUptimeDate from "../../../Types/Date";
14
+ import EventInterval from "../../../Types/Events/EventInterval";
15
+ import Recurring from "../../../Types/Events/Recurring";
16
+ import PositiveNumber from "../../../Types/PositiveNumber";
17
+ import StatusPageReportPeriodType from "../../../Types/StatusPage/StatusPageReportPeriodType";
18
+ import Timezone from "../../../Types/Timezone";
19
+ import StatusPageReportPeriodUtil, {
20
+ StatusPageReportPeriod,
21
+ } from "../../../Utils/StatusPage/ReportPeriod";
22
+ import { describe, expect, test } from "@jest/globals";
23
+
24
+ function every(intervalType: EventInterval, intervalCount: number): Recurring {
25
+ const recurring: Recurring = new Recurring();
26
+ recurring.intervalType = intervalType;
27
+ recurring.intervalCount = new PositiveNumber(intervalCount);
28
+ return recurring;
29
+ }
30
+
31
+ function iso(date: Date): string {
32
+ return date.toISOString();
33
+ }
34
+
35
+ describe("StatusPageReportPeriodUtil.getReportPeriod", () => {
36
+ describe("the previous calendar period", () => {
37
+ test("a monthly report sent in August covers all of July", () => {
38
+ const period: StatusPageReportPeriod =
39
+ StatusPageReportPeriodUtil.getReportPeriod({
40
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
41
+ reportRecurringInterval: every(EventInterval.Month, 1),
42
+ timezone: Timezone.UTC,
43
+ sentAt: OneUptimeDate.fromString("2026-08-01T09:00:00.000Z"),
44
+ });
45
+
46
+ expect(iso(period.startDate)).toBe("2026-07-01T00:00:00.000Z");
47
+ expect(iso(period.endDate)).toBe("2026-07-31T23:59:59.999Z");
48
+ expect(period.periodName).toBe("July 2026");
49
+ expect(period.reportDates).toBe("Jul 1, 2026 - Jul 31, 2026");
50
+ expect(period.numberOfDays).toBe(31);
51
+ });
52
+
53
+ test("still covers July when the email goes out later in August", () => {
54
+ /*
55
+ * The period is the last WHOLE one, not "the month before this instant",
56
+ * so moving the send time inside August must not slide the window.
57
+ */
58
+ const period: StatusPageReportPeriod =
59
+ StatusPageReportPeriodUtil.getReportPeriod({
60
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
61
+ reportRecurringInterval: every(EventInterval.Month, 1),
62
+ timezone: Timezone.UTC,
63
+ sentAt: OneUptimeDate.fromString("2026-08-27T16:45:00.000Z"),
64
+ });
65
+
66
+ expect(iso(period.startDate)).toBe("2026-07-01T00:00:00.000Z");
67
+ expect(period.periodName).toBe("July 2026");
68
+ });
69
+
70
+ test("resolves the month boundary in the report timezone, not the server's", () => {
71
+ /*
72
+ * 1 Aug 03:00 UTC is still 31 Jul in New York, so a status page reporting
73
+ * in New York has not finished July yet and must report June.
74
+ */
75
+ const period: StatusPageReportPeriod =
76
+ StatusPageReportPeriodUtil.getReportPeriod({
77
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
78
+ reportRecurringInterval: every(EventInterval.Month, 1),
79
+ timezone: Timezone.AmericaNew_York,
80
+ sentAt: OneUptimeDate.fromString("2026-08-01T03:00:00.000Z"),
81
+ });
82
+
83
+ // 1 Jun 00:00 EDT and 30 Jun 23:59:59.999 EDT, both UTC-4.
84
+ expect(iso(period.startDate)).toBe("2026-06-01T04:00:00.000Z");
85
+ expect(iso(period.endDate)).toBe("2026-07-01T03:59:59.999Z");
86
+ expect(period.periodName).toBe("June 2026");
87
+ });
88
+
89
+ test("a quarterly report covers the three whole months before it", () => {
90
+ const period: StatusPageReportPeriod =
91
+ StatusPageReportPeriodUtil.getReportPeriod({
92
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
93
+ reportRecurringInterval: every(EventInterval.Month, 3),
94
+ timezone: Timezone.UTC,
95
+ sentAt: OneUptimeDate.fromString("2026-10-01T09:00:00.000Z"),
96
+ });
97
+
98
+ expect(iso(period.startDate)).toBe("2026-07-01T00:00:00.000Z");
99
+ expect(iso(period.endDate)).toBe("2026-09-30T23:59:59.999Z");
100
+ // Three months have no single name a reader knows, so it stays a range.
101
+ expect(period.periodName).toBe("Jul 1, 2026 - Sep 30, 2026");
102
+ });
103
+
104
+ test("a weekly report covers Monday to Sunday", () => {
105
+ // Wednesday 5 Aug 2026.
106
+ const period: StatusPageReportPeriod =
107
+ StatusPageReportPeriodUtil.getReportPeriod({
108
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
109
+ reportRecurringInterval: every(EventInterval.Week, 1),
110
+ timezone: Timezone.UTC,
111
+ sentAt: OneUptimeDate.fromString("2026-08-05T09:00:00.000Z"),
112
+ });
113
+
114
+ // Monday 27 Jul through Sunday 2 Aug.
115
+ expect(iso(period.startDate)).toBe("2026-07-27T00:00:00.000Z");
116
+ expect(iso(period.endDate)).toBe("2026-08-02T23:59:59.999Z");
117
+ expect(period.periodName).toBe("the week of Jul 27, 2026");
118
+ expect(period.numberOfDays).toBe(7);
119
+ });
120
+
121
+ test("a daily report covers yesterday", () => {
122
+ const period: StatusPageReportPeriod =
123
+ StatusPageReportPeriodUtil.getReportPeriod({
124
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
125
+ reportRecurringInterval: every(EventInterval.Day, 1),
126
+ timezone: Timezone.UTC,
127
+ sentAt: OneUptimeDate.fromString("2026-08-05T09:00:00.000Z"),
128
+ });
129
+
130
+ expect(iso(period.startDate)).toBe("2026-08-04T00:00:00.000Z");
131
+ expect(iso(period.endDate)).toBe("2026-08-04T23:59:59.999Z");
132
+ expect(period.periodName).toBe("Aug 4, 2026");
133
+ });
134
+
135
+ test("a yearly report covers the previous calendar year", () => {
136
+ const period: StatusPageReportPeriod =
137
+ StatusPageReportPeriodUtil.getReportPeriod({
138
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
139
+ reportRecurringInterval: every(EventInterval.Year, 1),
140
+ timezone: Timezone.UTC,
141
+ sentAt: OneUptimeDate.fromString("2026-01-01T00:05:00.000Z"),
142
+ });
143
+
144
+ expect(iso(period.startDate)).toBe("2025-01-01T00:00:00.000Z");
145
+ expect(iso(period.endDate)).toBe("2025-12-31T23:59:59.999Z");
146
+ expect(period.periodName).toBe("2025");
147
+ });
148
+
149
+ test("falls back to a daily period when no frequency is configured", () => {
150
+ const period: StatusPageReportPeriod =
151
+ StatusPageReportPeriodUtil.getReportPeriod({
152
+ periodType: StatusPageReportPeriodType.PreviousCalendarPeriod,
153
+ timezone: Timezone.UTC,
154
+ sentAt: OneUptimeDate.fromString("2026-08-05T09:00:00.000Z"),
155
+ });
156
+
157
+ // Recurring's own default is every 1 day.
158
+ expect(iso(period.startDate)).toBe("2026-08-04T00:00:00.000Z");
159
+ });
160
+ });
161
+
162
+ describe("the rolling window", () => {
163
+ test("ends when the report is sent and runs back the configured days", () => {
164
+ const period: StatusPageReportPeriod =
165
+ StatusPageReportPeriodUtil.getReportPeriod({
166
+ periodType: StatusPageReportPeriodType.Rolling,
167
+ reportDataInDays: 30,
168
+ timezone: Timezone.UTC,
169
+ sentAt: OneUptimeDate.fromString("2026-07-30T09:00:00.000Z"),
170
+ });
171
+
172
+ expect(iso(period.startDate)).toBe("2026-06-30T09:00:00.000Z");
173
+ expect(iso(period.endDate)).toBe("2026-07-30T09:00:00.000Z");
174
+ expect(period.periodName).toBe("the last 30 days");
175
+ });
176
+
177
+ test("keeps the reportDates string custom email templates were written against", () => {
178
+ const period: StatusPageReportPeriod =
179
+ StatusPageReportPeriodUtil.getReportPeriod({
180
+ periodType: StatusPageReportPeriodType.Rolling,
181
+ reportDataInDays: 30,
182
+ timezone: Timezone.UTC,
183
+ sentAt: OneUptimeDate.fromString("2026-07-30T09:00:00.000Z"),
184
+ });
185
+
186
+ expect(period.reportDates).toBe("30 days (Jun 30, 2026 - Jul 30, 2026)");
187
+ });
188
+
189
+ test("is what a status page with no period configured gets", () => {
190
+ // Every column is null on status pages created before this setting existed.
191
+ const period: StatusPageReportPeriod =
192
+ StatusPageReportPeriodUtil.getReportPeriod({
193
+ sentAt: OneUptimeDate.fromString("2026-07-30T09:00:00.000Z"),
194
+ });
195
+
196
+ expect(iso(period.startDate)).toBe("2026-06-30T09:00:00.000Z");
197
+ expect(period.numberOfDays).toBe(
198
+ StatusPageReportPeriodUtil.DEFAULT_REPORT_DATA_IN_DAYS,
199
+ );
200
+ expect(period.timezone).toBe(Timezone.UTC);
201
+ });
202
+
203
+ test("refuses a zero, negative or non-numeric day count", () => {
204
+ for (const badValue of [0, -5, NaN, undefined]) {
205
+ const period: StatusPageReportPeriod =
206
+ StatusPageReportPeriodUtil.getReportPeriod({
207
+ periodType: StatusPageReportPeriodType.Rolling,
208
+ reportDataInDays: badValue as number,
209
+ sentAt: OneUptimeDate.fromString("2026-07-30T09:00:00.000Z"),
210
+ });
211
+
212
+ expect(period.numberOfDays).toBe(
213
+ StatusPageReportPeriodUtil.DEFAULT_REPORT_DATA_IN_DAYS,
214
+ );
215
+ }
216
+ });
217
+ });
218
+ });
@@ -0,0 +1,291 @@
1
+ import {
2
+ MAX_HOST_IP_ADDRESSES_LENGTH,
3
+ MAX_HOST_IP_ADDRESS_COUNT,
4
+ normalizeHostIpAddresses,
5
+ } from "../../../Utils/Telemetry/HostIpAddresses";
6
+ import { describe, expect, test } from "@jest/globals";
7
+
8
+ /*
9
+ * Regression suite for issue #3006. The OTel `host.ip` resource attribute
10
+ * is an array of every address on every interface, and a Docker host with
11
+ * IPv6 enabled reports one entry per bridge, per veth and per link-local.
12
+ * The reporter's host produced 55 addresses / ~1454 characters, which
13
+ * overflowed the then-varchar(500) Host.hostIpAddresses column and aborted
14
+ * the whole Host metadata write.
15
+ */
16
+
17
+ /** Rebuilds the exact shape reported in the issue: 55 addresses. */
18
+ function issueReporterAddresses(): Array<string> {
19
+ const addresses: Array<string> = [];
20
+
21
+ // 1 LAN IPv4.
22
+ addresses.push("192.168.1.42");
23
+
24
+ // 11 Docker bridge IPv4 addresses.
25
+ for (let i: number = 0; i < 11; i++) {
26
+ addresses.push(`172.${17 + i}.0.1`);
27
+ }
28
+
29
+ // 36 IPv6 link-local addresses.
30
+ for (let i: number = 0; i < 36; i++) {
31
+ addresses.push(`fe80::42:acff:fe11:${(0x1000 + i).toString(16)}`);
32
+ }
33
+
34
+ // 7 IPv6 ULA addresses.
35
+ for (let i: number = 0; i < 7; i++) {
36
+ addresses.push(`fd00:dead:beef:${i}::1`);
37
+ }
38
+
39
+ return addresses;
40
+ }
41
+
42
+ describe("normalizeHostIpAddresses", () => {
43
+ describe("empty and absent input", () => {
44
+ test("returns null for undefined", () => {
45
+ expect(normalizeHostIpAddresses(undefined)).toBeNull();
46
+ });
47
+
48
+ test("returns null for null", () => {
49
+ expect(normalizeHostIpAddresses(null)).toBeNull();
50
+ });
51
+
52
+ test("returns null for an empty array", () => {
53
+ expect(normalizeHostIpAddresses([])).toBeNull();
54
+ });
55
+
56
+ test("returns null when every entry is blank", () => {
57
+ expect(normalizeHostIpAddresses(["", " ", "\t", "\n"])).toBeNull();
58
+ });
59
+
60
+ test("returns null rather than an empty string, so callers leave the column untouched", () => {
61
+ // An empty string would overwrite a previously discovered list with "".
62
+ expect(normalizeHostIpAddresses([" "])).not.toBe("");
63
+ });
64
+ });
65
+
66
+ describe("basic joining", () => {
67
+ test("joins with ', ' to match the stored format", () => {
68
+ expect(normalizeHostIpAddresses(["192.168.1.42", "10.0.0.5"])).toBe(
69
+ "192.168.1.42, 10.0.0.5",
70
+ );
71
+ });
72
+
73
+ test("returns a single address unchanged", () => {
74
+ expect(normalizeHostIpAddresses(["10.0.0.5"])).toBe("10.0.0.5");
75
+ });
76
+
77
+ test("trims surrounding whitespace on each entry", () => {
78
+ expect(normalizeHostIpAddresses([" 10.0.0.5 ", "\t::1\n"])).toBe(
79
+ "10.0.0.5, ::1",
80
+ );
81
+ });
82
+
83
+ test("drops blank entries but keeps the rest", () => {
84
+ expect(normalizeHostIpAddresses(["10.0.0.5", "", " ", "::1"])).toBe(
85
+ "10.0.0.5, ::1",
86
+ );
87
+ });
88
+
89
+ test("preserves source order", () => {
90
+ const ips: Array<string> = ["203.0.113.9", "10.0.0.5", "192.168.1.42"];
91
+ expect(normalizeHostIpAddresses(ips)).toBe(
92
+ "203.0.113.9, 10.0.0.5, 192.168.1.42",
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("deduplication", () => {
98
+ test("removes exact duplicates", () => {
99
+ expect(
100
+ normalizeHostIpAddresses(["10.0.0.5", "10.0.0.5", "10.0.0.5"]),
101
+ ).toBe("10.0.0.5");
102
+ });
103
+
104
+ test("removes duplicates that differ only in IPv6 hex casing", () => {
105
+ /*
106
+ * Resource detectors disagree on IPv6 casing, so the same address can
107
+ * arrive twice in one batch in two different cases.
108
+ */
109
+ expect(normalizeHostIpAddresses(["FE80::1", "fe80::1"])).toBe("FE80::1");
110
+ });
111
+
112
+ test("removes duplicates that differ only in surrounding whitespace", () => {
113
+ expect(normalizeHostIpAddresses(["10.0.0.5", " 10.0.0.5 "])).toBe(
114
+ "10.0.0.5",
115
+ );
116
+ });
117
+
118
+ test("keeps the first-seen spelling of a duplicate", () => {
119
+ expect(normalizeHostIpAddresses(["fe80::AB", "FE80::ab"])).toBe(
120
+ "fe80::AB",
121
+ );
122
+ });
123
+
124
+ test("dedupes without disturbing the order of the survivors", () => {
125
+ expect(
126
+ normalizeHostIpAddresses(["a::1", "b::1", "A::1", "c::1", "B::1"]),
127
+ ).toBe("a::1, b::1, c::1");
128
+ });
129
+ });
130
+
131
+ describe("non-string entries", () => {
132
+ test("skips values that are not strings", () => {
133
+ const ips: Array<string> = [
134
+ "10.0.0.5",
135
+ // Defensive: the OTLP payload is untrusted JSON.
136
+ null as unknown as string,
137
+ undefined as unknown as string,
138
+ 42 as unknown as string,
139
+ {} as unknown as string,
140
+ "::1",
141
+ ];
142
+ expect(normalizeHostIpAddresses(ips)).toBe("10.0.0.5, ::1");
143
+ });
144
+
145
+ test("returns null when every entry is a non-string", () => {
146
+ const ips: Array<string> = [
147
+ null as unknown as string,
148
+ 7 as unknown as string,
149
+ ];
150
+ expect(normalizeHostIpAddresses(ips)).toBeNull();
151
+ });
152
+ });
153
+
154
+ describe("the issue #3006 payload", () => {
155
+ const addresses: Array<string> = issueReporterAddresses();
156
+
157
+ test("the fixture reproduces the reported shape", () => {
158
+ expect(addresses).toHaveLength(55);
159
+ expect(addresses.join(", ").length).toBeGreaterThan(1000);
160
+ });
161
+
162
+ test("is stored in full — every address survives", () => {
163
+ const result: string = normalizeHostIpAddresses(addresses) as string;
164
+ expect(result.split(", ")).toHaveLength(55);
165
+ for (const address of addresses) {
166
+ expect(result).toContain(address);
167
+ }
168
+ });
169
+
170
+ test("exceeds the old varchar(500) bound, which is why the column is now text", () => {
171
+ const result: string = normalizeHostIpAddresses(addresses) as string;
172
+ expect(result.length).toBeGreaterThan(500);
173
+ });
174
+ });
175
+
176
+ describe("count cap", () => {
177
+ function manyAddresses(count: number): Array<string> {
178
+ return Array.from({ length: count }, (_unused: unknown, i: number) => {
179
+ return `10.${Math.floor(i / 256)}.${i % 256}.1`;
180
+ });
181
+ }
182
+
183
+ test("keeps everything below the cap", () => {
184
+ const ips: Array<string> = manyAddresses(MAX_HOST_IP_ADDRESS_COUNT - 1);
185
+ const result: string = normalizeHostIpAddresses(ips) as string;
186
+ expect(result.split(", ")).toHaveLength(MAX_HOST_IP_ADDRESS_COUNT - 1);
187
+ });
188
+
189
+ test("keeps exactly the cap when handed exactly the cap", () => {
190
+ const ips: Array<string> = manyAddresses(MAX_HOST_IP_ADDRESS_COUNT);
191
+ const result: string = normalizeHostIpAddresses(ips) as string;
192
+ expect(result.split(", ")).toHaveLength(MAX_HOST_IP_ADDRESS_COUNT);
193
+ });
194
+
195
+ test("caps a pathological collector at the maximum count", () => {
196
+ const ips: Array<string> = manyAddresses(MAX_HOST_IP_ADDRESS_COUNT * 4);
197
+ const result: string = normalizeHostIpAddresses(ips) as string;
198
+ expect(result.split(", ")).toHaveLength(MAX_HOST_IP_ADDRESS_COUNT);
199
+ });
200
+
201
+ test("the addresses kept when capping are the first ones reported", () => {
202
+ const ips: Array<string> = manyAddresses(MAX_HOST_IP_ADDRESS_COUNT + 10);
203
+ const result: string = normalizeHostIpAddresses(ips) as string;
204
+ expect(result.startsWith(ips[0] as string)).toBe(true);
205
+ expect(result).not.toContain(ips[MAX_HOST_IP_ADDRESS_COUNT] as string);
206
+ });
207
+
208
+ test("duplicates do not consume cap slots", () => {
209
+ const ips: Array<string> = [
210
+ ...manyAddresses(10),
211
+ ...manyAddresses(10),
212
+ ...manyAddresses(10),
213
+ ];
214
+ const result: string = normalizeHostIpAddresses(ips) as string;
215
+ expect(result.split(", ")).toHaveLength(10);
216
+ });
217
+ });
218
+
219
+ describe("length cap", () => {
220
+ function longAddresses(count: number): Array<string> {
221
+ // 39 characters each — a fully expanded IPv6 address.
222
+ return Array.from({ length: count }, (_unused: unknown, i: number) => {
223
+ return `2001:0db8:0000:0000:0000:ff00:0042:${(0x1000 + i).toString(16)}`;
224
+ });
225
+ }
226
+
227
+ test("never exceeds the serialized length cap", () => {
228
+ const ips: Array<string> = longAddresses(MAX_HOST_IP_ADDRESS_COUNT);
229
+ const result: string = normalizeHostIpAddresses(ips) as string;
230
+ expect(result.length).toBeLessThanOrEqual(MAX_HOST_IP_ADDRESSES_LENGTH);
231
+ });
232
+
233
+ test("truncates whole addresses only — never a partial one", () => {
234
+ const ips: Array<string> = longAddresses(MAX_HOST_IP_ADDRESS_COUNT);
235
+ const result: string = normalizeHostIpAddresses(ips) as string;
236
+ for (const part of result.split(", ")) {
237
+ expect(ips).toContain(part);
238
+ }
239
+ });
240
+
241
+ test("a single address longer than the whole cap is dropped, not clipped", () => {
242
+ const huge: string = "a".repeat(MAX_HOST_IP_ADDRESSES_LENGTH + 1);
243
+ expect(normalizeHostIpAddresses([huge])).toBeNull();
244
+ });
245
+
246
+ test("a huge first entry does not suppress the entries after it being considered", () => {
247
+ /*
248
+ * The scan stops at the first entry that would overflow, so a huge
249
+ * leading entry costs the rest of the list. That is deliberate — but
250
+ * pin it so the behavior is a decision, not an accident.
251
+ */
252
+ const huge: string = "a".repeat(MAX_HOST_IP_ADDRESSES_LENGTH + 1);
253
+ expect(normalizeHostIpAddresses([huge, "10.0.0.5"])).toBeNull();
254
+ expect(normalizeHostIpAddresses(["10.0.0.5", huge])).toBe("10.0.0.5");
255
+ });
256
+
257
+ test("the separator counts toward the cap", () => {
258
+ // Two entries that only fit if the ", " between them is ignored.
259
+ const half: number = Math.floor(MAX_HOST_IP_ADDRESSES_LENGTH / 2);
260
+ const a: string = "a".repeat(half);
261
+ const b: string = "b".repeat(MAX_HOST_IP_ADDRESSES_LENGTH - half);
262
+ const result: string = normalizeHostIpAddresses([a, b]) as string;
263
+ expect(result).toBe(a);
264
+ expect(result.length).toBeLessThanOrEqual(MAX_HOST_IP_ADDRESSES_LENGTH);
265
+ });
266
+ });
267
+
268
+ describe("caps are sane", () => {
269
+ test("the length cap comfortably clears the reported 55-address payload", () => {
270
+ expect(MAX_HOST_IP_ADDRESSES_LENGTH).toBeGreaterThan(
271
+ issueReporterAddresses().join(", ").length * 2,
272
+ );
273
+ });
274
+
275
+ test("the count cap comfortably clears the reported 55-address payload", () => {
276
+ expect(MAX_HOST_IP_ADDRESS_COUNT).toBeGreaterThan(55 * 2);
277
+ });
278
+ });
279
+
280
+ describe("idempotence", () => {
281
+ test("re-normalizing an already-normalized list is a no-op", () => {
282
+ const once: string = normalizeHostIpAddresses(
283
+ issueReporterAddresses(),
284
+ ) as string;
285
+ const twice: string = normalizeHostIpAddresses(
286
+ once.split(", "),
287
+ ) as string;
288
+ expect(twice).toBe(once);
289
+ });
290
+ });
291
+ });
@@ -0,0 +1,24 @@
1
+ import CustomFieldType from "./CustomFieldType";
2
+
3
+ /*
4
+ * One custom field, as the rest of the product needs to read it: the shape
5
+ * that comes back from a `*CustomField` definition table, narrowed to the four
6
+ * properties anything rendering or filtering by it actually uses.
7
+ *
8
+ * Lives in Types rather than beside the table columns that first needed it
9
+ * because the facet bar builds its chips from the same definitions, and that
10
+ * code is compiled by App's `tsc`, which has no React in it — importing the
11
+ * type from a .tsx would pull React into a program that cannot resolve it.
12
+ * `CustomFieldColumns` re-exports it, so existing imports keep working.
13
+ */
14
+ export interface CustomFieldDefinition {
15
+ name: string;
16
+ description?: string | undefined;
17
+ customFieldType?: CustomFieldType | undefined;
18
+ /**
19
+ * Serialized options for a Dropdown / MultiSelectDropdown field. Parse with
20
+ * `parseCustomFieldDropdownOptions` — the value is either the legacy
21
+ * one-per-line format or a JSON array of `{ value, color }`.
22
+ */
23
+ dropdownOptions?: string | undefined;
24
+ }
package/Types/Date.ts CHANGED
@@ -9,6 +9,12 @@ import Zod, { ZodSchema } from "../Utils/Schema/Zod";
9
9
 
10
10
  export const Moment: typeof moment = moment;
11
11
 
12
+ /*
13
+ * The units a calendar (rather than a fixed-length) interval can be measured in.
14
+ * See getStartOfCalendarUnit / addRemoveCalendarUnits below.
15
+ */
16
+ export type CalendarUnit = "hour" | "day" | "week" | "month" | "year";
17
+
12
18
  export default class OneUptimeDate {
13
19
  // get date time from unix timestamp
14
20
 
@@ -363,6 +369,27 @@ export default class OneUptimeDate {
363
369
  ).trim();
364
370
  }
365
371
 
372
+ /*
373
+ * Format an instant with an explicit moment format string, resolved in an
374
+ * IANA timezone. The formatted-string helpers above each bake in a layout;
375
+ * this one is for callers that need a specific shape ("MMMM YYYY" for the
376
+ * month a status page report covers) and know exactly which zone it must be
377
+ * read in. Falls back to the viewer's / process's zone without one.
378
+ */
379
+ public static getDateAsCustomFormattedStringInTimezone(data: {
380
+ date: Date | string;
381
+ format: string;
382
+ timezone?: string | undefined;
383
+ }): string {
384
+ const date: Date = this.fromString(data.date);
385
+
386
+ if (!data.timezone) {
387
+ return this.inCurrentTimezone(date).format(data.format);
388
+ }
389
+
390
+ return moment(date).tz(data.timezone).format(data.format);
391
+ }
392
+
366
393
  /**
367
394
  * Reinterpret the wall-clock components of `date` — read in the CURRENT
368
395
  * timezone, i.e. the one the pickers render in — as the SAME wall-clock in
@@ -1213,6 +1240,91 @@ export default class OneUptimeDate {
1213
1240
  return moment(date).endOf("day").toDate();
1214
1241
  }
1215
1242
 
1243
+ /*
1244
+ * Calendar arithmetic - "the previous calendar month", "add 3 calendar
1245
+ * months" - as opposed to the fixed-length arithmetic the rest of this class
1246
+ * does. A calendar month is 28-31 days long and a calendar day is 23-25 hours
1247
+ * long across a DST transition, so anything that has to line up with a wall
1248
+ * clock (a status page report that must read "Jul 1 - Jul 31", a schedule that
1249
+ * must keep firing at 09:00 local) cannot be expressed by adding milliseconds.
1250
+ *
1251
+ * Every helper below takes an optional IANA timezone. Without one the
1252
+ * boundaries land in the process's local zone, which on a server is UTC.
1253
+ */
1254
+ private static toMomentCalendarUnit(
1255
+ unit: CalendarUnit,
1256
+ forStartOf: boolean,
1257
+ ): moment.unitOfTime.StartOf & moment.unitOfTime.DurationConstructor {
1258
+ /*
1259
+ * moment's "week" starts on the locale's first day, which differs between
1260
+ * environments and would make a weekly report cover Sun-Sat on one host and
1261
+ * Mon-Sun on another. ISO weeks are always Monday-Sunday, so the boundary is
1262
+ * the same everywhere. Durations have no such ambiguity - a week is 7 days -
1263
+ * and moment has no "isoWeek" duration, so only startOf is remapped.
1264
+ */
1265
+ if (unit === "week" && forStartOf) {
1266
+ return "isoWeek" as moment.unitOfTime.StartOf &
1267
+ moment.unitOfTime.DurationConstructor;
1268
+ }
1269
+
1270
+ return unit as moment.unitOfTime.StartOf &
1271
+ moment.unitOfTime.DurationConstructor;
1272
+ }
1273
+
1274
+ private static inTimezone(
1275
+ date: Date,
1276
+ timezone?: string | undefined,
1277
+ ): moment.Moment {
1278
+ return timezone ? moment.tz(date, timezone) : moment(date);
1279
+ }
1280
+
1281
+ /*
1282
+ * The instant the calendar unit containing `date` began - e.g. the first
1283
+ * millisecond of that month, in `timezone`.
1284
+ */
1285
+ public static getStartOfCalendarUnit(
1286
+ date: Date,
1287
+ unit: CalendarUnit,
1288
+ timezone?: string | undefined,
1289
+ ): Date {
1290
+ return this.inTimezone(this.fromString(date), timezone)
1291
+ .startOf(this.toMomentCalendarUnit(unit, true))
1292
+ .toDate();
1293
+ }
1294
+
1295
+ /*
1296
+ * Move `date` by whole calendar units, keeping the wall clock in `timezone`.
1297
+ * Adding one month to Jan 31 lands on Feb 28/29 (moment clamps), and adding a
1298
+ * day across a DST boundary still lands at the same local time.
1299
+ */
1300
+ public static addRemoveCalendarUnits(
1301
+ date: Date,
1302
+ unit: CalendarUnit,
1303
+ count: number,
1304
+ timezone?: string | undefined,
1305
+ ): Date {
1306
+ return this.inTimezone(this.fromString(date), timezone)
1307
+ .add(count, this.toMomentCalendarUnit(unit, false))
1308
+ .toDate();
1309
+ }
1310
+
1311
+ /*
1312
+ * How many whole calendar units fit between the two instants, measured in
1313
+ * `timezone`. Used to jump a recurrence forward to "now" in one step instead
1314
+ * of looping one interval at a time.
1315
+ */
1316
+ public static getCalendarUnitsBetween(
1317
+ startDate: Date,
1318
+ endDate: Date,
1319
+ unit: CalendarUnit,
1320
+ timezone?: string | undefined,
1321
+ ): number {
1322
+ return this.inTimezone(this.fromString(endDate), timezone).diff(
1323
+ this.inTimezone(this.fromString(startDate), timezone),
1324
+ this.toMomentCalendarUnit(unit, false),
1325
+ );
1326
+ }
1327
+
1216
1328
  public static isBetween(date: Date, startDate: Date, endDate: Date): boolean {
1217
1329
  date = this.fromString(date);
1218
1330
  startDate = this.fromString(startDate);