diff --git a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx index 03b6daaa4e715..6a283f4ca230d 100644 --- a/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx +++ b/apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx @@ -21,13 +21,11 @@ import { useManagedRelayEnvironments, } from "./managedRelayState"; -const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); - function linkedAtLabel(value: string): string { const linkedAt = new Date(value); return Number.isNaN(linkedAt.getTime()) ? "Link date unavailable" - : `Linked ${linkedAtFormatter.format(linkedAt)}`; + : `Linked ${new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(linkedAt)}`; } function endpointLabel(environment: RelayClientEnvironmentRecord): string { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d6fdc9e955cde..c68e75b65e633 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -294,16 +294,22 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; -const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", -}); +// A formatter pins the zone when built, so key it on the live zone. Probing costs one +// resolvedOptions() per rendered message; accepted for correctness across zone switches. +const MESSAGE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" }; +const messageTimeFormatters = new Map(); function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { return ""; } - return MESSAGE_TIME_FORMATTER.format(timestamp); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + let formatter = messageTimeFormatters.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat(undefined, { ...MESSAGE_TIME_OPTIONS, timeZone }); + messageTimeFormatters.set(timeZone, formatter); + } + return formatter.format(timestamp); } const TURN_FOLD_HEIGHT = 42; diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5b5e11d812027..ab20f88710285 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat"; +import { + formatChatTimestampTooltip, + formatDayAwareTimestamp, + formatShortTimestamp, + formatUpcomingTimestamp, +} from "./timestampFormat"; 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 +24,66 @@ describe("timestamps in an explicit time zone", () => { ); }); }); + +describe("timestamps in the system time zone", () => { + // Expectations come from a fresh formatter pinned to each zone, so the tests pass under any host + // locale ("11:45 PM" vs "11:45 pm", "11/6" vs "06/11") while still proving the cached formatter + // tracks the live zone. + const expectedTime = (instant: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone, + }).format(new Date(instant)); + const expectedNumericDate = (instant: string, timeZone: string) => + new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric", timeZone }).format( + new Date(instant), + ); + + it("follows a system time zone change without a reload", () => { + const instant = "2026-11-07T04:45:43.877Z"; + const now = Date.parse("2026-11-20T12:00:00.000Z"); + const originalTimeZone = process.env.TZ; + try { + for (const zone of ["America/New_York", "Asia/Bangkok"]) { + process.env.TZ = zone; + const time = expectedTime(instant, zone); + expect(formatShortTimestamp(instant, "12-hour")).toBe(time); + expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe( + `${expectedNumericDate(instant, zone)} ${time}`, + ); + } + // Guard the oracle: the zones must disagree on both the time and the date. + expect(expectedTime(instant, "America/New_York")).not.toBe( + expectedTime(instant, "Asia/Bangkok"), + ); + expect(expectedNumericDate(instant, "America/New_York")).not.toBe( + expectedNumericDate(instant, "Asia/Bangkok"), + ); + } finally { + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; + } + }); + + it("names the month in the current system time zone", () => { + // Oct 31 in New York, Nov 1 in Bangkok. + const instant = "2026-10-31T23:45:00.000Z"; + const originalTimeZone = process.env.TZ; + try { + process.env.TZ = "America/New_York"; + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe( + `${expectedTime(instant, "America/New_York")}, 31st October 2026`, + ); + + process.env.TZ = "Asia/Bangkok"; + expect(formatChatTimestampTooltip(instant, "12-hour")).toBe( + `${expectedTime(instant, "Asia/Bangkok")}, 1st November 2026`, + ); + } finally { + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; + } + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index abad91401ccbe..8ffd732457775 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -68,29 +68,49 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin export const weekStartsOn = resolveWeekStartsOn(timestampLocale); -const timestampFormatterCache = new Map(); +const formatterCache = new Map(); -function getTimestampFormatter( - timestampFormat: TimestampFormat, - includeSeconds: boolean, - timeZone?: string, +// A formatter pins the default zone when built, so key on the current one to follow OS zone changes. +// The probe builds a formatter, so public entry points take it once and pass it down. Reusing it +// while getTimezoneOffset() is unchanged would miss switches between same-offset zones. +function currentTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function getCachedFormatter( + name: string, + options: Intl.DateTimeFormatOptions, + timeZone: string, + locale: string | undefined = timestampLocale, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`; - const cachedFormatter = timestampFormatterCache.get(cacheKey); + const cacheKey = `${name}:${timeZone}`; + const cachedFormatter = formatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } - const formatter = new Intl.DateTimeFormat(timestampLocale, { - ...getTimestampFormatOptions(timestampFormat, includeSeconds), - ...(timeZone ? { timeZone } : {}), - }); - timestampFormatterCache.set(cacheKey, formatter); + const formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone }); + formatterCache.set(cacheKey, formatter); return formatter; } +function getTimestampFormatter( + timestampFormat: TimestampFormat, + includeSeconds: boolean, + timeZone: string, +): Intl.DateTimeFormat { + return getCachedFormatter( + `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`, + getTimestampFormatOptions(timestampFormat, includeSeconds), + timeZone, + ); +} + export function resolveHour12(timestampFormat: TimestampFormat): boolean { - return getTimestampFormatter(timestampFormat, false).resolvedOptions().hour12 ?? false; + return ( + getTimestampFormatter(timestampFormat, false, currentTimeZone()).resolvedOptions().hour12 ?? + false + ); } export function parseTimestampDate(isoDate: string): Date | null { @@ -101,7 +121,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 monthNameOptions: Intl.DateTimeFormatOptions = { month: "long" }; function ordinalSuffix(day: number): string { const lastTwo = day % 100; @@ -124,9 +144,10 @@ export function formatChatTimestampTooltip( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = formatShortTimestamp(isoDate, timestampFormat); + const timeZone = currentTimeZone(); + const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); const day = date.getDate(); - const month = monthNameFormatter.format(date); + const month = getCachedFormatter("monthName", monthNameOptions, timeZone, "en-US").format(date); const year = date.getFullYear(); return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`; } @@ -134,7 +155,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 getTimestampFormatter(timestampFormat, false, currentTimeZone()).format(date); } const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" }; @@ -142,11 +163,6 @@ const numericDateWithYearOptions: Intl.DateTimeFormatOptions = { ...numericDateOptions, year: "numeric", }; -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions); -const numericDateWithYearFormatter = new Intl.DateTimeFormat( - timestampLocale, - numericDateWithYearOptions, -); interface CalendarDay { year: number; @@ -171,25 +187,23 @@ function calendarDay(date: Date, timeZone: string | undefined): CalendarDay { return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 }; } -function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string { - if (!timeZone) { - return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date); - } - return new Intl.DateTimeFormat(timestampLocale, { - ...(sameYear ? numericDateOptions : numericDateWithYearOptions), - timeZone, - }).format(date); +function formatNumericDate(date: Date, sameYear: boolean, timeZone: string): string { + return ( + sameYear + ? getCachedFormatter("numericDate", numericDateOptions, timeZone) + : getCachedFormatter("numericDateWithYear", numericDateWithYearOptions, timeZone) + ).format(date); } -export function formatDayAwareTimestamp( - isoDate: string, +/** `timeZone` set: calendar days come from that zone; unset: from the system zone `formatZone` names. */ +function formatPastTimestamp( + date: Date, timestampFormat: TimestampFormat, - nowMs: number = Date.now(), - timeZone?: string, + nowMs: number, + timeZone: string | undefined, + formatZone: string, ): string { - const date = parseTimestampDate(isoDate); - if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const time = getTimestampFormatter(timestampFormat, false, formatZone).format(date); const today = calendarDay(new Date(nowMs), timeZone); const messageDay = calendarDay(date, timeZone); @@ -197,10 +211,21 @@ export function formatDayAwareTimestamp( if (dayDiff <= 0) return time; if (dayDiff === 1) return `yesterday at ${time}`; - return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`; + return `${formatNumericDate(date, messageDay.year === today.year, formatZone)} ${time}`; } -/** `timeZone` overrides the zone the formatters cached when they were built. */ +export function formatDayAwareTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + nowMs: number = Date.now(), + timeZone?: string, +): string { + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return formatPastTimestamp(date, timestampFormat, nowMs, timeZone, timeZone ?? currentTimeZone()); +} + +/** `timeZone` overrides the system time zone. */ export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, @@ -209,16 +234,17 @@ export function formatUpcomingTimestamp( ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date); + const formatZone = timeZone ?? currentTimeZone(); const today = calendarDay(new Date(nowMs), timeZone); const targetDay = calendarDay(date, timeZone); const dayDiff = targetDay.index - today.index; - if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone); + if (dayDiff < 0) return formatPastTimestamp(date, timestampFormat, nowMs, timeZone, formatZone); + const time = getTimestampFormatter(timestampFormat, false, formatZone).format(date); if (dayDiff === 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; - return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`; + return `${formatNumericDate(date, targetDay.year === today.year, formatZone)} ${time}`; } type RelativeTimeParts = { value: string; suffix: string | null };