/** Formatting helpers — pure, shared by server and client components. */ 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 nf3 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 3 }); export function fmtInt(n: number | string | null | undefined): string { if (n == null || n === '') return '—'; const v = Number(n); return Number.isFinite(v) ? nf0.format(v) : '—'; } export function fmtNum(n: number | string | null | undefined, digits = 1): string { if (n == null || n === '') return '—'; const v = Number(n); if (!Number.isFinite(v)) return '—'; const f = digits <= 0 ? nf0 : digits === 1 ? nf1 : digits === 2 ? nf2 : nf3; return f.format(v); } export function fmtPct(p: number | null | undefined, digits = 1): string { if (p == null || !Number.isFinite(p)) return '—'; return `${fmtNum(p * 100, digits)}%`; } /** Format a value according to the unit vocabulary used by metric_definitions / observations. */ export function fmtValue(value: number | string | null | undefined, unit: string | null | undefined): string { if (value == null) return '—'; const v = Number(value); if (!Number.isFinite(v)) return '—'; switch (unit) { case 'count': return fmtInt(v); case 'per_100k': return fmtNum(v, 1); case 'ratio': return fmtNum(v, 2); case 'probability': return fmtPct(v, 1); case 'percentile_points': return `${v > 0 ? '+' : ''}${fmtNum(v, 1)}`; case 'log2_ratio': return `${v > 0 ? '+' : ''}${fmtNum(v, 2)}`; case 'per_1000_deaths': return fmtNum(v, 1); case 'index': return fmtNum(v, 3); default: return Math.abs(v) >= 1000 ? fmtInt(v) : fmtNum(v, 2); } } export function unitLabel(unit: string | null | undefined): string { switch (unit) { case 'count': return 'count'; case 'per_100k': return 'per 100,000'; case 'ratio': return 'ratio'; case 'probability': return '%'; case 'percentile_points': return 'percentile points'; case 'per_1000_deaths': return 'per 1,000 deaths'; case 'log2_ratio': return 'log₂ ratio'; case 'index': return 'index (0–1)'; default: return unit ?? ''; } } /** * Coerce a driver value to a Date. Drizzle's postgres-js adapter returns timestamps from raw * `execute()` as strings ("2026-09-08 05:07:09.317-04"), so every date field may be a string. */ export function toDate(v: Date | string | number | null | undefined): Date | null { if (v == null || v === '') return null; if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v; if (typeof v === 'number') return new Date(v); let s = v.trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s = `${s}T00:00:00Z`; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) { s = s.replace(' ', 'T'); const m = /([+-]\d{2})$/.exec(s); if (m) s = `${s}:00`; else if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) s = `${s}Z`; } const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } export function isoDate(v: Date | string | null | undefined): string { const d = toDate(v); return d ? d.toISOString().slice(0, 10) : '—'; } export function fmtDate(d: Date | string | null | undefined, opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }): string { if (!d) return '—'; const date = toDate(d); if (!date) return typeof d === 'string' ? d : '—'; return new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts }).format(date); } export function fmtDateTime(d: Date | string | null | undefined): string { return fmtDate(d, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); } export function relativeTime(d: Date | string | null | undefined, now = new Date()): string { const date = toDate(d); if (!date) return 'unknown'; const diff = (now.getTime() - date.getTime()) / 1000; const abs = Math.abs(diff); const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); const sign = diff >= 0 ? -1 : 1; if (abs < 60) return 'just now'; if (abs < 3600) return rtf.format(sign * Math.round(abs / 60), 'minute'); if (abs < 86400) return rtf.format(sign * Math.round(abs / 3600), 'hour'); if (abs < 86400 * 30) return rtf.format(sign * Math.round(abs / 86400), 'day'); if (abs < 86400 * 365) return rtf.format(sign * Math.round(abs / (86400 * 30)), 'month'); return rtf.format(sign * Math.round(abs / (86400 * 365)), 'year'); } export function fmtDuration(ms: number | null | undefined): string { if (ms == null) return '—'; if (ms < 1000) return `${ms} ms`; const s = ms / 1000; if (s < 90) return `${fmtNum(s, 1)} s`; const m = s / 60; if (m < 90) return `${fmtNum(m, 1)} min`; return `${fmtNum(m / 60, 1)} h`; } /** Human labels for enumerations stored as UPPER_SNAKE / lower_snake. */ export function humanize(s: string | null | undefined): string { if (!s) return '—'; return s .replace(/_/g, ' ') .toLowerCase() .replace(/\b(\w)/g, (c) => c.toUpperCase()) .replace(/\bNa\b/, 'N/A') .replace(/\bNos\b/, 'NOS'); } export function phaseLabel(p: string): string { const map: Record = { EARLY_PHASE1: 'Early Phase 1', PHASE1: 'Phase 1', PHASE2: 'Phase 2', PHASE3: 'Phase 3', PHASE4: 'Phase 4', NA: 'N/A' }; return map[p] ?? humanize(p); } export function truncate(s: string | null | undefined, n: number): string { if (!s) return ''; if (s.length <= n) return s; const cut = s.slice(0, n); const i = cut.lastIndexOf(' '); return `${cut.slice(0, i > n * 0.6 ? i : n).trimEnd()}…`; } export function pluralize(n: number, one: string, many = `${one}s`): string { return n === 1 ? one : many; } /** Parse the ranking scope key "geo=WORLD|sex=all|age=all|year=latest|level=top" into a record. */ export function parseScopeKey(key: string): Record { const out: Record = {}; for (const part of key.split('|')) { const [k, v] = part.split('='); if (k && v != null) out[k] = v; } return out; } export function scopeLabel(key: string): string { const s = parseScopeKey(key); const bits: string[] = []; bits.push(s.geo === 'WORLD' ? 'World' : (s.geo ?? '?')); if (s.sex && s.sex !== 'all') bits.push(humanize(s.sex)); else bits.push('both sexes'); if (s.age && s.age !== 'all') bits.push(`ages ${s.age}`); else bits.push('all ages'); bits.push(s.year && s.year !== 'latest' ? s.year : 'latest'); bits.push(s.level === 'top' ? 'top-level cancers' : s.level === 'all' ? 'all malignant entities' : `${s.level} level`); return bits.join(' · '); }