SPB Git

spb/cancerindex

Public
6.4 KB · 176 lines typescript
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
Files · 602dc111f3dd7d7dec082e01832909f07dca7bd3
Menu

Theme