/** Pure helpers for the trial-intelligence pages (sorting whitelist, CSV cells). Shared by server components, route handlers and tests. */ export const INTEL_SORT_KEYS = ['active', 'total', 'recruiting', 'phase3Active', 'phase3Recruiting', 'growth', 'avgEnrollment', 'industryShare', 'sponsorHhi', 'distinctCountries', 'usShare', 'terminationShare', 'trialsPer1000Deaths', 'name'] as const; export type IntelSortKey = (typeof INTEL_SORT_KEYS)[number]; /** Minimal shape the sorter needs (snake_case, as returned by the query layer). */ export interface IntelSortable { cancer_name: string; total_trials: number; active_trials: number; recruiting_trials: number; phase3_active: number; phase3_recruiting: number; trial_growth_yoy: number | null; avg_enrollment: number | null; industry_share: number | null; sponsor_hhi: number | null; distinct_countries: number; us_share: number | null; termination_share: number | null; trials_per_1000_deaths: number | null; } const FIELD: Record = { active: 'active_trials', total: 'total_trials', recruiting: 'recruiting_trials', phase3Active: 'phase3_active', phase3Recruiting: 'phase3_recruiting', growth: 'trial_growth_yoy', avgEnrollment: 'avg_enrollment', industryShare: 'industry_share', sponsorHhi: 'sponsor_hhi', distinctCountries: 'distinct_countries', usShare: 'us_share', terminationShare: 'termination_share', trialsPer1000Deaths: 'trials_per_1000_deaths', name: 'cancer_name', }; export function isIntelSortKey(v: string): v is IntelSortKey { return (INTEL_SORT_KEYS as readonly string[]).includes(v); } /** * Sort rows by a whitelisted key. Nulls always go last (whatever the direction) so "unknown" never * ranks above a real value; ties fall back to the cancer name for a deterministic order. */ export function sortIntel(rows: readonly T[], key: IntelSortKey, order: 'asc' | 'desc'): T[] { const f = FIELD[key]; const dir = order === 'asc' ? 1 : -1; return [...rows].sort((a, b) => { const va = a[f]; const vb = b[f]; if (va == null && vb == null) return a.cancer_name.localeCompare(b.cancer_name); if (va == null) return 1; if (vb == null) return -1; if (typeof va === 'string' || typeof vb === 'string') return dir * String(va).localeCompare(String(vb)) || a.cancer_name.localeCompare(b.cancer_name); return dir * ((va as number) - (vb as number)) || a.cancer_name.localeCompare(b.cancer_name); }); } /** Column totals for the header strip (only meaningful for the mutually exclusive top-level set). */ export function intelTotals(rows: readonly T[]): { entities: number; total: number; active: number; recruiting: number; phase3Active: number; phase3Recruiting: number } { return rows.reduce( (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 }), { entities: 0, total: 0, active: 0, recruiting: 0, phase3Active: 0, phase3Recruiting: 0 }, ); } /** RFC 4180 cell: quote when the value contains a comma, quote or newline; null → empty. */ export function csvCell(v: unknown): string { const s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } /** Signed percentage for growth values: "+21.5%", "−3.6%"; em dash when null. */ export function fmtGrowth(v: number | null | undefined, digits = 1): string { if (v == null || !Number.isFinite(v)) return '—'; const pct = v * 100; const s = new Intl.NumberFormat('en-US', { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(Math.abs(pct)); return `${pct > 0 ? '+' : pct < 0 ? '−' : ''}${s}%`; } /** Human label for a stop-reason category. */ export function reasonLabel(cat: string): string { const map: Record = { enrollment: 'Enrollment / accrual', funding: 'Funding', sponsor_decision: 'Sponsor / business decision', safety: 'Safety / toxicity', efficacy: 'Efficacy / futility', drug_supply: 'Drug supply', investigator: 'Investigator', covid: 'COVID-19 / pandemic', other_stated: 'Other (stated, no rule matched)', not_stated: 'Not stated', }; return map[cat] ?? cat.replace(/_/g, ' '); }