@oneuptime/common 11.7.0 → 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 (28) hide show
  1. package/Tests/Types/DateUserTimezone.test.ts +205 -0
  2. package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
  3. package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
  4. package/Types/Date.ts +119 -50
  5. package/UI/Components/Forms/Fields/FormField.tsx +13 -3
  6. package/UI/Components/Input/Input.tsx +6 -1
  7. package/UI/Components/TimePicker/TimePicker.tsx +8 -4
  8. package/UI/Utils/User.ts +19 -0
  9. package/build/dist/Types/Date.js +106 -35
  10. package/build/dist/Types/Date.js.map +1 -1
  11. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
  12. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  13. package/build/dist/UI/Components/Input/Input.js +6 -1
  14. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  15. package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
  16. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  17. package/build/dist/UI/Utils/User.js +19 -0
  18. package/build/dist/UI/Utils/User.js.map +1 -1
  19. package/package.json +1 -1
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
  23. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The user's profile timezone (User Settings > Timezone) — not the zone the
3
+ * browser reports — decides which wall clock OneUptimeDate reads and writes.
4
+ *
5
+ * The bug these lock in: a user in America/New_York whose browser reported
6
+ * America/Adak was told "your local timezone - HDT" on every date field, and
7
+ * had to enter Hawaii-Aleutian times for their events to land correctly. The
8
+ * profile timezone was stored but never consulted by the UI.
9
+ *
10
+ * Every assertion is expressed in explicit-zone wall-clock (or absolute
11
+ * milliseconds) so it holds under whatever TZ the suite runs in.
12
+ */
13
+ import OneUptimeDate from "../../Types/Date";
14
+ import Timezone from "../../Types/Timezone";
15
+ import moment from "moment-timezone";
16
+
17
+ const NY: Timezone = Timezone.AmericaNew_York;
18
+ const ADAK: Timezone = Timezone.AmericaAdak;
19
+ const KOLKATA: Timezone = Timezone.AsiaKolkata;
20
+
21
+ describe("OneUptimeDate user timezone", () => {
22
+ afterEach(() => {
23
+ // Never leak an override into the rest of the suite.
24
+ OneUptimeDate.setUserTimezone(null);
25
+ });
26
+
27
+ describe("setUserTimezone / getCurrentTimezone", () => {
28
+ it("falls back to the browser / process zone when no user timezone is set", () => {
29
+ expect(OneUptimeDate.getUserTimezone()).toBeNull();
30
+ expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
31
+ moment.tz.guess(),
32
+ );
33
+ });
34
+
35
+ it("prefers the user timezone over the browser / process zone", () => {
36
+ OneUptimeDate.setUserTimezone(NY);
37
+
38
+ expect(OneUptimeDate.getCurrentTimezone()).toBe(NY);
39
+ expect(OneUptimeDate.getUserTimezone()).toBe(NY);
40
+ });
41
+
42
+ it("reports the abbreviation of the user timezone, DST aware", () => {
43
+ OneUptimeDate.setUserTimezone(NY);
44
+
45
+ // The bug reported "HDT" (America/Adak) for a New York user.
46
+ expect(OneUptimeDate.getCurrentTimezoneString()).not.toBe("HDT");
47
+ expect(["EST", "EDT"]).toContain(
48
+ OneUptimeDate.getCurrentTimezoneString(),
49
+ );
50
+ });
51
+
52
+ it("clears back to the browser / process zone when set to null", () => {
53
+ OneUptimeDate.setUserTimezone(NY);
54
+ OneUptimeDate.setUserTimezone(null);
55
+
56
+ expect(OneUptimeDate.getUserTimezone()).toBeNull();
57
+ expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
58
+ moment.tz.guess(),
59
+ );
60
+ });
61
+
62
+ it("ignores a value moment does not recognise as a zone rather than breaking every date", () => {
63
+ OneUptimeDate.setUserTimezone("Not/AZone" as Timezone);
64
+
65
+ expect(OneUptimeDate.getUserTimezone()).toBeNull();
66
+ expect(OneUptimeDate.getCurrentTimezone().toString()).toBe(
67
+ moment.tz.guess(),
68
+ );
69
+ // Formatting still works.
70
+ expect(
71
+ OneUptimeDate.toDateTimeLocalString(new Date("2026-07-09T13:00:00Z")),
72
+ ).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
73
+ });
74
+ });
75
+
76
+ describe("datetime-local round trip", () => {
77
+ it("renders a stored instant as its wall-clock in the user timezone", () => {
78
+ OneUptimeDate.setUserTimezone(NY);
79
+
80
+ // 17:00 UTC on 2026-07-09 is 13:00 in New York (EDT).
81
+ expect(
82
+ OneUptimeDate.toDateTimeLocalString(new Date("2026-07-09T17:00:00Z")),
83
+ ).toBe("2026-07-09T13:00:00");
84
+ });
85
+
86
+ it("resolves a typed wall-clock in the user timezone, not the browser zone", () => {
87
+ OneUptimeDate.setUserTimezone(NY);
88
+
89
+ const typed: Date =
90
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
91
+
92
+ expect(typed.toISOString()).toBe("2026-07-09T17:00:00.000Z");
93
+ });
94
+
95
+ it("round trips what the user typed back into the picker unchanged", () => {
96
+ OneUptimeDate.setUserTimezone(NY);
97
+
98
+ const typed: Date =
99
+ OneUptimeDate.fromDateTimeLocalString("2026-01-15T09:30");
100
+
101
+ expect(OneUptimeDate.toDateTimeLocalString(typed)).toBe(
102
+ "2026-01-15T09:30:00",
103
+ );
104
+ });
105
+
106
+ it("stores different instants for the same wall-clock in different user timezones", () => {
107
+ OneUptimeDate.setUserTimezone(NY);
108
+ const inNY: Date =
109
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
110
+
111
+ OneUptimeDate.setUserTimezone(ADAK);
112
+ const inAdak: Date =
113
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T13:00");
114
+
115
+ // Adak is 5 hours behind New York in July.
116
+ expect(inAdak.getTime() - inNY.getTime()).toBe(5 * 60 * 60 * 1000);
117
+ });
118
+
119
+ it("honours the user timezone's DST offset for a winter instant", () => {
120
+ OneUptimeDate.setUserTimezone(NY);
121
+
122
+ // 09:00 EST is 14:00 UTC in January.
123
+ expect(
124
+ OneUptimeDate.fromDateTimeLocalString("2026-01-15T09:00").toISOString(),
125
+ ).toBe("2026-01-15T14:00:00.000Z");
126
+ });
127
+ });
128
+
129
+ describe("display helpers", () => {
130
+ it("formats an instant in the user timezone with its abbreviation", () => {
131
+ OneUptimeDate.setUserTimezone(NY);
132
+
133
+ const formatted: string = OneUptimeDate.getDateAsLocalFormattedString(
134
+ new Date("2026-07-09T17:00:00Z"),
135
+ );
136
+
137
+ expect(formatted).toContain("Jul 09 2026");
138
+ expect(formatted).toContain("13:00");
139
+ expect(formatted).toContain("EDT");
140
+ });
141
+
142
+ it("reads the hour and minute of an instant in the user timezone", () => {
143
+ OneUptimeDate.setUserTimezone(KOLKATA);
144
+
145
+ const instant: Date = new Date("2026-07-09T17:00:00Z");
146
+
147
+ // 17:00 UTC is 22:30 in Kolkata (UTC+5:30).
148
+ expect(OneUptimeDate.getLocalHours(instant)).toBe(22);
149
+ expect(OneUptimeDate.getLocalMinutes(instant)).toBe(30);
150
+ expect(OneUptimeDate.getLocalTimeString(instant)).toBe("22:30");
151
+ });
152
+
153
+ it("rolls the calendar day when the user timezone puts the instant on another date", () => {
154
+ OneUptimeDate.setUserTimezone(KOLKATA);
155
+
156
+ // 23:00 UTC on Jul 9 is already 04:30 on Jul 10 in Kolkata.
157
+ const instant: Date = new Date("2026-07-09T23:00:00Z");
158
+
159
+ expect(OneUptimeDate.asDateForDatabaseQuery(instant)).toBe("2026-07-10");
160
+ expect(OneUptimeDate.getDateAsLocalDayMonthString(instant)).toBe(
161
+ "10 Jul",
162
+ );
163
+ expect(OneUptimeDate.getDateAsLocalMonthYearString(instant)).toBe(
164
+ "Jul 2026",
165
+ );
166
+ });
167
+ });
168
+
169
+ describe("schedule-zone wall-clock bridge", () => {
170
+ it("reads the entered wall-clock in the user timezone before anchoring it to the schedule zone", () => {
171
+ OneUptimeDate.setUserTimezone(NY);
172
+
173
+ // The admin typed 09:00 while working in New York.
174
+ const typed: Date =
175
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T09:00");
176
+
177
+ const stored: Date = OneUptimeDate.getInstantFromLocalWallClockInTimezone(
178
+ typed,
179
+ KOLKATA.toString(),
180
+ );
181
+
182
+ // ...and the schedule enforces 09:00 in Kolkata.
183
+ expect(moment.tz(stored, KOLKATA.toString()).format("HH:mm")).toBe(
184
+ "09:00",
185
+ );
186
+ });
187
+
188
+ it("redisplays a stored schedule-zone time as the same wall-clock in the picker", () => {
189
+ OneUptimeDate.setUserTimezone(NY);
190
+
191
+ const stored: Date = moment
192
+ .tz("2026-07-09 09:00", KOLKATA.toString())
193
+ .toDate();
194
+
195
+ const forPicker: Date = OneUptimeDate.getLocalDateFromWallClockInTimezone(
196
+ stored,
197
+ KOLKATA.toString(),
198
+ );
199
+
200
+ expect(OneUptimeDate.toDateTimeLocalString(forPicker)).toBe(
201
+ "2026-07-09T09:00:00",
202
+ );
203
+ });
204
+ });
205
+ });
@@ -0,0 +1,73 @@
1
+ /** @timezone America/Adak */
2
+
3
+ /**
4
+ * Regression test for the reported bug, reproduced exactly: the process (a
5
+ * stand-in for the customer's browser) reports America/Adak — abbreviated
6
+ * "HDT" — while the user has picked America/New_York in User Settings.
7
+ *
8
+ * Before the fix, every date field labelled itself "your local timezone - HDT"
9
+ * and resolved what the user typed in Adak's zone, so a New York user had to
10
+ * enter Hawaii-Aleutian times to get the right instant stored. The profile
11
+ * timezone must win over whatever the browser reports.
12
+ */
13
+ import OneUptimeDate from "../../Types/Date";
14
+ import Timezone from "../../Types/Timezone";
15
+ import moment from "moment-timezone";
16
+
17
+ const NY: Timezone = Timezone.AmericaNew_York;
18
+
19
+ describe("user timezone overrides a browser reporting a different zone", () => {
20
+ afterEach(() => {
21
+ OneUptimeDate.setUserTimezone(null);
22
+ });
23
+
24
+ it("confirms the process really is on the Adak (HDT) clock", () => {
25
+ // Guards the premise of every assertion below.
26
+ expect(moment.tz.guess()).toBe("America/Adak");
27
+ expect(OneUptimeDate.getCurrentTimezoneString()).toBe("HDT");
28
+ });
29
+
30
+ it("labels date fields with the profile timezone, not HDT", () => {
31
+ OneUptimeDate.setUserTimezone(NY);
32
+
33
+ expect(OneUptimeDate.getCurrentTimezoneString()).toBe("EDT");
34
+ expect(OneUptimeDate.getCurrentTimezone()).toBe(NY);
35
+ });
36
+
37
+ it("stores the wall-clock the user typed as New York time", () => {
38
+ OneUptimeDate.setUserTimezone(NY);
39
+
40
+ /*
41
+ * The user schedules maintenance for 14:00 on Jul 9. That is 18:00 UTC in
42
+ * New York; reading it on the Adak clock would have stored 23:00 UTC —
43
+ * five hours off, which is exactly what the customer worked around.
44
+ */
45
+ const startsAt: Date =
46
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T14:00");
47
+
48
+ expect(startsAt.toISOString()).toBe("2026-07-09T18:00:00.000Z");
49
+ });
50
+
51
+ it("reads a stored event back at the wall-clock the user typed", () => {
52
+ OneUptimeDate.setUserTimezone(NY);
53
+
54
+ const startsAt: Date = new Date("2026-07-09T18:00:00.000Z");
55
+
56
+ expect(OneUptimeDate.toDateTimeLocalString(startsAt)).toBe(
57
+ "2026-07-09T14:00:00",
58
+ );
59
+ expect(OneUptimeDate.getDateAsLocalFormattedString(startsAt)).toContain(
60
+ "EDT",
61
+ );
62
+ expect(OneUptimeDate.getLocalTimeString(startsAt)).toBe("14:00");
63
+ });
64
+
65
+ it("still follows the browser zone for a user who has not picked one", () => {
66
+ OneUptimeDate.setUserTimezone(null);
67
+
68
+ // 14:00 HDT (UTC-9) is 23:00 UTC the same day.
69
+ expect(
70
+ OneUptimeDate.fromDateTimeLocalString("2026-07-09T14:00").toISOString(),
71
+ ).toBe("2026-07-09T23:00:00.000Z");
72
+ });
73
+ });
@@ -89,6 +89,17 @@ jest.mock("../../../../Types/Date", () => {
89
89
  return base;
90
90
  },
91
91
  ),
92
+ /*
93
+ * The picker reads the wall-clock through OneUptimeDate so it resolves in
94
+ * the user's configured timezone; the stubbed dates above already carry
95
+ * the hour/minute the test intends.
96
+ */
97
+ getLocalHours: jest.fn((d: HourMinuteMock) => {
98
+ return d.getHours();
99
+ }),
100
+ getLocalMinutes: jest.fn((d: HourMinuteMock) => {
101
+ return d.getMinutes();
102
+ }),
92
103
  getCurrentTimezoneString: jest.fn(() => {
93
104
  return "UTC";
94
105
  }),
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 = (