@oneuptime/common 11.6.2 → 11.7.1

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 (64) hide show
  1. package/Models/AnalyticsModels/SloHistory.ts +6 -3
  2. package/Models/DatabaseModels/ServiceLevelObjective.ts +9 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.ts +30 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/ServiceLevelObjectiveService.ts +12 -4
  6. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +2 -2
  7. package/Tests/Types/DateUserTimezone.test.ts +205 -0
  8. package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
  9. package/Tests/Types/Monitor/MonitorCriteria.test.ts +155 -0
  10. package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +465 -0
  11. package/Tests/Types/Monitor/MonitorStep.test.ts +183 -0
  12. package/Tests/Types/Monitor/MonitorSteps.test.ts +183 -0
  13. package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
  14. package/Tests/Utils/Memory.test.ts +44 -0
  15. package/Tests/Utils/Slo/SloBurnRateRuleState.test.ts +124 -0
  16. package/Tests/Utils/Slo/SloDuration.test.ts +148 -0
  17. package/Tests/Utils/Slo/SloHealth.test.ts +334 -0
  18. package/Types/Date.ts +119 -50
  19. package/UI/Components/Forms/Fields/FormField.tsx +13 -3
  20. package/UI/Components/Input/Input.tsx +6 -1
  21. package/UI/Components/TimePicker/TimePicker.tsx +8 -4
  22. package/UI/Utils/User.ts +19 -0
  23. package/Utils/Slo/SloBurnRateRuleState.ts +91 -0
  24. package/Utils/Slo/SloDuration.ts +165 -0
  25. package/Utils/Slo/SloEvaluation.ts +22 -0
  26. package/Utils/Slo/SloHealth.ts +302 -0
  27. package/build/dist/Models/AnalyticsModels/SloHistory.js +6 -3
  28. package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -1
  29. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +11 -1
  30. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js +21 -0
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js.map +1 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  35. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +10 -4
  36. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  37. package/build/dist/Types/Date.js +106 -35
  38. package/build/dist/Types/Date.js.map +1 -1
  39. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
  40. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  41. package/build/dist/UI/Components/Input/Input.js +6 -1
  42. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  43. package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
  44. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  45. package/build/dist/UI/Utils/User.js +19 -0
  46. package/build/dist/UI/Utils/User.js.map +1 -1
  47. package/build/dist/Utils/Slo/SloBurnRateRuleState.js +35 -0
  48. package/build/dist/Utils/Slo/SloBurnRateRuleState.js.map +1 -0
  49. package/build/dist/Utils/Slo/SloDuration.js +87 -0
  50. package/build/dist/Utils/Slo/SloDuration.js.map +1 -0
  51. package/build/dist/Utils/Slo/SloEvaluation.js +21 -0
  52. package/build/dist/Utils/Slo/SloEvaluation.js.map +1 -0
  53. package/build/dist/Utils/Slo/SloHealth.js +179 -0
  54. package/build/dist/Utils/Slo/SloHealth.js.map +1 -0
  55. package/package.json +1 -1
  56. package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
  57. package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
  58. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
  61. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
  62. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
  63. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
  64. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
