Skip to content

Commit 4ed147a

Browse files
committed
fix(webapp): derive date tooltip offset from the displayed timezone
The date/time tooltip's "Local" row formatted its time in the viewer's configured timezone but computed the "(UTC +n)" label from the browser's own offset at the current moment. When those differed (a viewer whose machine is UTC but whose timezone preference is elsewhere), the label contradicted the time shown, e.g. "Local (UTC +0)" next to a value three hours ahead of UTC. Using the current moment also made the label wrong for dates in the opposite DST phase. Compute the offset from the same date and timezone used to render the row, so the label always matches the displayed time and stays correct across DST boundaries.
1 parent d720690 commit 4ed147a

3 files changed

Lines changed: 78 additions & 14 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 timezone offset label in the date/time tooltip so it matches the displayed local time and stays correct across daylight saving boundaries.

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/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+
});

0 commit comments

Comments
 (0)