/** * @factory/ui — pure display formatters (no DOM, no external imports). * * All time-based functions accept an injectable `now`/`iso` so output is * deterministic in tests. Locale-dependent output goes through `Intl`. */ const BYTES_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const; /** * Format a byte count with 1024 steps. 0 → "0 B", values under 10 in a unit * get one decimal ("1.5 MB"), larger values are rounded to an integer. * Non-finite/negative input is clamped to 0. */ export function formatBytes(n: number): string { if (!Number.isFinite(n) || n < 0) return "0 B"; if (n < 1024) return `${Math.round(n)} B`; let value = n; let unit = 0; while (value >= 1024 && unit < BYTES_UNITS.length - 1) { value /= 1024; unit += 1; } const label = BYTES_UNITS[unit] ?? "B"; // one decimal when <10 (and non-integer): "1.5 KB"; integers stay plain: "1 KB" const text = value < 10 && !Number.isInteger(value) ? value.toFixed(1) : String(Math.round(value)); return `${text} ${label}`; } /** Locale-aware integer/decimal formatting (defaults to the runtime locale). */ export function formatNumber(n: number, locale?: string): string { return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(n); } const MINUTE_MS = 60_000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; function parseDate(iso: string): number { const t = Date.parse(iso); return Number.isNaN(t) ? 0 : t; } /** * Deterministic relative time against an injectable `now` (default: wall clock). * Buckets: <1m "just now" (never future-clamped), minutes → "5m ago", * hours → "2h ago", days → "3d ago", beyond 7 days → locale date. */ export function formatRelativeTime(iso: string, now?: number): string { const nowMs = now ?? Date.now(); const thenMs = parseDate(iso); if (thenMs <= 0) return ""; const diff = nowMs - thenMs; if (diff < MINUTE_MS) return "just now"; if (diff < HOUR_MS) return `${Math.floor(diff / MINUTE_MS)}m ago`; if (diff < DAY_MS) return `${Math.floor(diff / HOUR_MS)}h ago`; if (diff < 7 * DAY_MS) return `${Math.floor(diff / DAY_MS)}d ago`; return formatDate(iso); } /** "1.2s", "3m 04s", "1h 02m 03s" — ms → compact human duration. */ export function formatDurationMs(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return "0s"; const totalSeconds = Math.round(ms / 1000); if (totalSeconds < 60) { const seconds = ms / 1000; return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`; } const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; } return `${minutes}m ${String(seconds).padStart(2, "0")}s`; } /** * Initials for an avatar: "Ada Lovelace" → "AL". A single word takes its first * two characters uppercased ("Grace" → "GR"). Empty/whitespace → "". * At most 2 characters, non-letter/digit words are skipped. */ export function initials(name: string): string { const words = name.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return ""; if (words.length === 1) { const w = words[0] ?? ""; return w.slice(0, 2).toUpperCase(); } let out = ""; for (const w of words) { if (out.length >= 2) break; out += (w[0] ?? "").toUpperCase(); } return out; } /** Truncate to `n` visible characters with an ellipsis when longer. */ export function truncate(s: string, n: number): string { if (n < 0 || s.length <= n) return s; return `${s.slice(0, n)}…`; } /** Locale-safe date formatting; injectable locale, defaults to runtime locale. */ export function formatDate(iso: string, opts?: { locale?: string }): string { const t = parseDate(iso); if (t <= 0) return ""; return new Date(t).toLocaleDateString(opts?.locale, { year: "numeric", month: "short", day: "numeric", }); }