spb/cancerindex
Public
1/** Formatting helpers — pure, shared by server and client components. */23const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });4const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });5const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });6const nf3 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 3 });78export function fmtInt(n: number | string | null | undefined): string {9 if (n == null || n === '') return '—';10 const v = Number(n);11 return Number.isFinite(v) ? nf0.format(v) : '—';12}1314export function fmtNum(n: number | string | null | undefined, digits = 1): string {15 if (n == null || n === '') return '—';16 const v = Number(n);17 if (!Number.isFinite(v)) return '—';18 const f = digits <= 0 ? nf0 : digits === 1 ? nf1 : digits === 2 ? nf2 : nf3;19 return f.format(v);20}2122export function fmtPct(p: number | null | undefined, digits = 1): string {23 if (p == null || !Number.isFinite(p)) return '—';24 return `${fmtNum(p * 100, digits)}%`;25}2627/** Format a value according to the unit vocabulary used by metric_definitions / observations. */28export function fmtValue(value: number | string | null | undefined, unit: string | null | undefined): string {29 if (value == null) return '—';30 const v = Number(value);31 if (!Number.isFinite(v)) return '—';32 switch (unit) {33 case 'count':34 return fmtInt(v);35 case 'per_100k':36 return fmtNum(v, 1);37 case 'ratio':38 return fmtNum(v, 2);39 case 'probability':40 return fmtPct(v, 1);41 case 'percentile_points':42 return `${v > 0 ? '+' : ''}${fmtNum(v, 1)}`;43 default:44 return Math.abs(v) >= 1000 ? fmtInt(v) : fmtNum(v, 2);45 }46}4748export function unitLabel(unit: string | null | undefined): string {49 switch (unit) {50 case 'count':51 return 'count';52 case 'per_100k':53 return 'per 100,000';54 case 'ratio':55 return 'ratio';56 case 'probability':57 return '%';58 case 'percentile_points':59 return 'percentile points';60 default:61 return unit ?? '';62 }63}6465/**66 * Coerce a driver value to a Date. Drizzle's postgres-js adapter returns timestamps from raw67 * `execute()` as strings ("2026-09-08 05:07:09.317-04"), so every date field may be a string.68 */69export function toDate(v: Date | string | number | null | undefined): Date | null {70 if (v == null || v === '') return null;71 if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;72 if (typeof v === 'number') return new Date(v);73 let s = v.trim();74 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s = `${s}T00:00:00Z`;75 else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) {76 s = s.replace(' ', 'T');77 const m = /([+-]\d{2})$/.exec(s);78 if (m) s = `${s}:00`;79 else if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) s = `${s}Z`;80 }81 const d = new Date(s);82 return Number.isNaN(d.getTime()) ? null : d;83}8485export function isoDate(v: Date | string | null | undefined): string {86 const d = toDate(v);87 return d ? d.toISOString().slice(0, 10) : '—';88}8990export function fmtDate(d: Date | string | null | undefined, opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }): string {91 if (!d) return '—';92 const date = toDate(d);93 if (!date) return typeof d === 'string' ? d : '—';94 return new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts }).format(date);95}9697export function fmtDateTime(d: Date | string | null | undefined): string {98 return fmtDate(d, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });99}100101export function relativeTime(d: Date | string | null | undefined, now = new Date()): string {102 const date = toDate(d);103 if (!date) return 'unknown';104 const diff = (now.getTime() - date.getTime()) / 1000;105 const abs = Math.abs(diff);106 const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });107 const sign = diff >= 0 ? -1 : 1;108 if (abs < 60) return 'just now';109 if (abs < 3600) return rtf.format(sign * Math.round(abs / 60), 'minute');110 if (abs < 86400) return rtf.format(sign * Math.round(abs / 3600), 'hour');111 if (abs < 86400 * 30) return rtf.format(sign * Math.round(abs / 86400), 'day');112 if (abs < 86400 * 365) return rtf.format(sign * Math.round(abs / (86400 * 30)), 'month');113 return rtf.format(sign * Math.round(abs / (86400 * 365)), 'year');114}115116export function fmtDuration(ms: number | null | undefined): string {117 if (ms == null) return '—';118 if (ms < 1000) return `${ms} ms`;119 const s = ms / 1000;120 if (s < 90) return `${fmtNum(s, 1)} s`;121 const m = s / 60;122 if (m < 90) return `${fmtNum(m, 1)} min`;123 return `${fmtNum(m / 60, 1)} h`;124}125126/** Human labels for enumerations stored as UPPER_SNAKE / lower_snake. */127export function humanize(s: string | null | undefined): string {128 if (!s) return '—';129 return s130 .replace(/_/g, ' ')131 .toLowerCase()132 .replace(/\b(\w)/g, (c) => c.toUpperCase())133 .replace(/\bNa\b/, 'N/A')134 .replace(/\bNos\b/, 'NOS');135}136137export function phaseLabel(p: string): string {138 const map: Record<string, string> = { EARLY_PHASE1: 'Early Phase 1', PHASE1: 'Phase 1', PHASE2: 'Phase 2', PHASE3: 'Phase 3', PHASE4: 'Phase 4', NA: 'N/A' };139 return map[p] ?? humanize(p);140}141142export function truncate(s: string | null | undefined, n: number): string {143 if (!s) return '';144 if (s.length <= n) return s;145 const cut = s.slice(0, n);146 const i = cut.lastIndexOf(' ');147 return `${cut.slice(0, i > n * 0.6 ? i : n).trimEnd()}…`;148}149150export function pluralize(n: number, one: string, many = `${one}s`): string {151 return n === 1 ? one : many;152}153154/** Parse the ranking scope key "geo=WORLD|sex=all|age=all|year=latest|level=top" into a record. */155export function parseScopeKey(key: string): Record<string, string> {156 const out: Record<string, string> = {};157 for (const part of key.split('|')) {158 const [k, v] = part.split('=');159 if (k && v != null) out[k] = v;160 }161 return out;162}163164export function scopeLabel(key: string): string {165 const s = parseScopeKey(key);166 const bits: string[] = [];167 bits.push(s.geo === 'WORLD' ? 'World' : (s.geo ?? '?'));168 if (s.sex && s.sex !== 'all') bits.push(humanize(s.sex));169 else bits.push('both sexes');170 if (s.age && s.age !== 'all') bits.push(`ages ${s.age}`);171 else bits.push('all ages');172 bits.push(s.year && s.year !== 'latest' ? s.year : 'latest');173 bits.push(s.level === 'top' ? 'top-level cancers' : s.level === 'all' ? 'all malignant entities' : `${s.level} level`);174 return bits.join(' · ');175}176