/** Formatting helpers — bytes, thin-space grouped numbers, ISO UTC timestamps. */ const THIN = " "; // narrow no-break space export function fmtInt(n: number | null | undefined): string { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; const v = Math.round(Number(n)); const neg = v < 0; const s = Math.abs(v).toString(); const parts: string[] = []; for (let i = s.length; i > 0; i -= 3) parts.unshift(s.slice(Math.max(0, i - 3), i)); return (neg ? "-" : "") + parts.join(THIN); } export function fmtNum(n: number | null | undefined, digits = 2): string { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; const v = Number(n); if (Number.isInteger(v) && digits === 0) return fmtInt(v); const [i, f] = v.toFixed(digits).split("."); return fmtInt(Number(i)) + (f ? "." + f : ""); } /** Compact 1 284 / 12.9K / 4.2M / 1.3B */ export function fmtCompact(n: number | null | undefined): string { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; const v = Number(n); const a = Math.abs(v); if (a < 10_000) return fmtInt(v); if (a < 1e6) return (v / 1e3).toFixed(1).replace(/\.0$/, "") + "K"; if (a < 1e9) return (v / 1e6).toFixed(1).replace(/\.0$/, "") + "M"; if (a < 1e12) return (v / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B"; return (v / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T"; } export function fmtBytes(b: number | null | undefined, digits = 1): string { if (b === null || b === undefined || Number.isNaN(Number(b))) return "—"; const v = Number(b); if (v < 1e3) return `${fmtInt(v)} B`; if (v < 1e6) return `${(v / 1e3).toFixed(digits)} kB`; if (v < 1e9) return `${(v / 1e6).toFixed(digits)} MB`; if (v < 1e12) return `${(v / 1e9).toFixed(digits)} GB`; return `${(v / 1e12).toFixed(digits)} TB`; } export function fmtGB(gb: number | null | undefined, digits = 1): string { if (gb === null || gb === undefined) return "—"; return `${Number(gb).toFixed(digits)} GB`; } export function fmtPct(x: number | null | undefined, digits = 1): string { if (x === null || x === undefined || Number.isNaN(Number(x))) return "—"; return `${(Number(x) * 100).toFixed(digits)} %`; } const pad = (n: number) => String(n).padStart(2, "0"); /** ISO UTC → `YYYY-MM-DD HH:mm:ssZ` */ export function fmtTs(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`; } /** Short time-of-day `HH:mm` (UTC) */ export function fmtTime(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } export function fmtDate(iso: string | null | undefined): string { if (!iso) return "—"; return String(iso).slice(0, 10); } export function fmtRelative(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso).getTime(); if (Number.isNaN(d)) return String(iso); const s = Math.round((Date.now() - d) / 1000); const abs = Math.abs(s); const suffix = s >= 0 ? "ago" : "from now"; if (abs < 60) return `${abs} s ${suffix}`; if (abs < 3600) return `${Math.round(abs / 60)} min ${suffix}`; if (abs < 86400) return `${(abs / 3600).toFixed(1)} h ${suffix}`; return `${Math.round(abs / 86400)} d ${suffix}`; } export function fmtDuration(startIso: string | null | undefined, endIso: string | null | undefined): string { if (!startIso) return "—"; const a = new Date(startIso).getTime(); const b = endIso ? new Date(endIso).getTime() : Date.now(); const s = Math.max(0, Math.round((b - a) / 1000)); if (s < 60) return `${s} s`; if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60} s`; return `${(s / 3600).toFixed(1)} h`; } export function isoDate(d: Date): string { return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; } export function daysAgo(n: number): string { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); return isoDate(d); } export function today(): string { return isoDate(new Date()); } /** datetime-local value (local tz) for `now` */ export function nowLocalInput(): string { const d = new Date(); const off = d.getTimezoneOffset(); const local = new Date(d.getTime() - off * 60_000); return local.toISOString().slice(0, 16); } export function toCsv(columns: string[], rows: Record[]): string { const esc = (v: unknown) => { if (v === null || v === undefined) return ""; const s = typeof v === "object" ? JSON.stringify(v) : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const lines = [columns.map(esc).join(",")]; for (const r of rows) lines.push(columns.map((c) => esc(r[c])).join(",")); return lines.join("\n"); } export function downloadText(filename: string, text: string, mime = "text/csv;charset=utf-8") { const blob = new Blob([text], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } export function splitList(text: string): string[] { return Array.from(new Set(text.split(/[\s,;]+/).map((s) => s.trim().toUpperCase()).filter(Boolean))); }