/** Formatting helpers. All numerals are rendered in mono/tabular; all timestamps go through formatTime(). */ export type TimeMode = 'utc' | 'local'; const nf = (digits: number) => new Intl.NumberFormat('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }); const NF0 = nf(0); const NF1 = nf(1); const NF2 = nf(2); export function fmt(v: number | null | undefined, digits = 1): string { if (v == null || Number.isNaN(v)) return '—'; return digits === 0 ? NF0.format(v) : digits === 2 ? NF2.format(v) : digits === 1 ? NF1.format(v) : nf(digits).format(v); } export const fmtInt = (v: number | null | undefined) => fmt(v, 0); /** Signed delta with a true minus sign: +6.3 / −2.1 / 0.0 */ export function fmtDelta(v: number | null | undefined, digits = 1): string { if (v == null || Number.isNaN(v)) return '—'; const s = fmt(Math.abs(v), digits); if (v > 0) return `+${s}`; if (v < 0) return `−${s}`; return s; } export function fmtPct(ratio: number | null | undefined, digits = 0): string { if (ratio == null || Number.isNaN(ratio)) return '—'; return `${fmt(ratio * 100, digits)} %`; } export function fmtMs(v: number | null | undefined, digits = 0): string { if (v == null || Number.isNaN(v)) return '—'; return `${fmt(v, digits)} ms`; } export function fmtBytes(b: number | null | undefined): string { if (b == null) return '—'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = b; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${fmt(v, i === 0 ? 0 : 1)} ${u[i]}`; } export function fmtRatio(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '—'; return `${fmt(v, v >= 10 ? 0 : 1)}×`; } export function fmtDuration(seconds: number | null | undefined): string { if (seconds == null || Number.isNaN(seconds)) return '—'; const s = Math.max(0, Math.round(seconds)); if (s < 60) return `${s} s`; const m = Math.floor(s / 60); if (m < 60) return `${m} min`; const h = Math.floor(m / 60); const rm = m % 60; if (h < 48) return rm ? `${h} h ${rm} min` : `${h} h`; const d = Math.floor(h / 24); return `${d} d ${h % 24} h`; } /** "3 s ago", "2 min ago", "4 h ago" — relative to `nowMs`. */ export function fmtAgo(ts: string | number | Date | null | undefined, nowMs = Date.now()): string { if (!ts) return '—'; const t = typeof ts === 'number' ? ts : new Date(ts).getTime(); if (Number.isNaN(t)) return '—'; const s = Math.max(0, Math.round((nowMs - t) / 1000)); if (s < 60) return `${s} s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m} min ago`; const h = Math.floor(m / 60); if (h < 48) return `${h} h ago`; return `${Math.floor(h / 24)} d ago`; } export type TimeStyle = 'time' | 'short' | 'full' | 'date' | 'month'; /** Single time formatter. UTC is the default (spec §67: UTC storage, local display with a toggle). */ export function formatTime(ts: string | number | Date | null | undefined, mode: TimeMode = 'utc', style: TimeStyle = 'short'): string { if (!ts) return '—'; const d = ts instanceof Date ? ts : new Date(ts); if (Number.isNaN(d.getTime())) return '—'; const tz = mode === 'utc' ? 'UTC' : undefined; const opts: Intl.DateTimeFormatOptions = style === 'time' ? { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz } : style === 'date' ? { year: 'numeric', month: 'short', day: '2-digit', timeZone: tz } : style === 'month' ? { year: 'numeric', month: 'long', timeZone: tz } : style === 'full' ? { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz, timeZoneName: 'short' } : { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz }; let out = new Intl.DateTimeFormat('en-GB', opts).format(d); if (mode === 'utc' && (style === 'short' || style === 'time')) out += ' UTC'; return out; } export function isoDate(ts: string | Date): string { return (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10); } export const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];