From 2ec48762d209963c6212886b5f81d2fb65f69cec Mon Sep 17 00:00:00 2001 From: Sage Stainsby Date: Tue, 28 Jul 2026 16:43:48 +1000 Subject: [PATCH] Follow system timezone changes in message timeline formatters The message timeline's Intl.DateTimeFormat instances were constructed once at module load, and a formatter snapshots the timezone it was constructed in. A long-running Buzz therefore kept rendering timestamps in the previous zone after the OS timezone changed (e.g. travelling between timezones), until a full restart. Cache the formatters per resolved timezone instead: instances are reused while the zone is stable and rebuilt when it changes. Signed-off-by: Sage Stainsby --- .../messages/lib/dateFormatters.test.mjs | 28 +++++++ .../features/messages/lib/dateFormatters.ts | 83 ++++++++++++------- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf6..6d22b5ab95 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -3,8 +3,10 @@ import test from "node:test"; import { formatDayHeading, + formatFullDateTime, formatShortMonthDayOrdinal, formatThreadSummaryLastReplyTime, + formatTime, formatTimeWithoutDayPeriod, startOfLocalDaySeconds, } from "./dateFormatters.ts"; @@ -153,3 +155,29 @@ test("startOfLocalDaySeconds separates adjacent calendar days", () => { startOfLocalDaySeconds(earlyOn15), ); }); + +test("formatters follow a system timezone change without a restart", () => { + const originalTz = process.env.TZ; + const unixSeconds = Date.UTC(2026, 3, 2, 21, 34) / 1_000; + try { + process.env.TZ = "America/Los_Angeles"; + assert.equal(formatTime(unixSeconds), "2:34 PM"); + assert.equal( + formatFullDateTime(unixSeconds), + "Thursday, April 2, 2026 at 2:34 PM", + ); + + process.env.TZ = "Australia/Melbourne"; + assert.equal(formatTime(unixSeconds), "8:34 AM"); + assert.equal( + formatFullDateTime(unixSeconds), + "Friday, April 3, 2026 at 8:34 AM", + ); + } finally { + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + } +}); diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d8150..b40bb6512d 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -7,39 +7,65 @@ * - `formatDayHeading` — label for day dividers / sticky headers. * Returns "Today", "Yesterday", or a date like "Monday, March 31st". * - `isSameDay` — compare two unix-second timestamps. + * + * All formatters follow the system timezone as resolved at call time, so a + * timezone change while the app is running is picked up without a restart. */ -const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - const DAY_PERIOD_SUFFIX_RE = /[\s\u00a0\u202f]*(?:AM|PM)$/i; -const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", -}); - -const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); +interface TimelineDateFormatters { + time: Intl.DateTimeFormat; + fullDateTime: Intl.DateTimeFormat; + weekday: Intl.DateTimeFormat; + longMonth: Intl.DateTimeFormat; + shortMonth: Intl.DateTimeFormat; +} -const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "long", -}); +let cachedTimeZone: string | null = null; +let cachedFormatters: TimelineDateFormatters | null = null; -const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "short", -}); +/** + * An `Intl.DateTimeFormat` snapshots the timezone it was constructed in, so a + * plain module-level instance keeps formatting in a stale zone for the whole + * life of the app when the OS timezone changes underneath it (travel, + * DST-less VMs, manual changes). Cache per resolved timezone instead: + * instances are reused while the zone is stable and rebuilt when it changes. + */ +function currentFormatters(): TimelineDateFormatters { + const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + if (cachedFormatters === null || cachedTimeZone !== timeZone) { + cachedTimeZone = timeZone; + cachedFormatters = { + time: new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }), + fullDateTime: new Intl.DateTimeFormat("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + weekday: new Intl.DateTimeFormat("en-US", { + weekday: "long", + }), + longMonth: new Intl.DateTimeFormat("en-US", { + month: "long", + }), + shortMonth: new Intl.DateTimeFormat("en-US", { + month: "short", + }), + }; + } + return cachedFormatters; +} /** Short clock time, e.g. "2:34 PM". */ export function formatTime(unixSeconds: number): string { - return TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); + return currentFormatters().time.format(new Date(unixSeconds * 1_000)); } /** Short clock time with the AM/PM marker removed, e.g. "2:34". */ @@ -49,7 +75,7 @@ export function formatTimeWithoutDayPeriod(time: string): string { /** Full date + time for tooltips, e.g. "Wednesday, April 2, 2026 at 2:34 PM". */ export function formatFullDateTime(unixSeconds: number): string { - return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); + return currentFormatters().fullDateTime.format(new Date(unixSeconds * 1_000)); } /** @@ -71,9 +97,10 @@ export function formatDayHeading(unixSeconds: number): string { return "Yesterday"; } - const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal( + const formatters = currentFormatters(); + const dateLabel = `${formatters.weekday.format(date)}, ${formatMonthDayOrdinal( date, - LONG_MONTH_FORMATTER, + formatters.longMonth, )}`; return date.getFullYear() === now.getFullYear() ? dateLabel @@ -101,7 +128,7 @@ export function startOfLocalDaySeconds(unixSeconds: number): number { export function formatShortMonthDayOrdinal(unixSeconds: number): string { return formatMonthDayOrdinal( new Date(unixSeconds * 1_000), - SHORT_MONTH_FORMATTER, + currentFormatters().shortMonth, ); }