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.
This commit is contained in:
@@ -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.
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
{icon}
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="font-normal text-text-dimmed">{getUtcOffset()}</span>
|
||||
{offset ? <span className="font-normal text-text-dimmed">{offset}</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
@@ -515,6 +530,7 @@ function TooltipContent({
|
||||
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)}
|
||||
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
|
||||
icon={<Laptop className="size-4 text-green-500" />}
|
||||
offset={formatUtcOffset(realDate, localTimeZone)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user