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%
14.6 KB · 208 lines tsx
Raw Blame History
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