import { type TimestampFormat } from "@t3tools/contracts/settings"; export function getTimestampFormatOptions( timestampFormat: TimestampFormat, includeSeconds: boolean,): Intl.DateTimeFormatOptions { const baseOptions: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit", ...(includeSeconds ? { second: "2-digit" } : {}), }; if (timestampFormat === "locale") { return baseOptions; } return { ...baseOptions, hour12: timestampFormat === "12-hour", };} /** * Pick the locale to format wall-clock times in, given the locale the host * reports. Hosts that report nothing fall back to `undefined`, which is the * runtime default and the right answer in a browser. * * A host reports a locale only when it knows better than the runtime does — * see `getSystemLocale` on the desktop bridge for why desktop does. */export function resolveTimestampLocale( systemLocale: string | null | undefined,): string | undefined { const tag = systemLocale?.trim(); if (!tag) return undefined; try { // Every timestamp in the UI runs through this formatter, so a tag the host // could not normalize falls back rather than throwing. Throws on a // structurally invalid tag; a well-formed tag ICU has no data for resolves // here and is left to ICU's own fallback. Intl.DateTimeFormat.supportedLocalesOf([tag]); return tag; } catch { return undefined; }} function readHostSystemLocale(): string | null { if (typeof window === "undefined") return null; return window.desktopBridge?.getSystemLocale?.() ?? null;} const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean,): Intl.DateTimeFormat { const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } const formatter = new Intl.DateTimeFormat( timestampLocale, getTimestampFormatOptions(timestampFormat, includeSeconds), ); timestampFormatterCache.set(cacheKey, formatter); return formatter;} export function parseTimestampDate(isoDate: string): Date | null { const date = new Date(isoDate); return Number.isNaN(date.getTime()) ? null : date;} export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { const date = parseTimestampDate(isoDate); if (!date) return ""; return getTimestampFormatter(timestampFormat, true).format(date);} // 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". Localizing the whole label is a separate change.const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; if (lastTwo >= 11 && lastTwo <= 13) return "th"; switch (day % 10) { case 1: return "st"; case 2: return "nd"; case 3: return "rd"; default: return "th"; }} /** * Long-form tooltip label, e.g. `12:04, 4th June`. * Renders the wall-clock time without seconds followed by the ordinal day and month name. */export function formatChatTimestampTooltip( isoDate: string, timestampFormat: TimestampFormat,): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const time = formatShortTimestamp(isoDate, timestampFormat); const day = date.getDate(); const month = monthNameFormatter.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);} const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { month: "numeric", day: "numeric",});const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { month: "numeric", day: "numeric", year: "numeric",}); /** * Chat timestamp that adds the date once the message is no longer from today: * today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM` * (locale digit order), with the year included once the calendar year differs. * Boundaries are local calendar days, not 24-hour windows. */export function formatDayAwareTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(),): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const time = getTimestampFormatter(timestampFormat, false).format(date); const now = new Date(nowMs); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); // Round so DST-shifted 23/25 hour days still count as whole days. const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); if (dayDiff <= 0) return time; if (dayDiff === 1) return `yesterday at ${time}`; const dateFormatter = date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; return `${dateFormatter.format(date)} ${time}`;} /** * The forward-looking counterpart of {@link formatDayAwareTimestamp} for an * instant that has not happened yet (a usage-limit reset): today `12:34 PM`, * tomorrow `tomorrow at 12:34 PM`, later `8/13 12:34 PM`. */export function formatUpcomingTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(),): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const time = getTimestampFormatter(timestampFormat, false).format(date); const now = new Date(nowMs); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000); if (dayDiff <= 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; const dateFormatter = date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; return `${dateFormatter.format(date)} ${time}`;} /** * Format a relative time string from an ISO date. * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` * so callers can style the numeric portion independently. */type RelativeTimeParts = { value: string; suffix: string | null };export type RelativeTimeState = | { status: "missing" } | { status: "invalid" } | { status: "relative"; value: string; suffix: string | null }; export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); if (!date) return null; const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; const minutes = Math.floor(seconds / 60); if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; const hours = Math.floor(minutes / 60); if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; const days = Math.floor(hours / 24); return { value: `${days}d`, suffix: "ago" };} export function formatRelativeTimeLabel(isoDate: string) { const relative = formatRelativeTime(isoDate); if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;} export function getRelativeTimeState(isoDate: string | null): RelativeTimeState { if (!isoDate) return { status: "missing" }; const relative = formatRelativeTime(isoDate); if (!relative) return { status: "invalid" }; return { status: "relative", ...relative };} /** * Relative elapsed duration since an ISO instant, without an "ago" suffix. * Useful for labels like "Connected for 3m". */export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const diffMs = nowMs - date.getTime(); if (diffMs <= 0) return "just now"; const seconds = Math.floor(diffMs / 1000); if (seconds < 5) return "just now"; if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); return `${days}d`;} /** * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. */export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { const date = parseTimestampDate(isoDate); if (!date) return null; const diffMs = date.getTime() - Date.now(); if (diffMs <= 0) return { value: "Expired", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 5) return { value: "Soon", suffix: null }; if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; const minutes = Math.floor(seconds / 60); if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; const hours = Math.floor(minutes / 60); if (hours < 24) return { value: `${hours}h`, suffix: "left" }; const days = Math.floor(hours / 24); return { value: `${days}d`, suffix: "left" };} export function formatRelativeTimeUntilLabel(isoDate: string): string { const relative = formatRelativeTimeUntil(isoDate); if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;} /** * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. */export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const diffMs = date.getTime() - nowMs; if (diffMs <= 0) return "Expired"; const totalSeconds = Math.floor(diffMs / 1000); if (totalSeconds < 5) return "Expires in a moment"; if (totalSeconds < 60) return `Expires in ${totalSeconds}s`; if (totalSeconds < 3600) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`; } if (totalSeconds < 86_400) { const hours = Math.floor(totalSeconds / 3600); const rem = totalSeconds % 3600; const minutes = Math.floor(rem / 60); const seconds = rem % 60; const parts = [`${hours}h`]; if (minutes > 0) parts.push(`${minutes}m`); if (seconds > 0) parts.push(`${seconds}s`); return `Expires in ${parts.join(" ")}`; } const days = Math.floor(totalSeconds / 86_400); const remAfterDays = totalSeconds % 86_400; if (remAfterDays === 0) return `Expires in ${days}d`; const hours = Math.floor(remAfterDays / 3600); const rem = remAfterDays % 3600; const minutes = Math.floor(rem / 60); const seconds = rem % 60; const tail: string[] = []; if (hours > 0) tail.push(`${hours}h`); if (minutes > 0) tail.push(`${minutes}m`); if (seconds > 0) tail.push(`${seconds}s`); return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`;}
import { type TimestampFormat } from "@t3tools/contracts/settings";
export function getTimestampFormatOptions(
timestampFormat: TimestampFormat,
includeSeconds: boolean,
): Intl.DateTimeFormatOptions {
const baseOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
...(includeSeconds ? { second: "2-digit" } : {}),
};
if (timestampFormat === "locale") {
return baseOptions;
}
return {
...baseOptions,
hour12: timestampFormat === "12-hour",
};
}
/**
* Pick the locale to format wall-clock times in, given the locale the host
* reports. Hosts that report nothing fall back to `undefined`, which is the
* runtime default and the right answer in a browser.
*
* A host reports a locale only when it knows better than the runtime does —
* see `getSystemLocale` on the desktop bridge for why desktop does.
*/
export function resolveTimestampLocale(
systemLocale: string | null | undefined,
): string | undefined {
const tag = systemLocale?.trim();
if (!tag) return undefined;
try {
// Every timestamp in the UI runs through this formatter, so a tag the host
// could not normalize falls back rather than throwing. Throws on a
// structurally invalid tag; a well-formed tag ICU has no data for resolves
// here and is left to ICU's own fallback.
Intl.DateTimeFormat.supportedLocalesOf([tag]);
return tag;
} catch {
return undefined;
}
}
function readHostSystemLocale(): string | null {
if (typeof window === "undefined") return null;
return window.desktopBridge?.getSystemLocale?.() ?? null;
}
const timestampLocale = resolveTimestampLocale(readHostSystemLocale());
const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getTimestampFormatter(
timestampFormat: TimestampFormat,
includeSeconds: boolean,
): Intl.DateTimeFormat {
const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`;
const cachedFormatter = timestampFormatterCache.get(cacheKey);
if (cachedFormatter) {
return cachedFormatter;
}
const formatter = new Intl.DateTimeFormat(
timestampLocale,
getTimestampFormatOptions(timestampFormat, includeSeconds),
);
timestampFormatterCache.set(cacheKey, formatter);
return formatter;
}
export function parseTimestampDate(isoDate: string): Date | null {
const date = new Date(isoDate);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
return getTimestampFormatter(timestampFormat, true).format(date);
}
// 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". Localizing the whole label is a separate change.
const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
function ordinalSuffix(day: number): string {
const lastTwo = day % 100;
if (lastTwo >= 11 && lastTwo <= 13) return "th";
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
/**
* Long-form tooltip label, e.g. `12:04, 4th June`.
* Renders the wall-clock time without seconds followed by the ordinal day and month name.
*/
export function formatChatTimestampTooltip(
isoDate: string,
timestampFormat: TimestampFormat,
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = formatShortTimestamp(isoDate, timestampFormat);
const day = date.getDate();
const month = monthNameFormatter.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);
}
const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, {
month: "numeric",
day: "numeric",
});
const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, {
month: "numeric",
day: "numeric",
year: "numeric",
});
/**
* Chat timestamp that adds the date once the message is no longer from today:
* today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM`
* (locale digit order), with the year included once the calendar year differs.
* Boundaries are local calendar days, not 24-hour windows.
*/
export function formatDayAwareTimestamp(
isoDate: string,
timestampFormat: TimestampFormat,
nowMs: number = Date.now(),
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = getTimestampFormatter(timestampFormat, false).format(date);
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
// Round so DST-shifted 23/25 hour days still count as whole days.
const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000);
if (dayDiff <= 0) return time;
if (dayDiff === 1) return `yesterday at ${time}`;
const dateFormatter =
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
return `${dateFormatter.format(date)} ${time}`;
}
/**
* The forward-looking counterpart of {@link formatDayAwareTimestamp} for an
* instant that has not happened yet (a usage-limit reset): today `12:34 PM`,
* tomorrow `tomorrow at 12:34 PM`, later `8/13 12:34 PM`.
*/
export function formatUpcomingTimestamp(
isoDate: string,
timestampFormat: TimestampFormat,
nowMs: number = Date.now(),
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = getTimestampFormatter(timestampFormat, false).format(date);
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000);
if (dayDiff <= 0) return time;
if (dayDiff === 1) return `tomorrow at ${time}`;
const dateFormatter =
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
return `${dateFormatter.format(date)} ${time}`;
}
/**
* Format a relative time string from an ISO date.
* Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
* so callers can style the numeric portion independently.
*/
type RelativeTimeParts = { value: string; suffix: string | null };
export type RelativeTimeState =
| { status: "missing" }
| { status: "invalid" }
| { status: "relative"; value: string; suffix: string | null };
export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
const date = parseTimestampDate(isoDate);
if (!date) return null;
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) return { value: "just now", suffix: null };
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return { value: "just now", suffix: null };
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { value: `${hours}h`, suffix: "ago" };
const days = Math.floor(hours / 24);
return { value: `${days}d`, suffix: "ago" };
}
export function formatRelativeTimeLabel(isoDate: string) {
const relative = formatRelativeTime(isoDate);
if (!relative) return "";
return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
}
export function getRelativeTimeState(isoDate: string | null): RelativeTimeState {
if (!isoDate) return { status: "missing" };
const relative = formatRelativeTime(isoDate);
if (!relative) return { status: "invalid" };
return { status: "relative", ...relative };
}
/**
* Relative elapsed duration since an ISO instant, without an "ago" suffix.
* Useful for labels like "Connected for 3m".
*/
export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const diffMs = nowMs - date.getTime();
if (diffMs <= 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
/**
* Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times.
*/
export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null {
const date = parseTimestampDate(isoDate);
if (!date) return null;
const diffMs = date.getTime() - Date.now();
if (diffMs <= 0) return { value: "Expired", suffix: null };
const seconds = Math.floor(diffMs / 1000);
if (seconds < 5) return { value: "Soon", suffix: null };
if (seconds < 60) return { value: `${seconds}s`, suffix: "left" };
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return { value: `${minutes}m`, suffix: "left" };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { value: `${hours}h`, suffix: "left" };
const days = Math.floor(hours / 24);
return { value: `${days}d`, suffix: "left" };
}
export function formatRelativeTimeUntilLabel(isoDate: string): string {
const relative = formatRelativeTimeUntil(isoDate);
if (!relative) return "";
return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
}
/**
* Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour.
* Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot.
*/
export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const diffMs = date.getTime() - nowMs;
if (diffMs <= 0) return "Expired";
const totalSeconds = Math.floor(diffMs / 1000);
if (totalSeconds < 5) return "Expires in a moment";
if (totalSeconds < 60) return `Expires in ${totalSeconds}s`;
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`;
}
if (totalSeconds < 86_400) {
const hours = Math.floor(totalSeconds / 3600);
const rem = totalSeconds % 3600;
const minutes = Math.floor(rem / 60);
const seconds = rem % 60;
const parts = [`${hours}h`];
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0) parts.push(`${seconds}s`);
return `Expires in ${parts.join(" ")}`;
}
const days = Math.floor(totalSeconds / 86_400);
const remAfterDays = totalSeconds % 86_400;
if (remAfterDays === 0) return `Expires in ${days}d`;
const hours = Math.floor(remAfterDays / 3600);
const rem = remAfterDays % 3600;
const minutes = Math.floor(rem / 60);
const seconds = rem % 60;
const tail: string[] = [];
if (hours > 0) tail.push(`${hours}h`);
if (minutes > 0) tail.push(`${minutes}m`);
if (seconds > 0) tail.push(`${seconds}s`);
return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`;
}