Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
23 changes: 18 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,29 @@ 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 default zone when built. Probing the zone builds a formatter too, so
// re-probe only when the UTC offset moves: that catches DST and almost every zone change.
// Missed: a switch between zones with the same current offset; it heals at the next offset change.
const MESSAGE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" };
let messageTimeFormatter = new Intl.DateTimeFormat(undefined, MESSAGE_TIME_OPTIONS);
let messageTimeOffset = new Date().getTimezoneOffset();
function formatMessageTime(input: string): string {
const timestamp = Date.parse(input);
if (Number.isNaN(timestamp)) {
return "";
}
return MESSAGE_TIME_FORMATTER.format(timestamp);
const offset = new Date().getTimezoneOffset();
if (offset !== messageTimeOffset) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
messageTimeOffset = offset;
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (messageTimeFormatter.resolvedOptions().timeZone !== timeZone) {
messageTimeFormatter = new Intl.DateTimeFormat(undefined, {
...MESSAGE_TIME_OPTIONS,
timeZone,
});
}
}
return messageTimeFormatter.format(timestamp);
}

const TURN_FOLD_HEIGHT = 42;
Expand Down
43 changes: 42 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,39 @@ describe("timestamps in an explicit time zone", () => {
);
});
});

describe("timestamps in the system time zone", () => {
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 {
process.env.TZ = "America/New_York";
expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 PM");
expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/6 11:45 PM");
Comment thread
kaovilai marked this conversation as resolved.
Resolved

process.env.TZ = "Asia/Bangkok";
expect(formatShortTimestamp(instant, "12-hour")).toBe("11:45 AM");
expect(formatDayAwareTimestamp(instant, "12-hour", now)).toBe("11/7 11:45 AM");
Comment thread
kaovilai marked this conversation as resolved.
Resolved
} 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("7:45 PM, 31st October 2026");

process.env.TZ = "Asia/Bangkok";
expect(formatChatTimestampTooltip(instant, "12-hour")).toBe("6:45 AM, 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