diff --git a/apps/mobile/src/features/threads/messageTimestamp.test.ts b/apps/mobile/src/features/threads/messageTimestamp.test.ts index 94f6010acd4a1..05af43556fe80 100644 --- a/apps/mobile/src/features/threads/messageTimestamp.test.ts +++ b/apps/mobile/src/features/threads/messageTimestamp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { formatMessageTimestamp } from "./messageTimestamp"; @@ -19,4 +19,31 @@ describe("formatMessageTimestamp", () => { `${lastYear.toLocaleDateString(undefined, { month: "numeric", day: "numeric", year: "numeric" })} ${time(lastYear)}`, ); }); + + it("follows a system time zone change", () => { + const previous = process.env.TZ; + vi.useFakeTimers({ now: 0 }); + const sameDay = "2026-11-07T04:45:43.877Z"; + const earlierInWeek = "2026-11-04T20:00:00.000Z"; + const nowMs = Date.parse(sameDay); + const timeIn = (iso: string, timeZone: string) => + new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone }); + const dayIn = (iso: string, timeZone: string) => + new Date(iso).toLocaleDateString(undefined, { month: "numeric", day: "numeric", timeZone }); + try { + // Node re-resolves the default zone when TZ is assigned, like the OS zone changing. + process.env.TZ = "America/New_York"; + expect(formatMessageTimestamp(sameDay, nowMs)).toBe(timeIn(sameDay, "America/New_York")); + process.env.TZ = "Asia/Bangkok"; + vi.setSystemTime(Date.now() + 1_000); + expect(formatMessageTimestamp(sameDay, nowMs)).toBe(timeIn(sameDay, "Asia/Bangkok")); + expect(formatMessageTimestamp(earlierInWeek, nowMs)).toBe( + `${dayIn(earlierInWeek, "Asia/Bangkok")} ${timeIn(earlierInWeek, "Asia/Bangkok")}`, + ); + } finally { + vi.useRealTimers(); + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } + }); }); diff --git a/apps/mobile/src/features/threads/messageTimestamp.ts b/apps/mobile/src/features/threads/messageTimestamp.ts index 5624b71c0c917..82cd24dfd0b98 100644 --- a/apps/mobile/src/features/threads/messageTimestamp.ts +++ b/apps/mobile/src/features/threads/messageTimestamp.ts @@ -1,19 +1,41 @@ -const TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }); -const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric" }); -const DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat(undefined, { - month: "numeric", - day: "numeric", - year: "numeric", -}); +// Formatters pin the default zone when built, and Hermes keeps Date's local offset until a cold +// start (react/react-native#59131), so formatting and day checks follow the zone Intl reports. +function buildFormatters(timeZone: string) { + return { + timeZone, + time: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", timeZone }), + date: new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric", timeZone }), + dateWithYear: new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", + timeZone, + }), + year: new Intl.DateTimeFormat(undefined, { year: "numeric", timeZone }), + }; +} + +// Reading the zone builds a formatter (tens of µs), so feed rows share one read per second. +const ZONE_RECHECK_MS = 1_000; +let formatters: ReturnType | null = null; +let zoneCheckedAt = 0; +function currentFormatters() { + const now = Date.now(); + if (formatters && Math.abs(now - zoneCheckedAt) < ZONE_RECHECK_MS) return formatters; + zoneCheckedAt = now; + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!formatters || formatters.timeZone !== timeZone) formatters = buildFormatters(timeZone); + return formatters; +} /** Time only for today's messages; older ones lead with the day and month. */ export function formatMessageTimestamp(input: string, nowMs: number = Date.now()): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) return ""; - const date = new Date(timestamp); - const now = new Date(nowMs); - const time = TIME_FORMATTER.format(date); - if (date.toDateString() === now.toDateString()) return time; - const sameYear = date.getFullYear() === now.getFullYear(); - return `${(sameYear ? DATE_FORMATTER : DATE_WITH_YEAR_FORMATTER).format(date)} ${time}`; + const f = currentFormatters(); + const time = f.time.format(timestamp); + const dateWithYear = f.dateWithYear.format(timestamp); + if (dateWithYear === f.dateWithYear.format(nowMs)) return time; + const sameYear = f.year.format(timestamp) === f.year.format(nowMs); + return `${sameYear ? f.date.format(timestamp) : dateWithYear} ${time}`; } diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 5b5e11d812027..5f6513ab69623 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,54 @@ 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"; + const now = Date.parse(sameDay); + using _zone = useTimeZone("America/New_York"); + expect([ + formatDayAwareTimestamp(sameDay, "12-hour", now), + formatDayAwareTimestamp(earlierInWeek, "12-hour", now), + ]).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([ + 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( + `${timeIn(monthEnd, "America/New_York")}, 31st October 2026`, + ); + + process.env.TZ = "Asia/Bangkok"; + expect(formatChatTimestampTooltip(monthEnd, "12-hour")).toBe( + `${timeIn(monthEnd, "Asia/Bangkok")}, 1st November 2026`, + ); + }); +}); 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);