From d750a753fe32c00fff33dc60f7b43bcd040e672e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 06:45:04 +0000 Subject: [PATCH 1/4] test(web): message timestamps follow a time zone change (reproduces #32355) --- apps/web/src/timestampFormat.test.ts | 46 +++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5b5e11d812027..7c92f31cff5de 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; +import { + formatChatTimestampTooltip, + formatDayAwareTimestamp, + formatUpcomingTimestamp, +} from "./timestampFormat"; + +// Node re-resolves the default zone when TZ is assigned, like a renderer after the OS zone changes. +function useTimeZone(timeZone: string): Disposable { + const previous = process.env.TZ; + process.env.TZ = timeZone; + return { + [Symbol.dispose]: () => { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + }, + }; +} describe("timestamps in an explicit time zone", () => { // 23:00 UTC on July 1 is 7 PM on July 1 in New York, whatever the host zone. @@ -19,3 +35,31 @@ describe("timestamps in an explicit time zone", () => { ); }); }); + +describe("timestamps after the system time zone changes", () => { + it("renders the gutter label in the new zone", () => { + const sameDay = "2026-11-07T04:45:43.877Z"; + const earlierInWeek = "2026-11-04T20:00:00.000Z"; + const now = Date.parse(sameDay); + using _zone = useTimeZone("America/New_York"); + expect([ + formatDayAwareTimestamp(sameDay, "12-hour", now), + formatDayAwareTimestamp(earlierInWeek, "12-hour", now), + ]).toEqual(["11:45 PM", "11/4 3:00 PM"]); + + process.env.TZ = "Asia/Bangkok"; + expect([ + formatDayAwareTimestamp(sameDay, "12-hour", now), + formatDayAwareTimestamp(earlierInWeek, "12-hour", now), + ]).toEqual(["11:45 AM", "11/5 3:00 AM"]); + }); + + it("renders the tooltip in the new zone", () => { + const monthEnd = "2026-10-31T20:00:00.000Z"; + using _zone = useTimeZone("America/New_York"); + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe("4:00 PM, 31st October 2026"); + + process.env.TZ = "Asia/Bangkok"; + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe("3:00 AM, 1st November 2026"); + }); +}); From 2b2fc8cb901ecbd90296b242aa0e87d173211752 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 06:48:51 +0000 Subject: [PATCH 2/4] fix(web): message timestamps follow a system time zone change Cached Intl.DateTimeFormat instances keep the default zone they were built in, so after the system zone changed every message time, date and tooltip month kept rendering in the old zone until reload. Default-zone formatters are now built in UTC and given the wall-clock time (date minus getTimezoneOffset), which Date reads from the current zone. The cache stays, explicit-zone paths are unchanged, and the mobile thread feed's message time formatter does the same. Fixes pingdotgg/t3code#32355 --- .../src/features/threads/ThreadFeed.tsx | 7 ++- apps/web/src/timestampFormat.ts | 49 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d6fdc9e955cde..fc4eaf225378f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,16 +294,21 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; +// A formatter keeps the default zone it was built in, so it runs in UTC on the wall-clock +// time, which Date reads from the current zone. const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", + timeZone: "UTC", }); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } - return MESSAGE_TIME_FORMATTER.format(timestamp); + return MESSAGE_TIME_FORMATTER.format( + timestamp - new Date(timestamp).getTimezoneOffset() * 60_000, + ); } const TURN_FOLD_HEIGHT = 42; diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index abad91401ccbe..5cec06e81741a 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -68,14 +68,20 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin export const weekStartsOn = resolveWeekStartsOn(timestampLocale); +// A formatter keeps the default zone it was built in, so default-zone formatters run in UTC +// on the wall-clock time, which Date reads from the current zone. +function wallClockAsUtc(date: Date): Date { + return new Date(date.getTime() - date.getTimezoneOffset() * 60_000); +} + const timestampFormatterCache = new Map(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, - timeZone?: string, + timeZone = "UTC", ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; + const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; @@ -83,12 +89,18 @@ function getTimestampFormatter( const formatter = new Intl.DateTimeFormat(timestampLocale, { ...getTimestampFormatOptions(timestampFormat, includeSeconds), - ...(timeZone ? { timeZone } : {}), + timeZone, }); timestampFormatterCache.set(cacheKey, formatter); return formatter; } +function formatTime(date: Date, timestampFormat: TimestampFormat, timeZone?: string): string { + return timeZone + ? getTimestampFormatter(timestampFormat, false, timeZone).format(date) + : getTimestampFormatter(timestampFormat, false).format(wallClockAsUtc(date)); +} + export function resolveHour12(timestampFormat: TimestampFormat): boolean { return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false; } @@ -101,7 +113,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". -const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); +const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; @@ -124,9 +136,9 @@ export function formatChatTimestampTooltip( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = formatShortTimestamp(isoDate, timestampFormat); + const time = formatTime(date, timestampFormat); const day = date.getDate(); - const month = monthNameFormatter.format(date); + const month = monthNameFormatter.format(wallClockAsUtc(date)); const year = date.getFullYear(); return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`; } @@ -134,7 +146,7 @@ export function formatChatTimestampTooltip( export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - return getTimestampFormatter(timestampFormat, false).format(date); + return formatTime(date, timestampFormat); } const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; @@ -142,11 +154,14 @@ const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { ...numericDateOptions, year: "numeric", }; -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); -const numericDateWithYearFormatter = new Intl.DateTimeFormat( - timestampLocale, - numericDateWithYearOptions, -); +const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { + ...numericDateOptions, + timeZone: "UTC", +}); +const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { + ...numericDateWithYearOptions, + timeZone: "UTC", +}); interface CalendarDay { year: number; @@ -173,7 +188,9 @@ function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { if (!timeZone) { - return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date); + return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format( + wallClockAsUtc(date), + ); } return new Intl.DateTimeFormat(timestampLocale, { ...(sameYear ? numericDateOptions : numericDateWithYearOptions), @@ -189,7 +206,7 @@ export function formatDayAwareTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = formatTime(date, timestampFormat, timeZone); const today = calendarDay(new Date(nowMs), timeZone); const messageDay = calendarDay(date, timeZone); @@ -200,7 +217,7 @@ export function formatDayAwareTimestamp( return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; } -/** `timeZone` overrides the zone the formatters cached when they were built. */ +/** `timeZone` overrides the system's current zone. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, @@ -209,7 +226,7 @@ export function formatUpcomingTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = formatTime(date, timestampFormat, timeZone); const today = calendarDay(new Date(nowMs), timeZone); const targetDay = calendarDay(date, timeZone); From 65b3056943493f6ab648154fd02f4689e3cc7eb1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 06:59:35 +0000 Subject: [PATCH 3/4] test(web): derive expected time zone labels from the host locale --- apps/web/src/timestampFormat.test.ts | 31 ++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 7c92f31cff5de..5f6513ab69623 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -37,6 +37,19 @@ describe("timestamps in an explicit time zone", () => { }); describe("timestamps after the system time zone changes", () => { + // Built fresh with an explicit zone, so expectations follow the host locale but not a stale zone. + const timeIn = (iso: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone, + }).format(new Date(iso)); + const dayIn = (iso: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric", timeZone }).format( + new Date(iso), + ); + it("renders the gutter label in the new zone", () => { const sameDay = "2026-11-07T04:45:43.877Z"; const earlierInWeek = "2026-11-04T20:00:00.000Z"; @@ -45,21 +58,31 @@ describe("timestamps after the system time zone changes", () => { expect([ formatDayAwareTimestamp(sameDay, "12-hour", now), formatDayAwareTimestamp(earlierInWeek, "12-hour", now), - ]).toEqual(["11:45 PM", "11/4 3:00 PM"]); + ]).toEqual([ + timeIn(sameDay, "America/New_York"), + `${dayIn(earlierInWeek, "America/New_York")} ${timeIn(earlierInWeek, "America/New_York")}`, + ]); process.env.TZ = "Asia/Bangkok"; expect([ formatDayAwareTimestamp(sameDay, "12-hour", now), formatDayAwareTimestamp(earlierInWeek, "12-hour", now), - ]).toEqual(["11:45 AM", "11/5 3:00 AM"]); + ]).toEqual([ + timeIn(sameDay, "Asia/Bangkok"), + `${dayIn(earlierInWeek, "Asia/Bangkok")} ${timeIn(earlierInWeek, "Asia/Bangkok")}`, + ]); }); it("renders the tooltip in the new zone", () => { const monthEnd = "2026-10-31T20:00:00.000Z"; using _zone = useTimeZone("America/New_York"); - expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe("4:00 PM, 31st October 2026"); + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe( + `${timeIn(monthEnd, "America/New_York")}, 31st October 2026`, + ); process.env.TZ = "Asia/Bangkok"; - expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe("3:00 AM, 1st November 2026"); + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe( + `${timeIn(monthEnd, "Asia/Bangkok")}, 1st November 2026`, + ); }); }); From 982a6a5add9b74509441ba3c5e1ae5717c1e7b5d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 07:14:59 +0000 Subject: [PATCH 4/4] fix(mobile): message time follows the zone Intl reports, not Hermes's cached offset --- .../src/features/threads/ThreadFeed.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fc4eaf225378f..b7e899cdd0bba 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,21 +294,23 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -// A formatter keeps the default zone it was built in, so it runs in UTC on the wall-clock -// time, which Date reads from the current zone. -const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - timeZone: "UTC", -}); +// A formatter pins the default zone when built, and Hermes keeps Date's local offset until a cold +// start (react/react-native#59131), so rebuild when Intl reports a different zone. +let messageTimeFormatter: Intl.DateTimeFormat | null = null; function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } - return MESSAGE_TIME_FORMATTER.format( - timestamp - new Date(timestamp).getTimezoneOffset() * 60_000, - ); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (messageTimeFormatter?.resolvedOptions().timeZone !== timeZone) { + messageTimeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone, + }); + } + return messageTimeFormatter.format(timestamp); } const TURN_FOLD_HEIGHT = 42;