spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { ClaimBadge } from '@/components/ui/badge';7import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';8import { listTrialIntelligence, type TrialIntelRow } from '@/lib/queries/trial-intelligence';9import { fmtInt, fmtNum, fmtPct, toDate } from '@/lib/format';10import { fmtGrowth, intelTotals, isIntelSortKey, sortIntel, type IntelSortKey } from '@/lib/trial-intel';11import { oneOf, str, withParams, type SP } from '@/lib/search-params';1213export const metadata: Metadata = { title: 'Clinical trial intelligence', description: 'Per-cancer trial activity, growth, enrollment, sponsor and country concentration, termination share and burden-normalized intensity, computed from ClinicalTrials.gov with a versioned formula.' };14export const revalidate = 600;1516const ALL_LIMIT = 150;1718const COLUMNS: Array<{ key: IntelSortKey; label: string; title: string; num?: boolean }> = [19 { key: 'name', label: 'Cancer', title: 'Cancer entity; figures aggregate the entity and its NCIt-hierarchy descendants' },20 { key: 'total', label: 'Total', title: 'Interventional studies mapped to the entity or a descendant (any status)', num: true },21 { key: 'active', label: 'Active', title: 'Recruiting, not yet recruiting, enrolling by invitation or active-not-recruiting interventional studies', num: true },22 { key: 'recruiting', label: 'Recruiting', title: 'Interventional studies with overall status RECRUITING', num: true },23 { key: 'phase3Recruiting', label: 'Ph III active / recruiting', title: 'Active (and recruiting) interventional studies with PHASE3 among their phases; a PHASE2|PHASE3 study counts in both phases', num: true },24 { key: 'growth', label: 'Growth YoY', title: '(studies first posted in the last 12 months − studies first posted in the preceding 12 months) / preceding; null when the preceding window has fewer than 20 studies', num: true },25 { key: 'avgEnrollment', label: 'Avg enrollment', title: 'Mean (median in title) enrollment_count over active interventional studies, as posted (anticipated or actual)', num: true },26 { key: 'industryShare', label: 'Industry share', title: 'Share of active interventional studies whose lead sponsor class is INDUSTRY', num: true },27 { key: 'sponsorHhi', label: 'Sponsor HHI · top sponsor', title: 'Herfindahl–Hirschman index of lead sponsors over active interventional studies (Σ share², 1 = one sponsor); null under 10 active studies', num: true },28 { key: 'distinctCountries', label: 'Countries · US share', title: 'Distinct countries with a site among active interventional studies; share of active studies with at least one US site', num: true },29 { key: 'terminationShare', label: 'Termination share', title: '(terminated + withdrawn) / (completed + terminated + withdrawn) over interventional studies first posted since 2010-01-01; null under 30 terminal studies', num: true },30 { key: 'trialsPer1000Deaths', label: 'Trials / 1,000 deaths', title: 'Active interventional studies per 1,000 annual deaths (US, latest year with both incidence and mortality counts from one source; deaths ≥ 100). Top-level cancers only', num: true },31];3233export default async function TrialIntelligencePage({ searchParams }: { searchParams: Promise<SP> }) {34 const sp = await searchParams;35 const level = oneOf(sp, 'level', ['top', 'all'] as const, 'top');36 const sortRaw = str(sp, 'sort', 'active');37 const sort: IntelSortKey = isIntelSortKey(sortRaw) ? sortRaw : 'active';38 const order = oneOf(sp, 'order', ['asc', 'desc'] as const, sort === 'name' ? 'asc' : 'desc');39 const fetched = await listTrialIntelligence(level, level === 'all' ? ALL_LIMIT : 10_000);40 const rows = sortIntel(fetched, sort, order);41 const totals = intelTotals(rows);42 const current = { level, sort, order };43 const href = (o: Record<string, string | number | null | undefined>) => `/trials/intelligence${withParams(current, o)}`;44 const computedAt = rows.reduce<Date | null>((m, r) => {45 const d = toDate(r.computed_at);46 return d && (!m || d > m) ? d : m;47 }, null);48 const first = rows[0];49 const th = (first?.inputs.thresholds ?? {}) as Record<string, unknown>;50 const windows = (first?.inputs.windows ?? null) as { new12m: { from: string; to: string }; prior12m: { from: string; to: string } } | null;51 const burdenSlugs = [...new Set(rows.map((r) => r.burden_source_slug).filter((s): s is string => !!s))];5253 const sortLink = (key: IntelSortKey, label: string, title: string, num?: boolean) => {54 const active = sort === key;55 const nextOrder = active ? (order === 'desc' ? 'asc' : 'desc') : key === 'name' ? 'asc' : 'desc';56 return (57 <th key={key} className={num ? 'num' : undefined} aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'} title={title}>58 <Link href={href({ sort: key, order: nextOrder })} className="ci-link no-underline">59 {label}60 {active ? <span aria-hidden> {order === 'asc' ? '↑' : '↓'}</span> : null}61 </Link>62 </th>63 );64 };6566 return (67 <div>68 <PageHeader kicker="Clinical trials" title="Clinical trial intelligence" lede="Per-cancer measures of registered clinical research: activity, growth, enrollment, who sponsors it and where it runs, how often it stops, and how it compares with the disease burden. Every figure is computed from ClinicalTrials.gov records with a versioned formula; nothing here is an observation.">69 <nav aria-label="Entity level" className="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px]">70 <span className="ci-kicker mr-1">Entities</span>71 <Link href={href({ level: 'top', sort: sort, order })} className="ci-chip" aria-current={level === 'top' ? 'page' : undefined}>72 Top-level cancers73 </Link>74 <Link href={href({ level: 'all', sort: sort, order })} className="ci-chip" aria-current={level === 'all' ? 'page' : undefined}>75 All entities ({ALL_LIMIT} most active)76 </Link>77 <span className="mx-2 text-ink-4" aria-hidden>78 ·79 </span>80 <Link href="/trials/terminated" className="ci-link">81 Terminated studies →82 </Link>83 <Link href={`/api/export/trial-intelligence.csv?level=${level}`} className="ci-link">84 CSV85 </Link>86 </nav>87 </PageHeader>8889 {rows.length === 0 ? (90 <EmptyState title="Trial intelligence not yet computed" knows={[{ label: 'Trials explorer', href: '/trials' }, { label: 'Rankings', href: '/rankings' }]}>91 The trial-intelligence layer is recomputed from the ClinicalTrials.gov records after each ingest (<code className="ci-mono">pnpm cix intel</code>). Nothing is shown until it has run on this environment.92 </EmptyState>93 ) : (94 <>95 {level === 'top' ? (96 <dl className="grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 text-[13px] sm:grid-cols-3 lg:grid-cols-6">97 {[98 ['Top-level cancers', fmtInt(totals.entities), 'Mutually exclusive registry set; sums below do not double count'],99 ['Interventional studies', fmtInt(totals.total), 'Sum of total interventional studies over the top-level set'],100 ['Active', fmtInt(totals.active), 'Sum of active interventional studies'],101 ['Recruiting', fmtInt(totals.recruiting), 'Sum of recruiting interventional studies'],102 ['Phase III active', fmtInt(totals.phase3Active), 'Sum of active studies with PHASE3 among their phases'],103 ['Phase III recruiting', fmtInt(totals.phase3Recruiting), 'Sum of recruiting studies with PHASE3 among their phases'],104 ].map(([k, v, t]) => (105 <div key={k} title={t}>106 <dt className="ci-kicker">{k}</dt>107 <dd className="ci-num text-xl text-ink">{v}</dd>108 </div>109 ))}110 </dl>111 ) : (112 <Note>113 Entities at this level overlap (a subtype and its parent both count the same study), so column totals are not shown. The {ALL_LIMIT} entities with the most active studies are listed; use the API (<code className="ci-mono">/api/v1/trials/intelligence?level=all</code>) or the CSV export for the full set.114 </Note>115 )}116117 <div className="mt-4">118 <TableProvenance p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', layer: 'derived', note: 'Studies attach to a cancer through their reconciled conditions, aggregated over the entity and its NCIt-hierarchy descendants (a study mapped to "lung adenocarcinoma" also counts for "lung cancer").' }} claim={<ClaimBadge kind="computed" />}>119 {fmtInt(rows.length)} entities · sorted by {COLUMNS.find((c) => c.key === sort)?.label ?? sort} ({order}) · click a header to sort120 </TableProvenance>121 <div className="ci-table-wrap">122 <table className="ci-table">123 <thead>124 <tr>{COLUMNS.map((c) => sortLink(c.key, c.label, c.title, c.num))}</tr>125 </thead>126 <tbody>127 {rows.map((r) => (128 <IntelRow key={r.cancer_id} r={r} />129 ))}130 </tbody>131 </table>132 </div>133 </div>134135 <div className="mt-4 border-t border-rule pt-3 text-[12.5px] text-ink-3">136 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">137 <ClaimBadge kind="computed" />138 <span>139 formula <span className="ci-mono">{first?.formula_version}</span>140 </span>141 <span>· interventional studies only · aggregation over NCIt descendants (depth ≤ {String(th.maxHierarchyDepth ?? 12)})</span>142 <span>· active = {(first?.inputs.activeStatuses as string[] | undefined)?.join(', ')}</span>143 </p>144 <p className="mt-1">145 Thresholds: growth requires ≥ {String(th.growthMinPriorTrials ?? 20)} studies in the prior window{windows ? ` (windows ${windows.new12m.from} → ${windows.new12m.to} vs ${windows.prior12m.from} → ${windows.prior12m.to})` : ''}; sponsor HHI requires ≥ {String(th.hhiMinActiveTrials ?? 10)} active studies; termination share requires ≥ {String(th.terminationMinTerminalTrials ?? 30)} terminal studies first posted since {String(th.terminationSince ?? '2010-01-01')}; burden ratios require ≥ {String(th.burdenMinDeaths ?? 100)} annual deaths ({String(th.burdenGeography ?? 'USA')}, latest year with both counts from one source{burdenSlugs.length ? `: ${burdenSlugs.join(', ')}` : ''}).146 A multinational study contributes to every country it lists, so country shares can sum above 100%.{' '}147 <Link className="ci-link" href="/methodology#trial-intelligence">148 Methodology149 </Link>150 </p>151 <Freshness dataUpdatedAt={computedAt} extra="computed by CancerIndex from ClinicalTrials.gov records" />152 </div>153 </>154 )}155 </div>156 );157}158159function IntelRow({ r }: { r: TrialIntelRow }) {160 return (161 <tr>162 <td>163 <Link className="ci-link" href={`/cancer/${r.cancer_slug}/trials`}>164 {r.cancer_name}165 </Link>166 </td>167 <td className="num">{fmtInt(r.total_trials)}</td>168 <td className="num font-medium">{fmtInt(r.active_trials)}</td>169 <td className="num">{fmtInt(r.recruiting_trials)}</td>170 <td className="num" title={`${fmtInt(r.phase3_active)} active Phase III, ${fmtInt(r.phase3_recruiting)} recruiting`}>171 {fmtInt(r.phase3_active)} <span className="text-ink-3">/ {fmtInt(r.phase3_recruiting)}</span>172 </td>173 <td className={`num ${r.trial_growth_yoy != null && r.trial_growth_yoy > 0 ? 'text-ok' : r.trial_growth_yoy != null && r.trial_growth_yoy < 0 ? 'text-danger' : ''}`} title={`${fmtInt(r.new_trials_12m)} first posted in the last 12 months vs ${fmtInt(r.new_trials_prior_12m)} in the preceding 12 months${r.trial_growth_yoy == null ? ' — below the 20-study threshold, not computed' : ''}`}>174 {fmtGrowth(r.trial_growth_yoy)}175 </td>176 <td className="num" title={r.avg_enrollment != null ? `mean ${fmtNum(r.avg_enrollment, 1)} · median ${fmtNum(r.median_enrollment, 0)} · total ${fmtInt(r.total_enrollment_active)} participants across active studies` : 'No enrollment counts posted'}>177 {fmtNum(r.avg_enrollment, 0)}178 </td>179 <td className="num">{fmtPct(r.industry_share, 0)}</td>180 <td className="num" title={r.sponsor_hhi == null ? 'Fewer than 10 active studies — not computed' : `${fmtInt(r.distinct_sponsors)} distinct lead sponsors; top sponsor ${r.top_sponsor ?? '—'} holds ${fmtPct(r.top_sponsor_share, 1)} of active studies`}>181 {fmtNum(r.sponsor_hhi, 3)}182 {r.top_sponsor ? <span className="block max-w-[14rem] truncate text-[11.5px] text-ink-3">{r.top_sponsor}</span> : null}183 </td>184 <td className="num" title={r.top_country ? `top country ${r.top_country} (${fmtPct(r.top_country_share, 0)} of active studies); country HHI ${fmtNum(r.country_hhi, 3)}` : undefined}>185 {fmtInt(r.distinct_countries)} <span className="text-ink-3">· {fmtPct(r.us_share, 0)}</span>186 </td>187 <td className="num" title={r.termination_share == null ? 'Fewer than 30 terminal studies since 2010 — not computed' : `${fmtInt(r.terminated_trials)} terminated, ${fmtInt(r.withdrawn_trials)} withdrawn, ${fmtInt(r.completed_trials)} completed (all years)`}>188 {fmtPct(r.termination_share, 1)}189 </td>190 <td className="num">191 {r.trials_per_1000_deaths != null ? (192 <span className="inline-flex flex-wrap items-baseline justify-end gap-x-1.5" title={`${fmtInt(r.active_trials)} active studies / (${fmtInt((r.inputs.burden as { deaths?: number } | undefined)?.deaths)} deaths / 1,000) · also ${fmtNum(r.trials_per_100k_cases, 1)} per 100,000 new cases`}>193 {fmtNum(r.trials_per_1000_deaths, 1)}194 <span className="text-[11px] text-ink-3">195 {r.burden_geography} {r.burden_year}196 </span>197 {r.burden_source_slug ? <SourceBadge compact p={{ sourceSlug: r.burden_source_slug }} title={`Deaths and incidence: ${r.burden_source_slug}, ${r.burden_geography} ${r.burden_year}, all sexes, all ages`} /> : null}198 </span>199 ) : (200 <span className="text-ink-4" title={r.top_level ? 'No US mortality and incidence counts from one source for this entity' : 'Burden normalization is computed for top-level cancers only'}>201 —202 </span>203 )}204 </td>205 </tr>206 );207}208