Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
}

const TURN_FOLD_HEIGHT = 42;
Expand Down
69 changes: 68 additions & 1 deletion apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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`,
);
});
});
49 changes: 33 additions & 16 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,39 @@ 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<string, Intl.DateTimeFormat>();

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;
}

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;
}
Expand All @@ -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;
Expand All @@ -124,29 +136,32 @@ 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}`;
}

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" };
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;
Expand All @@ -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),
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Loading