Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
4 changes: 1 addition & 3 deletions apps/mobile/src/features/cloud/T3ConnectProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 11 additions & 5 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Intl.DateTimeFormat>();
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;
Expand Down
70 changes: 69 additions & 1 deletion apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
}
});
});
110 changes: 68 additions & 42 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,29 +68,49 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin

export const weekStartsOn = resolveWeekStartsOn(timestampLocale);

const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
const formatterCache = new Map<string, Intl.DateTimeFormat>();

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 {
Expand All @@ -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;
Expand All @@ -124,29 +144,25 @@ 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}`;
}

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" };
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;
Expand All @@ -171,36 +187,45 @@ 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);
const dayDiff = today.index - messageDay.index;

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