spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/** Pure helpers for the trial-intelligence pages (sorting whitelist, CSV cells). Shared by server components, route handlers and tests. */23export const INTEL_SORT_KEYS = ['active', 'total', 'recruiting', 'phase3Active', 'phase3Recruiting', 'growth', 'avgEnrollment', 'industryShare', 'sponsorHhi', 'distinctCountries', 'usShare', 'terminationShare', 'trialsPer1000Deaths', 'name'] as const;4export type IntelSortKey = (typeof INTEL_SORT_KEYS)[number];56/** Minimal shape the sorter needs (snake_case, as returned by the query layer). */7export interface IntelSortable {8 cancer_name: string;9 total_trials: number;10 active_trials: number;11 recruiting_trials: number;12 phase3_active: number;13 phase3_recruiting: number;14 trial_growth_yoy: number | null;15 avg_enrollment: number | null;16 industry_share: number | null;17 sponsor_hhi: number | null;18 distinct_countries: number;19 us_share: number | null;20 termination_share: number | null;21 trials_per_1000_deaths: number | null;22}2324const FIELD: Record<IntelSortKey, keyof IntelSortable> = {25 active: 'active_trials',26 total: 'total_trials',27 recruiting: 'recruiting_trials',28 phase3Active: 'phase3_active',29 phase3Recruiting: 'phase3_recruiting',30 growth: 'trial_growth_yoy',31 avgEnrollment: 'avg_enrollment',32 industryShare: 'industry_share',33 sponsorHhi: 'sponsor_hhi',34 distinctCountries: 'distinct_countries',35 usShare: 'us_share',36 terminationShare: 'termination_share',37 trialsPer1000Deaths: 'trials_per_1000_deaths',38 name: 'cancer_name',39};4041export function isIntelSortKey(v: string): v is IntelSortKey {42 return (INTEL_SORT_KEYS as readonly string[]).includes(v);43}4445/**46 * Sort rows by a whitelisted key. Nulls always go last (whatever the direction) so "unknown" never47 * ranks above a real value; ties fall back to the cancer name for a deterministic order.48 */49export function sortIntel<T extends IntelSortable>(rows: readonly T[], key: IntelSortKey, order: 'asc' | 'desc'): T[] {50 const f = FIELD[key];51 const dir = order === 'asc' ? 1 : -1;52 return [...rows].sort((a, b) => {53 const va = a[f];54 const vb = b[f];55 if (va == null && vb == null) return a.cancer_name.localeCompare(b.cancer_name);56 if (va == null) return 1;57 if (vb == null) return -1;58 if (typeof va === 'string' || typeof vb === 'string') return dir * String(va).localeCompare(String(vb)) || a.cancer_name.localeCompare(b.cancer_name);59 return dir * ((va as number) - (vb as number)) || a.cancer_name.localeCompare(b.cancer_name);60 });61}6263/** Column totals for the header strip (only meaningful for the mutually exclusive top-level set). */64export function intelTotals<T extends IntelSortable>(rows: readonly T[]): { entities: number; total: number; active: number; recruiting: number; phase3Active: number; phase3Recruiting: number } {65 return rows.reduce(66 (s, r) => ({ entities: s.entities + 1, total: s.total + r.total_trials, active: s.active + r.active_trials, recruiting: s.recruiting + r.recruiting_trials, phase3Active: s.phase3Active + r.phase3_active, phase3Recruiting: s.phase3Recruiting + r.phase3_recruiting }),67 { entities: 0, total: 0, active: 0, recruiting: 0, phase3Active: 0, phase3Recruiting: 0 },68 );69}7071/** RFC 4180 cell: quote when the value contains a comma, quote or newline; null → empty. */72export function csvCell(v: unknown): string {73 const s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);74 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;75}7677/** Signed percentage for growth values: "+21.5%", "−3.6%"; em dash when null. */78export function fmtGrowth(v: number | null | undefined, digits = 1): string {79 if (v == null || !Number.isFinite(v)) return '—';80 const pct = v * 100;81 const s = new Intl.NumberFormat('en-US', { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(Math.abs(pct));82 return `${pct > 0 ? '+' : pct < 0 ? '−' : ''}${s}%`;83}8485/** Human label for a stop-reason category. */86export function reasonLabel(cat: string): string {87 const map: Record<string, string> = {88 enrollment: 'Enrollment / accrual',89 funding: 'Funding',90 sponsor_decision: 'Sponsor / business decision',91 safety: 'Safety / toxicity',92 efficacy: 'Efficacy / futility',93 drug_supply: 'Drug supply',94 investigator: 'Investigator',95 covid: 'COVID-19 / pandemic',96 other_stated: 'Other (stated, no rule matched)',97 not_stated: 'Not stated',98 };99 return map[cat] ?? cat.replace(/_/g, ' ');100}101