spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1/**2 * Formatting helpers. API timestamps are UTC ISO strings; absolute dates render in UTC (identical on server and client),3 * relative times are rendered client-side after mount (`components/ui/live.tsx`). Missing values → em dash, never a fake number.4 */5import type { Num } from './types';67const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });8const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });9const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });10const nfCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });1112export const DASH = '—';1314export function num(v: unknown): number | null {15 if (v === null || v === undefined || v === '') return null;16 const n = typeof v === 'number' ? v : Number(v);17 return Number.isFinite(n) ? n : null;18}19export function fmtInt(v: Num | undefined | unknown): string {20 const n = num(v);21 return n === null ? DASH : nf0.format(n);22}23export function fmt1(v: Num | undefined | unknown): string {24 const n = num(v);25 return n === null ? DASH : nf1.format(n);26}27export function fmt2(v: Num | undefined | unknown): string {28 const n = num(v);29 return n === null ? DASH : nf2.format(n);30}31export function fmtCompact(v: Num | undefined | unknown): string {32 const n = num(v);33 return n === null ? DASH : nfCompact.format(n);34}35/** 0–100 score with one decimal (API rounds to 1 decimal). */36export function fmtScore(v: Num | undefined | unknown): string {37 const n = num(v);38 return n === null ? DASH : nf1.format(n);39}40/** Percentage from a 0–1 ratio or a 0–100 value (`ratio` flag). */41export function fmtPct(v: Num | undefined | unknown, digits = 1, ratio = false): string {42 const n = num(v);43 if (n === null) return DASH;44 return `${(ratio ? n * 100 : n).toFixed(digits)} %`;45}46/** Signed percentage: +12.3 % / −4.0 % / 0.0 %. */47export function fmtPctSigned(v: Num | undefined | unknown, digits = 1): string {48 const n = num(v);49 if (n === null) return DASH;50 const sign = n > 0 ? '+' : n < 0 ? '−' : '';51 return `${sign}${Math.abs(n).toFixed(digits)} %`;52}53export function fmtSigned(v: Num | undefined | unknown, digits = 0): string {54 const n = num(v);55 if (n === null) return DASH;56 const f = digits ? Math.abs(n).toFixed(digits) : nf0.format(Math.abs(n));57 return n > 0 ? `+${f}` : n < 0 ? `−${f}` : digits ? (0).toFixed(digits) : '0';58}59export function fmtBytes(v: Num | undefined | unknown): string {60 const n = num(v);61 if (n === null) return DASH;62 if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TB`;63 if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`;64 if (n >= 1e6) return `${(n / 1e6).toFixed(0)} MB`;65 if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`;66 return `${n} B`;67}68export function fmtUsd(v: Num | undefined | unknown, digits = 2): string {69 const n = num(v);70 return n === null ? DASH : `$${n.toFixed(digits)}`;71}72/**73 * Compact currency for stated financial facts: 130.5B USD → "$130.5B", 34.2B EUR → "€34.2B", 48T JPY → "¥48.0T".74 * Never converts between currencies; unknown ISO codes fall back to "<compact> <CODE>".75 */76export function fmtMoney(value: Num | undefined | unknown, currency: string | null | undefined): string {77 const n = num(value);78 if (n === null) return DASH;79 const code = (currency ?? '').toUpperCase();80 if (code) {81 try {82 return new Intl.NumberFormat('en-US', { style: 'currency', currency: code, notation: 'compact', maximumFractionDigits: 1, currencyDisplay: 'narrowSymbol' }).format(n);83 } catch {84 /* invalid code */85 }86 }87 return code ? `${nfCompact.format(n)} ${code}` : nfCompact.format(n);88}89export function fmtPrice(price: Num | undefined, currency: string | null | undefined, text?: string | null): string {90 const n = num(price);91 if (n === null) return text ?? DASH;92 const cur = (currency ?? 'USD').toUpperCase();93 const sym = cur === 'USD' ? '$' : cur === 'EUR' ? '€' : cur === 'GBP' ? '£' : `${cur} `;94 return `${sym}${Number.isInteger(n) ? nf0.format(n) : nf2.format(n)}`;95}9697/** ISO date/timestamp → "11 Sept 2026" (UTC). */98export function fmtDate(v: string | null | undefined): string {99 if (!v) return DASH;100 const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);101 if (Number.isNaN(d.getTime())) return v;102 return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });103}104export function fmtDateShort(v: string | null | undefined): string {105 if (!v) return DASH;106 const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);107 if (Number.isNaN(d.getTime())) return v;108 return d.toLocaleDateString('en-GB', { month: 'short', day: 'numeric', timeZone: 'UTC' });109}110export function fmtDateTime(v: string | null | undefined): string {111 if (!v) return DASH;112 const d = new Date(v);113 if (Number.isNaN(d.getTime())) return DASH;114 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`;115}116export function fmtTime(v: string | null | undefined): string {117 if (!v) return DASH;118 const d = new Date(v);119 if (Number.isNaN(d.getTime())) return DASH;120 return `${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' })}`;121}122/** Day label for timeline groups: "Today", "Yesterday", else the date (UTC). */123export function fmtDayLabel(day: string, now: number = Date.now()): string {124 const today = new Date(now).toISOString().slice(0, 10);125 const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10);126 if (day === today) return 'Today';127 if (day === yesterday) return 'Yesterday';128 return fmtDate(day);129}130/** "17 sec ago" · "3 min ago" · "2 h ago" · "5 d ago" — precise at the second for the live feed. */131export function fmtAgo(v: string | null | undefined, now: number = Date.now()): string {132 if (!v) return DASH;133 const t = new Date(v).getTime();134 if (Number.isNaN(t)) return DASH;135 const s = Math.max(0, Math.round((now - t) / 1000));136 if (s < 5) return 'just now';137 if (s < 60) return `${s} sec ago`;138 const m = Math.floor(s / 60);139 if (m < 60) return `${m} min ago`;140 const h = Math.floor(m / 60);141 if (h < 48) return `${h} h ago`;142 const d = Math.floor(h / 24);143 if (d < 60) return `${d} d ago`;144 return fmtDate(v);145}146export function fmtDuration(seconds: Num | undefined | unknown): string {147 const n = num(seconds);148 if (n === null) return DASH;149 if (n < 60) return `${Math.round(n)} s`;150 if (n < 3600) return `${Math.round(n / 60)} min`;151 if (n < 86400) return `${(n / 3600).toFixed(n < 7200 ? 1 : 0)} h`;152 return `${Math.round(n / 86400)} d`;153}154export function fmtDays(days: Num | undefined | unknown): string {155 const n = num(days);156 if (n === null) return DASH;157 if (n < 1) return '< 1 day';158 if (n < 60) return `${Math.round(n)} ${plural(Math.round(n), 'day')}`;159 if (n < 730) return `${(n / 30.44).toFixed(0)} months`;160 return `${(n / 365.25).toFixed(1)} years`;161}162export function plural(n: number, one: string, many = `${one}s`): string {163 return n === 1 ? one : many;164}165export function hostOf(url: string | null | undefined): string | null {166 if (!url) return null;167 try {168 return new URL(url).hostname.replace(/^www\./, '');169 } catch {170 return null;171 }172}173export function pathOf(url: string | null | undefined): string {174 if (!url) return DASH;175 try {176 const u = new URL(url);177 return `${u.hostname.replace(/^www\./, '')}${u.pathname === '/' ? '' : u.pathname}`;178 } catch {179 return url;180 }181}182/** Constant → words: PRODUCT_LAUNCH → "Product launch"; job_count_increase → "Job count increase". */183export function humanize(s: string | null | undefined): string {184 if (!s) return DASH;185 const w = s.replace(/[_-]+/g, ' ').trim().toLowerCase();186 return w.charAt(0).toUpperCase() + w.slice(1);187}188export function titleCase(s: string | null | undefined): string {189 if (!s) return DASH;190 return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());191}192export function truncate(s: string | null | undefined, n = 140): string {193 if (!s) return '';194 return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;195}196/** Significance 0–1 → band per spec §20. */197export function significanceBand(v: Num | undefined | unknown): 'noise' | 'minor' | 'meaningful' | 'major' | 'critical' | null {198 const n = num(v);199 if (n === null) return null;200 if (n < 0.2) return 'noise';201 if (n < 0.4) return 'minor';202 if (n < 0.65) return 'meaningful';203 if (n < 0.85) return 'major';204 return 'critical';205}206/** Importance 0–1 (or 0–100) → 0..3 steps for the meter. */207export function importanceSteps(v: Num | undefined | unknown): 0 | 1 | 2 | 3 {208 const n = num(v);209 if (n === null) return 0;210 const x = n > 1 ? n / 100 : n;211 if (x >= 0.8) return 3;212 if (x >= 0.55) return 2;213 if (x >= 0.3) return 1;214 return 0;215}216export function toneOf(v: Num | undefined | unknown): 'positive' | 'negative' | 'neutral' {217 const n = num(v);218 if (n === null || n === 0) return 'neutral';219 return n > 0 ? 'positive' : 'negative';220}221