Skip to content

Commit 6563793

Browse files
authored
fix(webapp): dashboard timezone display + preference persistence (#4104)
## Summary Two related timezone bugs in the dashboard. 1. The date/time tooltip could show a UTC offset label that contradicted the time it displayed. A viewer whose machine clock differs from their saved timezone (or when a date falls in the other DST phase) would see something like `Local (UTC +0)` next to a value that isn't at +0. 2. A user's timezone preference silently failed to save whenever their browser reported a zone like `UTC`, `Etc/UTC`, or `Asia/Kolkata`, leaving their timestamps stuck in a previously-saved timezone. ## Offset label The "Local" row formatted its time using the viewer's configured timezone but computed the `(UTC +n)` label from `new Date().getTimezoneOffset()`, the browser's offset at the current moment. Those are two independent sources, so they disagreed when the configured timezone differed from the machine, and also when the displayed date was in the opposite DST phase. The label is now derived from the same date and timezone used to render the row (via `Intl.DateTimeFormat` with `timeZoneName: "longOffset"`), so it always matches the displayed time. ## Preference persistence `/resources/timezone` validated the incoming zone against `Intl.supportedValuesOf("timeZone")`, which lists only canonical zone ids. Browsers report zones that aren't in that list via `resolvedOptions().timeZone`, notably `UTC` (and `Etc/UTC`, `Asia/Kolkata`, `GMT`), so those requests returned 400 and the preference was never stored. Validation now checks whether the runtime can resolve the zone at all, which accepts every real zone and still rejects invalid input. Added unit tests for both.
1 parent fd4f02b commit 6563793

6 files changed

Lines changed: 125 additions & 18 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+
Fix the date/time tooltip's timezone offset label so it always matches the displayed local time, including across daylight saving boundaries, and let users whose browser reports UTC or an alias zone (e.g. Etc/UTC, Asia/Kolkata) save their timezone preference instead of it silently failing.

apps/webapp/app/components/primitives/DateTime.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,31 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
178178
);
179179
}
180180

181+
/**
182+
* Human-readable UTC offset for a timezone at a specific instant, e.g. "(UTC +3)".
183+
* Returns "" for UTC. The offset must be derived from the same (date, timeZone) pair
184+
* used to render the displayed time so the label always matches the value shown — and
185+
* so it stays correct across DST boundaries regardless of the viewer's current season.
186+
*/
187+
export function formatUtcOffset(date: Date, timeZone: string): string {
188+
if (timeZone === "UTC") return "";
189+
190+
const parts = new Intl.DateTimeFormat("en-US", {
191+
timeZone,
192+
timeZoneName: "longOffset",
193+
}).formatToParts(date);
194+
195+
// longOffset yields "GMT+03:00", "GMT-08:00", "GMT+05:30", or "GMT" for UTC-equivalent zones.
196+
const raw = parts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ?? "";
197+
const match = raw.match(/^([+-])(\d{2}):(\d{2})$/);
198+
if (!match) return "(UTC +0)";
199+
200+
const [, sign, hh, mm] = match;
201+
const hours = parseInt(hh, 10);
202+
const minutes = parseInt(mm, 10);
203+
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
204+
}
205+
181206
// New component that only shows date when it changes
182207
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
183208
const locales = useLocales();
@@ -445,32 +470,22 @@ type DateTimeTooltipContentProps = {
445470
dateTime: string;
446471
isoDateTime: string;
447472
icon: ReactNode;
473+
offset?: string;
448474
};
449475

450476
function DateTimeTooltipContent({
451477
title,
452478
dateTime,
453479
isoDateTime,
454480
icon,
481+
offset,
455482
}: DateTimeTooltipContentProps) {
456-
const getUtcOffset = useMemo(
457-
() => () => {
458-
if (title !== "Local") return "";
459-
const offset = -new Date().getTimezoneOffset();
460-
const sign = offset >= 0 ? "+" : "-";
461-
const hours = Math.abs(Math.floor(offset / 60));
462-
const minutes = Math.abs(offset % 60);
463-
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
464-
},
465-
[title]
466-
);
467-
468483
return (
469484
<div className="flex flex-col gap-1">
470485
<div className="flex items-center gap-1 text-sm">
471486
{icon}
472487
<span className="font-medium">{title}</span>
473-
<span className="font-normal text-text-dimmed">{getUtcOffset()}</span>
488+
{offset ? <span className="font-normal text-text-dimmed">{offset}</span> : null}
474489
</div>
475490
<div className="flex items-center justify-between gap-2">
476491
<Paragraph variant="extra-small" className="text-text-dimmed">
@@ -515,6 +530,7 @@ function TooltipContent({
515530
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)}
516531
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
517532
icon={<Laptop className="size-4 text-green-500" />}
533+
offset={formatUtcOffset(realDate, localTimeZone)}
518534
/>
519535
</div>
520536
</div>

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+
}

apps/webapp/test/components/DateTime.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { formatDateTimeISO } from "~/components/primitives/DateTime";
2+
import { formatDateTimeISO, formatUtcOffset } from "~/components/primitives/DateTime";
33

44
describe("formatDateTimeISO", () => {
55
it("should format UTC dates with Z suffix", () => {
@@ -52,3 +52,45 @@ describe("formatDateTimeISO", () => {
5252
expect(result).toBe("2025-04-29T15:01:19.123+01:00");
5353
});
5454
});
55+
56+
describe("formatUtcOffset", () => {
57+
const date = new Date("2026-06-30T13:16:26.000Z");
58+
59+
it("returns an empty string for UTC", () => {
60+
expect(formatUtcOffset(date, "UTC")).toBe("");
61+
});
62+
63+
it("returns an empty offset for UTC-equivalent zones", () => {
64+
expect(formatUtcOffset(date, "Atlantic/Reykjavik")).toBe("(UTC +0)");
65+
});
66+
67+
// The reported bug: the offset label must reflect the displayed timezone, not the
68+
// viewer's machine. A viewer on a UTC machine looking at a UTC+3 zone must see +3.
69+
it("reflects the timezone being displayed, not the viewer's machine", () => {
70+
expect(formatUtcOffset(date, "Europe/Moscow")).toBe("(UTC +3)");
71+
});
72+
73+
it("formats half-hour offsets", () => {
74+
expect(formatUtcOffset(date, "Asia/Kolkata")).toBe("(UTC +5:30)");
75+
});
76+
77+
it("formats negative offsets", () => {
78+
expect(formatUtcOffset(date, "America/Los_Angeles")).toBe("(UTC -7)");
79+
});
80+
81+
// The offset is derived from the given instant, so it stays correct across DST
82+
// boundaries regardless of what season the viewer is currently in.
83+
describe("is DST-aware for the given instant", () => {
84+
it("uses +0 for a London winter date", () => {
85+
expect(formatUtcOffset(new Date("2026-01-15T12:00:00.000Z"), "Europe/London")).toBe(
86+
"(UTC +0)"
87+
);
88+
});
89+
90+
it("uses +1 for a London summer date", () => {
91+
expect(formatUtcOffset(new Date("2026-07-15T12:00:00.000Z"), "Europe/London")).toBe(
92+
"(UTC +1)"
93+
);
94+
});
95+
});
96+
});
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)