/** * Formatting helpers. API timestamps are UTC ISO strings; absolute dates render in UTC (identical on server and client), * relative times are rendered client-side after mount (`components/ui/live.tsx`). Missing values → em dash, never a fake number. */ import type { Num } from './types'; const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); const nfCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); export const DASH = '—'; export function num(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : null; } export function fmtInt(v: Num | undefined | unknown): string { const n = num(v); return n === null ? DASH : nf0.format(n); } export function fmt1(v: Num | undefined | unknown): string { const n = num(v); return n === null ? DASH : nf1.format(n); } export function fmt2(v: Num | undefined | unknown): string { const n = num(v); return n === null ? DASH : nf2.format(n); } export function fmtCompact(v: Num | undefined | unknown): string { const n = num(v); return n === null ? DASH : nfCompact.format(n); } /** 0–100 score with one decimal (API rounds to 1 decimal). */ export function fmtScore(v: Num | undefined | unknown): string { const n = num(v); return n === null ? DASH : nf1.format(n); } /** Percentage from a 0–1 ratio or a 0–100 value (`ratio` flag). */ export function fmtPct(v: Num | undefined | unknown, digits = 1, ratio = false): string { const n = num(v); if (n === null) return DASH; return `${(ratio ? n * 100 : n).toFixed(digits)} %`; } /** Signed percentage: +12.3 % / −4.0 % / 0.0 %. */ export function fmtPctSigned(v: Num | undefined | unknown, digits = 1): string { const n = num(v); if (n === null) return DASH; const sign = n > 0 ? '+' : n < 0 ? '−' : ''; return `${sign}${Math.abs(n).toFixed(digits)} %`; } export function fmtSigned(v: Num | undefined | unknown, digits = 0): string { const n = num(v); if (n === null) return DASH; const f = digits ? Math.abs(n).toFixed(digits) : nf0.format(Math.abs(n)); return n > 0 ? `+${f}` : n < 0 ? `−${f}` : digits ? (0).toFixed(digits) : '0'; } export function fmtBytes(v: Num | undefined | unknown): string { const n = num(v); if (n === null) return DASH; if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TB`; if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`; if (n >= 1e6) return `${(n / 1e6).toFixed(0)} MB`; if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`; return `${n} B`; } export function fmtUsd(v: Num | undefined | unknown, digits = 2): string { const n = num(v); return n === null ? DASH : `$${n.toFixed(digits)}`; } /** * Compact currency for stated financial facts: 130.5B USD → "$130.5B", 34.2B EUR → "€34.2B", 48T JPY → "¥48.0T". * Never converts between currencies; unknown ISO codes fall back to " ". */ export function fmtMoney(value: Num | undefined | unknown, currency: string | null | undefined): string { const n = num(value); if (n === null) return DASH; const code = (currency ?? '').toUpperCase(); if (code) { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: code, notation: 'compact', maximumFractionDigits: 1, currencyDisplay: 'narrowSymbol' }).format(n); } catch { /* invalid code */ } } return code ? `${nfCompact.format(n)} ${code}` : nfCompact.format(n); } export function fmtPrice(price: Num | undefined, currency: string | null | undefined, text?: string | null): string { const n = num(price); if (n === null) return text ?? DASH; const cur = (currency ?? 'USD').toUpperCase(); const sym = cur === 'USD' ? '$' : cur === 'EUR' ? '€' : cur === 'GBP' ? '£' : `${cur} `; return `${sym}${Number.isInteger(n) ? nf0.format(n) : nf2.format(n)}`; } /** ISO date/timestamp → "11 Sept 2026" (UTC). */ export function fmtDate(v: string | null | undefined): string { if (!v) return DASH; const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v); if (Number.isNaN(d.getTime())) return v; return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } export function fmtDateShort(v: string | null | undefined): string { if (!v) return DASH; const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v); if (Number.isNaN(d.getTime())) return v; return d.toLocaleDateString('en-GB', { month: 'short', day: 'numeric', timeZone: 'UTC' }); } export function fmtDateTime(v: string | null | undefined): string { if (!v) return DASH; const d = new Date(v); if (Number.isNaN(d.getTime())) return DASH; return `${d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })} ${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`; } export function fmtTime(v: string | null | undefined): string { if (!v) return DASH; const d = new Date(v); if (Number.isNaN(d.getTime())) return DASH; return `${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' })}`; } /** Day label for timeline groups: "Today", "Yesterday", else the date (UTC). */ export function fmtDayLabel(day: string, now: number = Date.now()): string { const today = new Date(now).toISOString().slice(0, 10); const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10); if (day === today) return 'Today'; if (day === yesterday) return 'Yesterday'; return fmtDate(day); } /** "17 sec ago" · "3 min ago" · "2 h ago" · "5 d ago" — precise at the second for the live feed. */ export function fmtAgo(v: string | null | undefined, now: number = Date.now()): string { if (!v) return DASH; const t = new Date(v).getTime(); if (Number.isNaN(t)) return DASH; const s = Math.max(0, Math.round((now - t) / 1000)); if (s < 5) return 'just now'; if (s < 60) return `${s} sec 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`; const d = Math.floor(h / 24); if (d < 60) return `${d} d ago`; return fmtDate(v); } export function fmtDuration(seconds: Num | undefined | unknown): string { const n = num(seconds); if (n === null) return DASH; if (n < 60) return `${Math.round(n)} s`; if (n < 3600) return `${Math.round(n / 60)} min`; if (n < 86400) return `${(n / 3600).toFixed(n < 7200 ? 1 : 0)} h`; return `${Math.round(n / 86400)} d`; } export function fmtDays(days: Num | undefined | unknown): string { const n = num(days); if (n === null) return DASH; if (n < 1) return '< 1 day'; if (n < 60) return `${Math.round(n)} ${plural(Math.round(n), 'day')}`; if (n < 730) return `${(n / 30.44).toFixed(0)} months`; return `${(n / 365.25).toFixed(1)} years`; } export function plural(n: number, one: string, many = `${one}s`): string { return n === 1 ? one : many; } export function hostOf(url: string | null | undefined): string | null { if (!url) return null; try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return null; } } export function pathOf(url: string | null | undefined): string { if (!url) return DASH; try { const u = new URL(url); return `${u.hostname.replace(/^www\./, '')}${u.pathname === '/' ? '' : u.pathname}`; } catch { return url; } } /** Constant → words: PRODUCT_LAUNCH → "Product launch"; job_count_increase → "Job count increase". */ export function humanize(s: string | null | undefined): string { if (!s) return DASH; const w = s.replace(/[_-]+/g, ' ').trim().toLowerCase(); return w.charAt(0).toUpperCase() + w.slice(1); } export function titleCase(s: string | null | undefined): string { if (!s) return DASH; return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } export function truncate(s: string | null | undefined, n = 140): string { if (!s) return ''; return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s; } /** Significance 0–1 → band per spec §20. */ export function significanceBand(v: Num | undefined | unknown): 'noise' | 'minor' | 'meaningful' | 'major' | 'critical' | null { const n = num(v); if (n === null) return null; if (n < 0.2) return 'noise'; if (n < 0.4) return 'minor'; if (n < 0.65) return 'meaningful'; if (n < 0.85) return 'major'; return 'critical'; } /** Importance 0–1 (or 0–100) → 0..3 steps for the meter. */ export function importanceSteps(v: Num | undefined | unknown): 0 | 1 | 2 | 3 { const n = num(v); if (n === null) return 0; const x = n > 1 ? n / 100 : n; if (x >= 0.8) return 3; if (x >= 0.55) return 2; if (x >= 0.3) return 1; return 0; } export function toneOf(v: Num | undefined | unknown): 'positive' | 'negative' | 'neutral' { const n = num(v); if (n === null || n === 0) return 'neutral'; return n > 0 ? 'positive' : 'negative'; }