@@ -0,0 +1,334 @@
1
+ import SliType from "../../../Types/ServiceLevelObjective/SliType";
2
+ import SloMultiMonitorMode from "../../../Types/ServiceLevelObjective/SloMultiMonitorMode";
3
+ import SloStatus from "../../../Types/ServiceLevelObjective/SloStatus";
4
+ import SloWindowType from "../../../Types/ServiceLevelObjective/SloWindowType";
5
+ import {
6
+ DEFAULT_AT_RISK_THRESHOLD_PERCENTAGE,
7
+ getSloBudgetTier,
8
+ getSloNotice,
9
+ isRollingWindowNotYetFull,
10
+ SloBudgetTier,
11
+ SloNotice,
12
+ SloNoticeType,
13
+ } from "../../../Utils/Slo/SloHealth";
14
+
15
+ const SECONDS_PER_DAY: number = 24 * 60 * 60;
16
+
17
+ /** The full 30-day error budget of a 99.9% SLO: 43m 12s. */
18
+ const THIRTY_DAY_999_BUDGET_SECONDS: number = 0.001 * 30 * SECONDS_PER_DAY;
19
+
20
+ describe("SloHealth", () => {
21
+ describe("getSloBudgetTier", () => {
22
+ it("returns Unknown when the SLO has not been evaluated", () => {
23
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: null })).toBe(
24
+ SloBudgetTier.Unknown,
25
+ );
26
+ expect(
27
+ getSloBudgetTier({ errorBudgetRemainingPercentage: undefined }),
28
+ ).toBe(SloBudgetTier.Unknown);
29
+ });
30
+
31
+ it("returns Unknown for non-finite values", () => {
32
+ expect(
33
+ getSloBudgetTier({ errorBudgetRemainingPercentage: Number.NaN }),
34
+ ).toBe(SloBudgetTier.Unknown);
35
+ });
36
+
37
+ it("returns Healthy well above the threshold", () => {
38
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 68 })).toBe(
39
+ SloBudgetTier.Healthy,
40
+ );
41
+ });
42
+
43
+ it("returns Exhausted at exactly zero", () => {
44
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 0 })).toBe(
45
+ SloBudgetTier.Exhausted,
46
+ );
47
+ });
48
+
49
+ it("returns Exhausted when overspent", () => {
50
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: -37 })).toBe(
51
+ SloBudgetTier.Exhausted,
52
+ );
53
+ });
54
+
55
+ it("returns AtRisk at exactly the threshold", () => {
56
+ expect(
57
+ getSloBudgetTier({
58
+ errorBudgetRemainingPercentage: DEFAULT_AT_RISK_THRESHOLD_PERCENTAGE,
59
+ }),
60
+ ).toBe(SloBudgetTier.AtRisk);
61
+ });
62
+
63
+ it("falls back to the 20% default when no threshold is given", () => {
64
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 19.9 })).toBe(
65
+ SloBudgetTier.AtRisk,
66
+ );
67
+ expect(getSloBudgetTier({ errorBudgetRemainingPercentage: 20.1 })).toBe(
68
+ SloBudgetTier.Healthy,
69
+ );
70
+ });
71
+
72
+ it("honours a custom at-risk threshold instead of the hardcoded 20", () => {
73
+ /*
74
+ * The regression this function exists for: at a 50% threshold the
75
+ * old `remaining <= 20` check rendered 35% green while the worker
76
+ * had already moved the SLO to At Risk.
77
+ */
78
+ expect(
79
+ getSloBudgetTier({
80
+ errorBudgetRemainingPercentage: 35,
81
+ atRiskThresholdPercentage: 50,
82
+ }),
83
+ ).toBe(SloBudgetTier.AtRisk);
84
+
85
+ expect(
86
+ getSloBudgetTier({
87
+ errorBudgetRemainingPercentage: 15,
88
+ atRiskThresholdPercentage: 5,
89
+ }),
90
+ ).toBe(SloBudgetTier.Healthy);
91
+ });
92
+
93
+ it("treats a zero threshold as 'at risk only once exhausted'", () => {
94
+ expect(
95
+ getSloBudgetTier({
96
+ errorBudgetRemainingPercentage: 0.5,
97
+ atRiskThresholdPercentage: 0,
98
+ }),
99
+ ).toBe(SloBudgetTier.Healthy);
100
+ });
101
+ });
102
+
103
+ describe("getSloNotice", () => {
104
+ it("returns null for a normally measuring SLO", () => {
105
+ expect(
106
+ getSloNotice({
107
+ isEnabled: true,
108
+ sloStatus: SloStatus.Healthy,
109
+ sliType: SliType.MonitorUptime,
110
+ monitorCount: 2,
111
+ targetPercentage: 99.9,
112
+ lastEvaluatedAt: new Date(),
113
+ }),
114
+ ).toBeNull();
115
+ });
116
+
117
+ it("reports a disabled SLO ahead of any other reason", () => {
118
+ /*
119
+ * A disabled SLO is never evaluated, so its stale Misconfigured
120
+ * status must not send the user off to attach monitors.
121
+ */
122
+ const notice: SloNotice | null = getSloNotice({
123
+ isEnabled: false,
124
+ sloStatus: SloStatus.Misconfigured,
125
+ monitorCount: 0,
126
+ });
127
+
128
+ expect(notice?.type).toBe(SloNoticeType.Info);
129
+ expect(notice?.title).toBe("This SLO is disabled");
130
+ });
131
+
132
+ it("names 'no monitors' as the reason for Misconfigured", () => {
133
+ const notice: SloNotice | null = getSloNotice({
134
+ isEnabled: true,
135
+ sloStatus: SloStatus.Misconfigured,
136
+ sliType: SliType.MonitorUptime,
137
+ monitorCount: 0,
138
+ targetPercentage: 99.9,
139
+ lastEvaluatedAt: new Date(),
140
+ });
141
+
142
+ expect(notice?.type).toBe(SloNoticeType.Warning);
143
+ expect(notice?.title).toBe("No monitors attached");
144
+ });
145
+
146
+ it("names an out-of-range target as the reason for Misconfigured", () => {
147
+ const notice: SloNotice | null = getSloNotice({
148
+ isEnabled: true,
149
+ sloStatus: SloStatus.Misconfigured,
150
+ sliType: SliType.MonitorUptime,
151
+ monitorCount: 1,
152
+ targetPercentage: 100,
153
+ lastEvaluatedAt: new Date(),
154
+ });
155
+
156
+ expect(notice?.title).toBe("The target is out of range");
157
+ });
158
+
159
+ it("names an unsupported SLI type ahead of the monitor count", () => {
160
+ const notice: SloNotice | null = getSloNotice({
161
+ isEnabled: true,
162
+ sloStatus: SloStatus.Misconfigured,
163
+ sliType: SliType.Metric,
164
+ monitorCount: 0,
165
+ targetPercentage: 99.9,
166
+ lastEvaluatedAt: new Date(),
167
+ });
168
+
169
+ expect(notice?.body).toContain(SliType.MonitorUptime);
170
+ });
171
+
172
+ it("falls back to a generic reason for Misconfigured", () => {
173
+ const notice: SloNotice | null = getSloNotice({
174
+ isEnabled: true,
175
+ sloStatus: SloStatus.Misconfigured,
176
+ sliType: SliType.MonitorUptime,
177
+ monitorCount: 1,
178
+ targetPercentage: 99.9,
179
+ lastEvaluatedAt: new Date(),
180
+ });
181
+
182
+ expect(notice?.title).toBe("This SLO cannot be evaluated");
183
+ });
184
+
185
+ it("explains Paused", () => {
186
+ const notice: SloNotice | null = getSloNotice({
187
+ isEnabled: true,
188
+ sloStatus: SloStatus.Paused,
189
+ sliType: SliType.MonitorUptime,
190
+ monitorCount: 2,
191
+ targetPercentage: 99.9,
192
+ lastEvaluatedAt: new Date(),
193
+ });
194
+
195
+ expect(notice?.type).toBe(SloNoticeType.Info);
196
+ expect(notice?.title).toBe("Measurement is paused");
197
+ });
198
+
199
+ it("explains a never-evaluated SLO", () => {
200
+ const notice: SloNotice | null = getSloNotice({
201
+ isEnabled: true,
202
+ sloStatus: SloStatus.Healthy,
203
+ sliType: SliType.MonitorUptime,
204
+ monitorCount: 1,
205
+ targetPercentage: 99.9,
206
+ lastEvaluatedAt: null,
207
+ });
208
+
209
+ expect(notice?.title).toBe("Not evaluated yet");
210
+ });
211
+
212
+ it("says nothing about a healthy SLO whose isEnabled was not selected", () => {
213
+ // `undefined` means "not loaded", which is not the same as `false`.
214
+ expect(
215
+ getSloNotice({
216
+ sloStatus: SloStatus.Healthy,
217
+ sliType: SliType.MonitorUptime,
218
+ monitorCount: 1,
219
+ targetPercentage: 99.9,
220
+ lastEvaluatedAt: new Date(),
221
+ }),
222
+ ).toBeNull();
223
+ });
224
+ });
225
+
226
+ describe("isRollingWindowNotYetFull", () => {
227
+ it("is false for a mature rolling window", () => {
228
+ expect(
229
+ isRollingWindowNotYetFull({
230
+ windowType: SloWindowType.Rolling,
231
+ windowDays: 30,
232
+ targetPercentage: 99.9,
233
+ errorBudgetTotalSeconds: THIRTY_DAY_999_BUDGET_SECONDS,
234
+ }),
235
+ ).toBe(false);
236
+ });
237
+
238
+ it("is true for a 30-day SLO carrying only a week of data", () => {
239
+ expect(
240
+ isRollingWindowNotYetFull({
241
+ windowType: SloWindowType.Rolling,
242
+ windowDays: 30,
243
+ targetPercentage: 99.9,
244
+ errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
245
+ }),
246
+ ).toBe(true);
247
+ });
248
+
249
+ it("stays silent for Monitor Seconds Average — its budget scales with the monitor count", () => {
250
+ /*
251
+ * A five-monitor SLO with a week of data carries 35 monitor-days of
252
+ * budget, which would read as mature against a 30-day yardstick. The
253
+ * helper declines to guess rather than answer wrongly in either
254
+ * direction.
255
+ */
256
+ expect(
257
+ isRollingWindowNotYetFull({
258
+ windowType: SloWindowType.Rolling,
259
+ windowDays: 30,
260
+ targetPercentage: 99.9,
261
+ errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
262
+ multiMonitorMode: SloMultiMonitorMode.MonitorSecondsAverage,
263
+ }),
264
+ ).toBe(false);
265
+ });
266
+
267
+ it("still answers for the default Any Monitor Down mode", () => {
268
+ expect(
269
+ isRollingWindowNotYetFull({
270
+ windowType: SloWindowType.Rolling,
271
+ windowDays: 30,
272
+ targetPercentage: 99.9,
273
+ errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
274
+ multiMonitorMode: SloMultiMonitorMode.AnyDown,
275
+ }),
276
+ ).toBe(true);
277
+ });
278
+
279
+ it("tolerates the small shortfall between now and the last evaluation", () => {
280
+ // 0.5% short — a mature SLO must not flicker the banner.
281
+ expect(
282
+ isRollingWindowNotYetFull({
283
+ windowType: SloWindowType.Rolling,
284
+ windowDays: 30,
285
+ targetPercentage: 99.9,
286
+ errorBudgetTotalSeconds: THIRTY_DAY_999_BUDGET_SECONDS * 0.995,
287
+ }),
288
+ ).toBe(false);
289
+ });
290
+
291
+ it("is false for calendar-month windows — their budget is never prorated", () => {
292
+ expect(
293
+ isRollingWindowNotYetFull({
294
+ windowType: SloWindowType.CalendarMonth,
295
+ windowDays: 30,
296
+ targetPercentage: 99.9,
297
+ errorBudgetTotalSeconds: 1,
298
+ }),
299
+ ).toBe(false);
300
+ });
301
+
302
+ it("is false when the SLO has not been evaluated", () => {
303
+ expect(
304
+ isRollingWindowNotYetFull({
305
+ windowType: SloWindowType.Rolling,
306
+ windowDays: 30,
307
+ targetPercentage: 99.9,
308
+ errorBudgetTotalSeconds: null,
309
+ }),
310
+ ).toBe(false);
311
+ });
312
+
313
+ it("is false when the target would make the full-window budget meaningless", () => {
314
+ expect(
315
+ isRollingWindowNotYetFull({
316
+ windowType: SloWindowType.Rolling,
317
+ windowDays: 30,
318
+ targetPercentage: 100,
319
+ errorBudgetTotalSeconds: 60,
320
+ }),
321
+ ).toBe(false);
322
+ });
323
+
324
+ it("defaults windowDays to 30 when it is not set", () => {
325
+ expect(
326
+ isRollingWindowNotYetFull({
327
+ windowType: SloWindowType.Rolling,
328
+ targetPercentage: 99.9,
329
+ errorBudgetTotalSeconds: 0.001 * 7 * SECONDS_PER_DAY,
330
+ }),
331
+ ).toBe(true);
332
+ });
333
+ });
334
+ });
package/Types/Date.ts CHANGED
@@ -12,6 +12,13 @@ export const Moment: typeof moment = moment;
12
12
  export default class OneUptimeDate {
13
13
  // get date time from unix timestamp
14
14
 
15
+ /*
16
+ * The timezone the signed-in user configured in User Settings, pushed in by
17
+ * the UI (see UserUtil.initializeUserTimezone). Null on the server and on
18
+ * signed-out pages, where the process / browser zone is used instead.
19
+ */
20
+ private static userTimezone: Timezone | null = null;
21
+
15
22
  private static padDatePart(value: number): string {
16
23
  return value.toString().padStart(2, "0");
17
24
  }
@@ -44,6 +51,7 @@ export default class OneUptimeDate {
44
51
  private static getLocalShortMonthName(date: Date): string {
45
52
  return date.toLocaleString("default", {
46
53
  month: "short",
54
+ timeZone: this.getCurrentTimezone().toString(),
47
55
  });
48
56
  }
49
57
 
@@ -156,6 +164,19 @@ export default class OneUptimeDate {
156
164
  return this.getLocalTimeString(date);
157
165
  }
158
166
 
167
+ /**
168
+ * The hour / minute `date` reads at in the current timezone. Time pickers use
169
+ * these instead of Date.getHours()/getMinutes() so the digits they show are
170
+ * the ones the user expects in the zone they configured.
171
+ */
172
+ public static getLocalHours(date: Date | string): number {
173
+ return this.inCurrentTimezone(date).hours();
174
+ }
175
+
176
+ public static getLocalMinutes(date: Date | string): number {
177
+ return this.inCurrentTimezone(date).minutes();
178
+ }
179
+
159
180
  public static getLocalShortMonthNameFromDate(date: Date | string): string {
160
181
  date = this.fromString(date);
161
182
  return this.getLocalShortMonthName(date);
@@ -172,26 +193,27 @@ export default class OneUptimeDate {
172
193
 
173
194
  const includeMinutes: boolean = options?.includeMinutes ?? true;
174
195
  const includeSeconds: boolean = options?.includeSeconds ?? false;
175
- const hours: string = this.padDatePart(date.getHours());
196
+ const localDate: moment.Moment = this.inCurrentTimezone(date);
197
+ const hours: string = this.padDatePart(localDate.hours());
176
198
 
177
199
  if (!includeMinutes) {
178
200
  return hours;
179
201
  }
180
202
 
181
- const minutes: string = this.padDatePart(date.getMinutes());
203
+ const minutes: string = this.padDatePart(localDate.minutes());
182
204
 
183
205
  if (!includeSeconds) {
184
206
  return `${hours}:${minutes}`;
185
207
  }
186
208
 
187
- const seconds: string = this.padDatePart(date.getSeconds());
209
+ const seconds: string = this.padDatePart(localDate.seconds());
188
210
  return `${hours}:${minutes}:${seconds}`;
189
211
  }
190
212
 
191
213
  public static getDateAsLocalDayMonthString(date: Date | string): string {
192
214
  date = this.fromString(date);
193
215
 
194
- const day: string = this.padDatePart(date.getDate());
216
+ const day: string = this.padDatePart(this.inCurrentTimezone(date).date());
195
217
  const month: string = this.getLocalShortMonthName(date);
196
218
 
197
219
  return `${day} ${month}`;
@@ -207,7 +229,7 @@ export default class OneUptimeDate {
207
229
  date = this.fromString(date);
208
230
 
209
231
  const month: string = this.getLocalShortMonthName(date);
210
- const year: string = date.getFullYear().toString();
232
+ const year: string = this.inCurrentTimezone(date).year().toString();
211
233
 
212
234
  return `${month} ${year}`;
213
235
  }
@@ -316,7 +338,7 @@ export default class OneUptimeDate {
316
338
  : `${datePart}, ${timePart}`;
317
339
 
318
340
  if (!data.timezone) {
319
- const local: moment.Moment = moment(date).local();
341
+ const local: moment.Moment = this.inCurrentTimezone(date);
320
342
  return (
321
343
  local.format(formatString) +
322
344
  (data.onlyShowDate ? "" : " " + this.getCurrentTimezoneString())
@@ -333,18 +355,18 @@ export default class OneUptimeDate {
333
355
  }
334
356
 
335
357
  /**
336
- * Reinterpret the wall-clock components of `date` — read in the process /
337
- * browser local zone — as the SAME wall-clock in `timezone`, and return that
338
- * instant. Used to store a time the user typed for the schedule's timezone
339
- * even though the time picker captured it in the browser's local zone (audit
340
- * F1). Inverse of getLocalDateFromWallClockInTimezone.
358
+ * Reinterpret the wall-clock components of `date` — read in the CURRENT
359
+ * timezone, i.e. the one the pickers render in — as the SAME wall-clock in
360
+ * `timezone`, and return that instant. Used to store a time the user typed
361
+ * for the schedule's timezone even though the time picker captured it in the
362
+ * viewer's own zone (audit F1). Inverse of
363
+ * getLocalDateFromWallClockInTimezone.
341
364
  */
342
365
  public static getInstantFromLocalWallClockInTimezone(
343
366
  date: Date | string,
344
367
  timezone: string,
345
368
  ): Date {
346
- date = this.fromString(date);
347
- const local: moment.Moment = moment(date);
369
+ const local: moment.Moment = this.inCurrentTimezone(date);
348
370
  return moment
349
371
  .tz(
350
372
  {
@@ -361,10 +383,10 @@ export default class OneUptimeDate {
361
383
  }
362
384
 
363
385
  /**
364
- * Inverse of getInstantFromLocalWallClockInTimezone: return a process /
365
- * browser local Date whose LOCAL wall-clock equals `date`'s wall-clock as seen
366
- * in `timezone`. Used to display a stored schedule-timezone time inside a
367
- * browser-local time picker (audit F1).
386
+ * Inverse of getInstantFromLocalWallClockInTimezone: return a Date whose
387
+ * wall-clock IN THE CURRENT TIMEZONE equals `date`'s wall-clock as seen in
388
+ * `timezone`. Used to display a stored schedule-timezone time inside a picker
389
+ * that renders in the viewer's own zone (audit F1).
368
390
  */
369
391
  public static getLocalDateFromWallClockInTimezone(
370
392
  date: Date | string,
@@ -372,14 +394,19 @@ export default class OneUptimeDate {
372
394
  ): Date {
373
395
  date = this.fromString(date);
374
396
  const zoned: moment.Moment = moment.tz(date, timezone);
375
- return moment({
376
- year: zoned.year(),
377
- month: zoned.month(),
378
- day: zoned.date(),
379
- hour: zoned.hour(),
380
- minute: zoned.minute(),
381
- second: zoned.second(),
382
- }).toDate();
397
+ return moment
398
+ .tz(
399
+ {
400
+ year: zoned.year(),
401
+ month: zoned.month(),
402
+ day: zoned.date(),
403
+ hour: zoned.hour(),
404
+ minute: zoned.minute(),
405
+ second: zoned.second(),
406
+ },
407
+ this.getCurrentTimezone().toString(),
408
+ )
409
+ .toDate();
383
410
  }
384
411
 
385
412
  public static isOverlapping(
@@ -709,22 +736,26 @@ export default class OneUptimeDate {
709
736
  return this.addRemoveMinutes(date, date.getTimezoneOffset());
710
737
  }
711
738
 
739
+ /**
740
+ * Render an instant as the value of an `<input type="datetime-local">` — a
741
+ * bare wall-clock with no offset — read in the current timezone.
742
+ */
712
743
  public static toDateTimeLocalString(date: Date): string {
713
- date = this.fromString(date);
714
-
715
- type TenFunction = (i: number) => string;
716
-
717
- const ten: TenFunction = (i: number): string => {
718
- return (i < 10 ? "0" : "") + i;
719
- },
720
- YYYY: number = date.getFullYear(),
721
- MM: string = ten(date.getMonth() + 1),
722
- DD: string = ten(date.getDate()),
723
- HH: string = ten(date.getHours()),
724
- II: string = ten(date.getMinutes()),
725
- SS: string = ten(date.getSeconds());
744
+ return this.inCurrentTimezone(date).format("YYYY-MM-DDTHH:mm:ss");
745
+ }
726
746
 
727
- return YYYY + "-" + MM + "-" + DD + "T" + HH + ":" + II + ":" + SS;
747
+ /**
748
+ * Inverse of toDateTimeLocalString: turn the offset-less wall-clock a user
749
+ * typed into a `<input type="date">` / `<input type="datetime-local">` into
750
+ * the instant it names in the current timezone. Without this the browser
751
+ * zone is used to resolve the wall-clock, so a user whose machine reports a
752
+ * different zone than the one they configured would have their times stored
753
+ * hours off.
754
+ */
755
+ public static fromDateTimeLocalString(value: string): Date {
756
+ return moment
757
+ .tz(value.trim(), this.getCurrentTimezone().toString())
758
+ .toDate();
728
759
  }
729
760
 
730
761
  public static fromJSON(json: JSONObject): Date {
@@ -1713,7 +1744,7 @@ export default class OneUptimeDate {
1713
1744
  formatstring = "MMM DD, YYYY";
1714
1745
  }
1715
1746
 
1716
- const momentDate: moment.Moment = moment(date).local();
1747
+ const momentDate: moment.Moment = this.inCurrentTimezone(date);
1717
1748
 
1718
1749
  return (
1719
1750
  momentDate.format(formatstring) +
@@ -1752,8 +1783,41 @@ export default class OneUptimeDate {
1752
1783
  return zoneAbbr;
1753
1784
  }
1754
1785
 
1786
+ /**
1787
+ * Set the timezone the signed-in user picked in User Settings. Once set,
1788
+ * every "local" helper below resolves wall-clock times in THIS zone instead
1789
+ * of the zone the browser reports, so what the user reads and types matches
1790
+ * the timezone they configured — even when the machine, OS or VPN they are
1791
+ * on reports a different one. Pass null to fall back to the browser zone
1792
+ * (signed-out surfaces, or a user who has not picked a timezone).
1793
+ */
1794
+ public static setUserTimezone(timezone: Timezone | null | undefined): void {
1795
+ /*
1796
+ * Ignore anything moment does not recognise as an IANA zone. A stale or
1797
+ * corrupt saved value must not take every date in the UI down with it.
1798
+ */
1799
+ if (!timezone || !moment.tz.zone(timezone.toString())) {
1800
+ this.userTimezone = null;
1801
+ return;
1802
+ }
1803
+
1804
+ this.userTimezone = timezone;
1805
+ }
1806
+
1807
+ public static getUserTimezone(): Timezone | null {
1808
+ return this.userTimezone;
1809
+ }
1810
+
1755
1811
  public static getCurrentTimezone(): Timezone {
1756
- return moment.tz.guess() as Timezone;
1812
+ return this.userTimezone || (moment.tz.guess() as Timezone);
1813
+ }
1814
+
1815
+ /**
1816
+ * The instant `date` viewed through the current timezone — the single place
1817
+ * that decides which wall clock the "local" helpers below read from.
1818
+ */
1819
+ private static inCurrentTimezone(date: Date | string): moment.Moment {
1820
+ return moment(this.fromString(date)).tz(this.getCurrentTimezone());
1757
1821
  }
1758
1822
 
1759
1823
  public static getDateString(date: Date): string {
@@ -1810,9 +1874,8 @@ export default class OneUptimeDate {
1810
1874
  }
1811
1875
 
1812
1876
  public static asDateForDatabaseQuery(date: string | Date): string {
1813
- date = this.fromString(date);
1814
1877
  const formatstring: string = "YYYY-MM-DD";
1815
- return moment(date).local().format(formatstring);
1878
+ return this.inCurrentTimezone(date).format(formatstring);
1816
1879
  }
1817
1880
 
1818
1881
  public static asFilterDateForDatabaseQuery(
@@ -1850,13 +1913,19 @@ export default class OneUptimeDate {
1850
1913
  throw new BadDataException("Invalid seconds");
1851
1914
  }
1852
1915
 
1853
- const date: Date = OneUptimeDate.getCurrentDate();
1854
-
1855
- date.setHours(hour);
1856
- date.setMinutes(minutes);
1857
- date.setSeconds(seconds);
1858
-
1859
- return date;
1916
+ /*
1917
+ * The hour/minute the user picked is a wall-clock reading in THEIR
1918
+ * timezone, so anchor it to today's date in that zone rather than setting
1919
+ * it on a browser-local Date.
1920
+ */
1921
+ return this.inCurrentTimezone(OneUptimeDate.getCurrentDate())
1922
+ .set({
1923
+ hour: hour,
1924
+ minute: minutes,
1925
+ second: seconds,
1926
+ millisecond: 0,
1927
+ })
1928
+ .toDate();
1860
1929
  }
1861
1930
 
1862
1931
  public static toDatabaseDate(date: Date): string {
@@ -291,9 +291,19 @@ const FormField: <T extends GenericObject>(
291
291
  fieldDescription = "";
292
292
  }
293
293
 
294
- fieldDescription +=
295
- " This is in your local timezone - " +
296
- OneUptimeDate.getCurrentTimezoneString();
294
+ /*
295
+ * Name the zone in full. The abbreviation on its own ("EDT") is not
296
+ * enough to tell whether the field is following the timezone picked in
297
+ * User Settings or the one the browser reports.
298
+ */
299
+ fieldDescription = (
300
+ fieldDescription +
301
+ " This is in your timezone - " +
302
+ OneUptimeDate.getCurrentTimezoneString() +
303
+ " (" +
304
+ OneUptimeDate.getCurrentTimezone().toString() +
305
+ ")."
306
+ ).trim();
297
307
  }
298
308
 
299
309
  type GetFieldDescriptionFunction = () => ReactElement | string;
@@ -174,7 +174,12 @@ const Input: FunctionComponent<ComponentProps> = (
174
174
  props.type === InputType.DATETIME_LOCAL) &&
175
175
  value
176
176
  ) {
177
- const date: Date = OneUptimeDate.fromString(value);
177
+ /*
178
+ * The input hands back a bare wall-clock with no offset. Resolve
179
+ * it in the user's configured timezone rather than letting the
180
+ * browser assume its own zone.
181
+ */
182
+ const date: Date = OneUptimeDate.fromDateTimeLocalString(value);
178
183
  const dateString: string = OneUptimeDate.toString(date);
179
184
  setValue(dateString);
180
185
  if (props.onChange) {
@@ -81,8 +81,12 @@ const TimePicker: FunctionComponent<ComponentProps> = (
81
81
  return toDate(props.value) || OneUptimeDate.getCurrentDate();
82
82
  }, [props.value]);
83
83
 
84
- const [hours24, setHours24] = useState<number>(initialDate.getHours());
85
- const [minutes, setMinutes] = useState<number>(initialDate.getMinutes());
84
+ const [hours24, setHours24] = useState<number>(
85
+ OneUptimeDate.getLocalHours(initialDate),
86
+ );
87
+ const [minutes, setMinutes] = useState<number>(
88
+ OneUptimeDate.getLocalMinutes(initialDate),
89
+ );
86
90
 
87
91
  const hoursInputRef: React.MutableRefObject<HTMLInputElement | null> =
88
92
  useRef<HTMLInputElement | null>(null);
@@ -99,8 +103,8 @@ const TimePicker: FunctionComponent<ComponentProps> = (
99
103
  if (!d) {
100
104
  return;
101
105
  }
102
- setHours24(d.getHours());
103
- setMinutes(d.getMinutes());
106
+ setHours24(OneUptimeDate.getLocalHours(d));
107
+ setMinutes(OneUptimeDate.getLocalMinutes(d));
104
108
  }, [props.value]);
105
109
 
106
110
  const emitChange: (h24: number, m: number) => void = (