1/** Formatting helpers — bytes, thin-space grouped numbers, ISO UTC timestamps. */23const THIN = " "; // narrow no-break space45export function fmtInt(n: number | null | undefined): string {6 if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";7 const v = Math.round(Number(n));8 const neg = v < 0;9 const s = Math.abs(v).toString();10 const parts: string[] = [];11 for (let i = s.length; i > 0; i -= 3) parts.unshift(s.slice(Math.max(0, i - 3), i));12 return (neg ? "-" : "") + parts.join(THIN);13}1415export function fmtNum(n: number | null | undefined, digits = 2): string {16 if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";17 const v = Number(n);18 if (Number.isInteger(v) && digits === 0) return fmtInt(v);19 const [i, f] = v.toFixed(digits).split(".");20 return fmtInt(Number(i)) + (f ? "." + f : "");21}2223/** Compact 1 284 / 12.9K / 4.2M / 1.3B */24export function fmtCompact(n: number | null | undefined): string {25 if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";26 const v = Number(n);27 const a = Math.abs(v);28 if (a < 10_000) return fmtInt(v);29 if (a < 1e6) return (v / 1e3).toFixed(1).replace(/\.0$/, "") + "K";30 if (a < 1e9) return (v / 1e6).toFixed(1).replace(/\.0$/, "") + "M";31 if (a < 1e12) return (v / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B";32 return (v / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T";33}3435export function fmtBytes(b: number | null | undefined, digits = 1): string {36 if (b === null || b === undefined || Number.isNaN(Number(b))) return "—";37 const v = Number(b);38 if (v < 1e3) return `${fmtInt(v)} B`;39 if (v < 1e6) return `${(v / 1e3).toFixed(digits)} kB`;40 if (v < 1e9) return `${(v / 1e6).toFixed(digits)} MB`;41 if (v < 1e12) return `${(v / 1e9).toFixed(digits)} GB`;42 return `${(v / 1e12).toFixed(digits)} TB`;43}4445export function fmtGB(gb: number | null | undefined, digits = 1): string {46 if (gb === null || gb === undefined) return "—";47 return `${Number(gb).toFixed(digits)} GB`;48}4950export function fmtPct(x: number | null | undefined, digits = 1): string {51 if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";52 return `${(Number(x) * 100).toFixed(digits)} %`;53}5455const pad = (n: number) => String(n).padStart(2, "0");5657/** ISO UTC → `YYYY-MM-DD HH:mm:ssZ` */58export function fmtTs(iso: string | null | undefined): string {59 if (!iso) return "—";60 const d = new Date(iso);61 if (Number.isNaN(d.getTime())) return String(iso);62 return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;63}6465/** Short time-of-day `HH:mm` (UTC) */66export function fmtTime(iso: string | null | undefined): string {67 if (!iso) return "—";68 const d = new Date(iso);69 if (Number.isNaN(d.getTime())) return String(iso);70 return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;71}7273export function fmtDate(iso: string | null | undefined): string {74 if (!iso) return "—";75 return String(iso).slice(0, 10);76}7778export function fmtRelative(iso: string | null | undefined): string {79 if (!iso) return "—";80 const d = new Date(iso).getTime();81 if (Number.isNaN(d)) return String(iso);82 const s = Math.round((Date.now() - d) / 1000);83 const abs = Math.abs(s);84 const suffix = s >= 0 ? "ago" : "from now";85 if (abs < 60) return `${abs} s ${suffix}`;86 if (abs < 3600) return `${Math.round(abs / 60)} min ${suffix}`;87 if (abs < 86400) return `${(abs / 3600).toFixed(1)} h ${suffix}`;88 return `${Math.round(abs / 86400)} d ${suffix}`;89}9091export function fmtDuration(startIso: string | null | undefined, endIso: string | null | undefined): string {92 if (!startIso) return "—";93 const a = new Date(startIso).getTime();94 const b = endIso ? new Date(endIso).getTime() : Date.now();95 const s = Math.max(0, Math.round((b - a) / 1000));96 if (s < 60) return `${s} s`;97 if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60} s`;98 return `${(s / 3600).toFixed(1)} h`;99}100101export function isoDate(d: Date): string {102 return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;103}104105export function daysAgo(n: number): string {106 const d = new Date();107 d.setUTCDate(d.getUTCDate() - n);108 return isoDate(d);109}110111export function today(): string {112 return isoDate(new Date());113}114115/** datetime-local value (local tz) for `now` */116export function nowLocalInput(): string {117 const d = new Date();118 const off = d.getTimezoneOffset();119 const local = new Date(d.getTime() - off * 60_000);120 return local.toISOString().slice(0, 16);121}122123export function toCsv(columns: string[], rows: Record<string, unknown>[]): string {124 const esc = (v: unknown) => {125 if (v === null || v === undefined) return "";126 const s = typeof v === "object" ? JSON.stringify(v) : String(v);127 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;128 };129 const lines = [columns.map(esc).join(",")];130 for (const r of rows) lines.push(columns.map((c) => esc(r[c])).join(","));131 return lines.join("\n");132}133134export function downloadText(filename: string, text: string, mime = "text/csv;charset=utf-8") {135 const blob = new Blob([text], { type: mime });136 const url = URL.createObjectURL(blob);137 const a = document.createElement("a");138 a.href = url;139 a.download = filename;140 document.body.appendChild(a);141 a.click();142 a.remove();143 setTimeout(() => URL.revokeObjectURL(url), 1000);144}145146export function splitList(text: string): string[] {147 return Array.from(new Set(text.split(/[\s,;]+/).map((s) => s.trim().toUpperCase()).filter(Boolean)));148}149