Skip to content

Commit f75b949

Browse files
committed
fix(webapp): accept all browser-reported timezones when saving preference
The timezone preference endpoint validated against Intl.supportedValuesOf("timeZone"), which lists only canonical zone ids and omits ones browsers report via resolvedOptions().timeZone, such as "UTC", "Etc/UTC" and "Asia/Kolkata". A client on any of those zones got a 400 and its preference was never stored, leaving timestamps rendered in a stale timezone. Validate by whether the runtime can resolve the zone instead, which accepts every real zone and still rejects invalid input.
1 parent 36b3f21 commit f75b949

4 files changed

Lines changed: 53 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Save the user's timezone preference for any zone the browser reports, including UTC and alias zones (e.g. Etc/UTC, Asia/Kolkata) that were previously rejected. Without this, affected users had timestamps stuck in a previously-saved timezone.

apps/webapp/app/routes/resources.timezone.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ import {
44
setTimezonePreference,
55
uiPreferencesStorage,
66
} from "~/services/preferences/uiPreferences.server";
7+
import { isValidTimeZone } from "~/utils/timezones.server";
78

89
const schema = z.object({
910
timezone: z.string().min(1).max(100),
1011
});
1112

12-
// Cache the supported timezones to avoid repeated calls
13-
const supportedTimezones = new Set(Intl.supportedValuesOf("timeZone"));
14-
1513
export async function action({ request }: ActionFunctionArgs) {
1614
let data: unknown;
1715
try {
@@ -26,7 +24,7 @@ export async function action({ request }: ActionFunctionArgs) {
2624
return json({ success: false, error: "Invalid timezone" }, { status: 400 });
2725
}
2826

29-
if (!supportedTimezones.has(result.data.timezone)) {
27+
if (!isValidTimeZone(result.data.timezone)) {
3028
return json({ success: false, error: "Invalid timezone" }, { status: 400 });
3129
}
3230

apps/webapp/app/utils/timezones.server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,18 @@ export function getTimezones(includeUtc = true) {
55
}
66
return possibleTimezones;
77
}
8+
9+
/**
10+
* Whether the runtime can resolve this IANA timezone. Prefer this over checking membership
11+
* in `Intl.supportedValuesOf("timeZone")`, which lists only canonical ids and omits zones
12+
* browsers legitimately report (e.g. "UTC", "Etc/UTC", "Asia/Kolkata") — rejecting those
13+
* would leave a client's stored timezone stale.
14+
*/
15+
export function isValidTimeZone(timeZone: string): boolean {
16+
try {
17+
new Intl.DateTimeFormat("en-US", { timeZone });
18+
return true;
19+
} catch {
20+
return false;
21+
}
22+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isValidTimeZone } from "~/utils/timezones.server";
3+
4+
describe("isValidTimeZone", () => {
5+
// These are all zones a browser can report via
6+
// Intl.DateTimeFormat().resolvedOptions().timeZone, but which are NOT in
7+
// Intl.supportedValuesOf("timeZone"). Rejecting them left the user's stored
8+
// timezone stale (their preference update would 400).
9+
it.each(["UTC", "Etc/UTC", "GMT", "Asia/Kolkata"])(
10+
"accepts %s even though it is not in supportedValuesOf",
11+
(tz) => {
12+
expect(Intl.supportedValuesOf("timeZone").includes(tz)).toBe(false);
13+
expect(isValidTimeZone(tz)).toBe(true);
14+
}
15+
);
16+
17+
it.each(["Europe/London", "Europe/Moscow", "America/New_York", "Asia/Calcutta"])(
18+
"accepts canonical zone %s",
19+
(tz) => {
20+
expect(isValidTimeZone(tz)).toBe(true);
21+
}
22+
);
23+
24+
it.each(["", "Not/AZone", "Mars/Phobos", "Europe/Nowhere", "12345"])(
25+
"rejects invalid zone %s",
26+
(tz) => {
27+
expect(isValidTimeZone(tz)).toBe(false);
28+
}
29+
);
30+
});

0 commit comments

Comments
 (0)