SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
9.0 KB · 221 lines typescript
Raw Blame History
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