SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
6.7 KB · 188 lines typescript
Raw Blame History
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