From 6563793132df2fc10499690cef5ff2359dac72bd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 2 Jul 2026 16:04:09 +0100 Subject: [PATCH] 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. --- .server-changes/dashboard-timezone.md | 6 +++ .../app/components/primitives/DateTime.tsx | 42 ++++++++++++------ apps/webapp/app/routes/resources.timezone.ts | 6 +-- apps/webapp/app/utils/timezones.server.ts | 15 +++++++ apps/webapp/test/components/DateTime.test.ts | 44 ++++++++++++++++++- apps/webapp/test/utils/timezones.test.ts | 30 +++++++++++++ 6 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 .server-changes/dashboard-timezone.md create mode 100644 apps/webapp/test/utils/timezones.test.ts diff --git a/.server-changes/dashboard-timezone.md b/.server-changes/dashboard-timezone.md new file mode 100644 index 000000000..2cb106f94 --- /dev/null +++ b/.server-changes/dashboard-timezone.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +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. diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 41c51cdd7..355af397a 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -178,6 +178,31 @@ export function formatDateTimeISO(date: Date, timeZone: string): string { ); } +/** + * Human-readable UTC offset for a timezone at a specific instant, e.g. "(UTC +3)". + * Returns "" for UTC. The offset must be derived from the same (date, timeZone) pair + * used to render the displayed time so the label always matches the value shown — and + * so it stays correct across DST boundaries regardless of the viewer's current season. + */ +export function formatUtcOffset(date: Date, timeZone: string): string { + if (timeZone === "UTC") return ""; + + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "longOffset", + }).formatToParts(date); + + // longOffset yields "GMT+03:00", "GMT-08:00", "GMT+05:30", or "GMT" for UTC-equivalent zones. + const raw = parts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ?? ""; + const match = raw.match(/^([+-])(\d{2}):(\d{2})$/); + if (!match) return "(UTC +0)"; + + const [, sign, hh, mm] = match; + const hours = parseInt(hh, 10); + const minutes = parseInt(mm, 10); + return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`; +} + // New component that only shows date when it changes export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => { const locales = useLocales(); @@ -445,6 +470,7 @@ type DateTimeTooltipContentProps = { dateTime: string; isoDateTime: string; icon: ReactNode; + offset?: string; }; function DateTimeTooltipContent({ @@ -452,25 +478,14 @@ function DateTimeTooltipContent({ dateTime, isoDateTime, icon, + offset, }: DateTimeTooltipContentProps) { - const getUtcOffset = useMemo( - () => () => { - if (title !== "Local") return ""; - const offset = -new Date().getTimezoneOffset(); - const sign = offset >= 0 ? "+" : "-"; - const hours = Math.abs(Math.floor(offset / 60)); - const minutes = Math.abs(offset % 60); - return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`; - }, - [title] - ); - return (
{icon} {title} - {getUtcOffset()} + {offset ? {offset} : null}
@@ -515,6 +530,7 @@ function TooltipContent({ dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)} isoDateTime={formatDateTimeISO(realDate, localTimeZone)} icon={} + offset={formatUtcOffset(realDate, localTimeZone)} />
diff --git a/apps/webapp/app/routes/resources.timezone.ts b/apps/webapp/app/routes/resources.timezone.ts index f06b44e61..de90b5036 100644 --- a/apps/webapp/app/routes/resources.timezone.ts +++ b/apps/webapp/app/routes/resources.timezone.ts @@ -4,14 +4,12 @@ import { setTimezonePreference, uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; +import { isValidTimeZone } from "~/utils/timezones.server"; const schema = z.object({ timezone: z.string().min(1).max(100), }); -// Cache the supported timezones to avoid repeated calls -const supportedTimezones = new Set(Intl.supportedValuesOf("timeZone")); - export async function action({ request }: ActionFunctionArgs) { let data: unknown; try { @@ -26,7 +24,7 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: false, error: "Invalid timezone" }, { status: 400 }); } - if (!supportedTimezones.has(result.data.timezone)) { + if (!isValidTimeZone(result.data.timezone)) { return json({ success: false, error: "Invalid timezone" }, { status: 400 }); } diff --git a/apps/webapp/app/utils/timezones.server.ts b/apps/webapp/app/utils/timezones.server.ts index a2d3b83d2..5080502da 100644 --- a/apps/webapp/app/utils/timezones.server.ts +++ b/apps/webapp/app/utils/timezones.server.ts @@ -5,3 +5,18 @@ export function getTimezones(includeUtc = true) { } return possibleTimezones; } + +/** + * Whether the runtime can resolve this IANA timezone. Prefer this over checking membership + * in `Intl.supportedValuesOf("timeZone")`, which lists only canonical ids and omits zones + * browsers legitimately report (e.g. "UTC", "Etc/UTC", "Asia/Kolkata") — rejecting those + * would leave a client's stored timezone stale. + */ +export function isValidTimeZone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone }); + return true; + } catch { + return false; + } +} diff --git a/apps/webapp/test/components/DateTime.test.ts b/apps/webapp/test/components/DateTime.test.ts index 103f416eb..68c806f5d 100644 --- a/apps/webapp/test/components/DateTime.test.ts +++ b/apps/webapp/test/components/DateTime.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatDateTimeISO } from "~/components/primitives/DateTime"; +import { formatDateTimeISO, formatUtcOffset } from "~/components/primitives/DateTime"; describe("formatDateTimeISO", () => { it("should format UTC dates with Z suffix", () => { @@ -52,3 +52,45 @@ describe("formatDateTimeISO", () => { expect(result).toBe("2025-04-29T15:01:19.123+01:00"); }); }); + +describe("formatUtcOffset", () => { + const date = new Date("2026-06-30T13:16:26.000Z"); + + it("returns an empty string for UTC", () => { + expect(formatUtcOffset(date, "UTC")).toBe(""); + }); + + it("returns an empty offset for UTC-equivalent zones", () => { + expect(formatUtcOffset(date, "Atlantic/Reykjavik")).toBe("(UTC +0)"); + }); + + // The reported bug: the offset label must reflect the displayed timezone, not the + // viewer's machine. A viewer on a UTC machine looking at a UTC+3 zone must see +3. + it("reflects the timezone being displayed, not the viewer's machine", () => { + expect(formatUtcOffset(date, "Europe/Moscow")).toBe("(UTC +3)"); + }); + + it("formats half-hour offsets", () => { + expect(formatUtcOffset(date, "Asia/Kolkata")).toBe("(UTC +5:30)"); + }); + + it("formats negative offsets", () => { + expect(formatUtcOffset(date, "America/Los_Angeles")).toBe("(UTC -7)"); + }); + + // The offset is derived from the given instant, so it stays correct across DST + // boundaries regardless of what season the viewer is currently in. + describe("is DST-aware for the given instant", () => { + it("uses +0 for a London winter date", () => { + expect(formatUtcOffset(new Date("2026-01-15T12:00:00.000Z"), "Europe/London")).toBe( + "(UTC +0)" + ); + }); + + it("uses +1 for a London summer date", () => { + expect(formatUtcOffset(new Date("2026-07-15T12:00:00.000Z"), "Europe/London")).toBe( + "(UTC +1)" + ); + }); + }); +}); diff --git a/apps/webapp/test/utils/timezones.test.ts b/apps/webapp/test/utils/timezones.test.ts new file mode 100644 index 000000000..7664c2334 --- /dev/null +++ b/apps/webapp/test/utils/timezones.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { isValidTimeZone } from "~/utils/timezones.server"; + +describe("isValidTimeZone", () => { + // These are all zones a browser can report via + // Intl.DateTimeFormat().resolvedOptions().timeZone, but which are NOT in + // Intl.supportedValuesOf("timeZone"). Rejecting them left the user's stored + // timezone stale (their preference update would 400). + it.each(["UTC", "Etc/UTC", "GMT", "Asia/Kolkata"])( + "accepts %s even though it is not in supportedValuesOf", + (tz) => { + expect(Intl.supportedValuesOf("timeZone").includes(tz)).toBe(false); + expect(isValidTimeZone(tz)).toBe(true); + } + ); + + it.each(["Europe/London", "Europe/Moscow", "America/New_York", "Asia/Calcutta"])( + "accepts canonical zone %s", + (tz) => { + expect(isValidTimeZone(tz)).toBe(true); + } + ); + + it.each(["", "Not/AZone", "Mars/Phobos", "Europe/Nowhere", "12345"])( + "rejects invalid zone %s", + (tz) => { + expect(isValidTimeZone(tz)).toBe(false); + } + ); +});