spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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 case 'log2_ratio':44 return `${v > 0 ? '+' : ''}${fmtNum(v, 2)}`;45 case 'per_1000_deaths':46 return fmtNum(v, 1);47 case 'index':48 return fmtNum(v, 3);49 default:50 return Math.abs(v) >= 1000 ? fmtInt(v) : fmtNum(v, 2);51 }52}5354export function unitLabel(unit: string | null | undefined): string {55 switch (unit) {56 case 'count':57 return 'count';58 case 'per_100k':59 return 'per 100,000';60 case 'ratio':61 return 'ratio';62 case 'probability':63 return '%';64 case 'percentile_points':65 return 'percentile points';66 case 'per_1000_deaths':67 return 'per 1,000 deaths';68 case 'log2_ratio':69 return 'log₂ ratio';70 case 'index':71 return 'index (0–1)';72 default:73 return unit ?? '';74 }75}7677/**78 * Coerce a driver value to a Date. Drizzle's postgres-js adapter returns timestamps from raw79 * `execute()` as strings ("2026-09-08 05:07:09.317-04"), so every date field may be a string.80 */81export function toDate(v: Date | string | number | null | undefined): Date | null {82 if (v == null || v === '') return null;83 if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;84 if (typeof v === 'number') return new Date(v);85 let s = v.trim();86 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s = `${s}T00:00:00Z`;87 else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) {88 s = s.replace(' ', 'T');89 const m = /([+-]\d{2})$/.exec(s);90 if (m) s = `${s}:00`;91 else if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) s = `${s}Z`;92 }93 const d = new Date(s);94 return Number.isNaN(d.getTime()) ? null : d;95}9697export function isoDate(v: Date | string | null | undefined): string {98 const d = toDate(v);99 return d ? d.toISOString().slice(0, 10) : '—';100}101102export function fmtDate(d: Date | string | null | undefined, opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }): string {103 if (!d) return '—';104 const date = toDate(d);105 if (!date) return typeof d === 'string' ? d : '—';106 return new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts }).format(date);107}108109export function fmtDateTime(d: Date | string | null | undefined): string {110 return fmtDate(d, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });111}112113export function relativeTime(d: Date | string | null | undefined, now = new Date()): string {114 const date = toDate(d);115 if (!date) return 'unknown';116 const diff = (now.getTime() - date.getTime()) / 1000;117 const abs = Math.abs(diff);118 const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });119 const sign = diff >= 0 ? -1 : 1;120 if (abs < 60) return 'just now';121 if (abs < 3600) return rtf.format(sign * Math.round(abs / 60), 'minute');122 if (abs < 86400) return rtf.format(sign * Math.round(abs / 3600), 'hour');123 if (abs < 86400 * 30) return rtf.format(sign * Math.round(abs / 86400), 'day');124 if (abs < 86400 * 365) return rtf.format(sign * Math.round(abs / (86400 * 30)), 'month');125 return rtf.format(sign * Math.round(abs / (86400 * 365)), 'year');126}127128export function fmtDuration(ms: number | null | undefined): string {129 if (ms == null) return '—';130 if (ms < 1000) return `${ms} ms`;131 const s = ms / 1000;132 if (s < 90) return `${fmtNum(s, 1)} s`;133 const m = s / 60;134 if (m < 90) return `${fmtNum(m, 1)} min`;135 return `${fmtNum(m / 60, 1)} h`;136}137138/** Human labels for enumerations stored as UPPER_SNAKE / lower_snake. */139export function humanize(s: string | null | undefined): string {140 if (!s) return '—';141 return s142 .replace(/_/g, ' ')143 .toLowerCase()144 .replace(/\b(\w)/g, (c) => c.toUpperCase())145 .replace(/\bNa\b/, 'N/A')146 .replace(/\bNos\b/, 'NOS');147}148149export function phaseLabel(p: string): string {150 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' };151 return map[p] ?? humanize(p);152}153154export function truncate(s: string | null | undefined, n: number): string {155 if (!s) return '';156 if (s.length <= n) return s;157 const cut = s.slice(0, n);158 const i = cut.lastIndexOf(' ');159 return `${cut.slice(0, i > n * 0.6 ? i : n).trimEnd()}…`;160}161162export function pluralize(n: number, one: string, many = `${one}s`): string {163 return n === 1 ? one : many;164}165166/** Parse the ranking scope key "geo=WORLD|sex=all|age=all|year=latest|level=top" into a record. */167export function parseScopeKey(key: string): Record<string, string> {168 const out: Record<string, string> = {};169 for (const part of key.split('|')) {170 const [k, v] = part.split('=');171 if (k && v != null) out[k] = v;172 }173 return out;174}175176export function scopeLabel(key: string): string {177 const s = parseScopeKey(key);178 const bits: string[] = [];179 bits.push(s.geo === 'WORLD' ? 'World' : (s.geo ?? '?'));180 if (s.sex && s.sex !== 'all') bits.push(humanize(s.sex));181 else bits.push('both sexes');182 if (s.age && s.age !== 'all') bits.push(`ages ${s.age}`);183 else bits.push('all ages');184 bits.push(s.year && s.year !== 'latest' ? s.year : 'latest');185 bits.push(s.level === 'top' ? 'top-level cancers' : s.level === 'all' ? 'all malignant entities' : `${s.level} level`);186 return bits.join(' · ');187}188