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%
8.4 KB · 130 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Section } from '@/components/ui/section';3import { EmptyState } from '@/components/ui/empty-state';4import { Freshness } from '@/components/ui/freshness';5import { Pager } from '@/components/ui/pager';6import { TrialTable } from '@/components/data/trial-list';7import { ClaimBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { listTrialRows, trialFacets, TRIAL_PAGE_SIZE } from '@/lib/queries/trials';10import { trialIntelligenceFor, type TrialIntelRow } from '@/lib/queries/trial-intelligence';11import { fmtInt, fmtNum, fmtPct, humanize, phaseLabel } from '@/lib/format';12import { fmtGrowth } from '@/lib/trial-intel';13import { pageInfo } from '@/lib/pagination';14import { withParams } from '@/lib/search-params';15import type { CancerBundle } from '../load';1617/**18 * Intelligence strip on top of the trials tab: figures from `trial_intelligence` (level "all" for this19 * entity, falling back to "top"). Rendered only when a row exists — never a fake zero.20 */21function IntelStrip({ r, slug }: { r: TrialIntelRow; slug: string }) {22  const th = (r.inputs.thresholds ?? {}) as Record<string, unknown>;23  const burden = r.inputs.burden as { deaths?: number } | undefined;24  const items: Array<{ k: string; v: React.ReactNode; title: string }> = [25    { k: 'Active', v: fmtInt(r.active_trials), title: `Interventional studies with status ${(r.inputs.activeStatuses as string[] | undefined)?.join(', ') ?? 'active'} mapped to this entity or a descendant` },26    { k: 'Recruiting', v: fmtInt(r.recruiting_trials), title: 'Interventional studies with overall status RECRUITING' },27    { k: 'Ph III recruiting', v: fmtInt(r.phase3_recruiting), title: `Recruiting interventional studies with PHASE3 among their phases (${fmtInt(r.phase3_active)} active Phase III)` },28    { k: 'Growth YoY', v: fmtGrowth(r.trial_growth_yoy), title: `(${fmtInt(r.new_trials_12m)} first posted in the last 12 months − ${fmtInt(r.new_trials_prior_12m)} in the preceding 12 months) / ${fmtInt(r.new_trials_prior_12m)}${r.trial_growth_yoy == null ? ` — not computed under ${String(th.growthMinPriorTrials ?? 20)} studies in the preceding window` : ''}` },29    { k: 'Industry share', v: fmtPct(r.industry_share, 0), title: 'Share of active interventional studies whose lead sponsor class is INDUSTRY' },30    { k: 'Top sponsor', v: r.top_sponsor ? <span className="block max-w-[12rem] truncate">{r.top_sponsor}</span> : '—', title: r.top_sponsor ? `${r.top_sponsor} leads ${fmtPct(r.top_sponsor_share, 1)} of active studies · ${fmtInt(r.distinct_sponsors)} distinct lead sponsors · sponsor HHI ${fmtNum(r.sponsor_hhi, 3)}` : 'No active study with a lead sponsor' },31    { k: 'Termination share', v: fmtPct(r.termination_share, 1), title: `(terminated + withdrawn) / (completed + terminated + withdrawn), interventional studies first posted since ${String(th.terminationSince ?? '2010-01-01')}${r.termination_share == null ? ` — not computed under ${String(th.terminationMinTerminalTrials ?? 30)} terminal studies` : ''}` },32  ];33  if (r.trials_per_1000_deaths != null) {34    items.push({ k: 'Trials / 1,000 deaths', v: <>{fmtNum(r.trials_per_1000_deaths, 1)} <span className="text-[11px] text-ink-3">{r.burden_geography} {r.burden_year}</span> {r.burden_source_slug ? <SourceBadge compact p={{ sourceSlug: r.burden_source_slug }} title={`Deaths: ${r.burden_source_slug}, ${r.burden_geography} ${r.burden_year}, all sexes, all ages`} /> : null}</>, title: `${fmtInt(r.active_trials)} active interventional studies / (${fmtInt(burden?.deaths)} annual deaths / 1,000) · also ${fmtNum(r.trials_per_100k_cases, 1)} per 100,000 new cases` });35  }36  return (37    <div className="mb-4 border-y border-rule py-3">38      <dl className="grid grid-cols-2 gap-x-5 gap-y-2.5 text-[13px] sm:grid-cols-4">39        {items.map((i) => (40          <div key={i.k} title={i.title} className="min-w-0">41            <dt className="ci-kicker">{i.k}</dt>42            <dd className="ci-num text-lg text-ink">{i.v}</dd>43          </div>44        ))}45      </dl>46      <p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3">47        <ClaimBadge kind="computed" />48        <span>49          formula <span className="ci-mono">{r.formula_version}</span> · interventional studies · entity + NCIt descendants · hover a figure for its formula50        </span>51        <Link className="ci-link" href="/trials/intelligence">52          Compare cancers53        </Link>54        <Link className="ci-link" href={`/trials/terminated?cancer=${encodeURIComponent(slug)}`}>55          Terminated studies ({fmtInt(r.terminated_trials + r.withdrawn_trials + r.suspended_trials)})56        </Link>57      </p>58    </div>59  );60}6162export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; status: string; phase: string; page: number }) {63  const [facets, { rows, total }, intel] = await Promise.all([trialFacets(b.descendants), listTrialRows({ q: '', status, phase, country: '', cancerIds: b.descendants, page, pageSize: TRIAL_PAGE_SIZE }), trialIntelligenceFor(b.cancer.id)]);64  const info = pageInfo(page, TRIAL_PAGE_SIZE, total);65  const base = `/cancer/${b.cancer.slug}/trials`;66  const href = (o: Record<string, string | number | null | undefined>) => `${base}${withParams({ status, phase }, o)}`;67  const anyTrials = facets.statuses.reduce((s, x) => s + x.n, 0);6869  if (anyTrials === 0) {70    return (71      <Section id="trials" kicker="Clinical trials" title="Registered studies">72        <EmptyState knows={[{ label: 'Trials explorer', href: '/trials' }, { label: 'Research tab', href: `/cancer/${b.cancer.slug}/research` }]}>73          No ClinicalTrials.gov study has a condition mapped to this entity or its descendants yet. Conditions are free text at the source; they are reconciled to the taxonomy by identifier, curated alias, then normalized string — unmatched labels are queued for review, never dropped.74        </EmptyState>75      </Section>76    );77  }7879  const chip = (isCurrent: boolean) => ({ className: 'ci-chip', 'aria-current': isCurrent ? ('page' as const) : undefined });8081  return (82    <Section id="trials" kicker="Clinical trials" title="Registered studies" description={`${fmtInt(anyTrials)} studies whose conditions map to this entity or one of its descendants. Status and phase are as posted on ClinicalTrials.gov. ${TRIAL_PAGE_SIZE} studies per page.`}>83      {intel.primary ? <IntelStrip r={intel.primary} slug={b.cancer.slug} /> : null}84      <div className="mb-3 flex flex-wrap gap-4 text-[12.5px]">85        <nav aria-label="Filter by status" className="flex flex-wrap items-center gap-1.5">86          <span className="ci-kicker mr-1">Status</span>87          <Link href={href({ status: '', page: '' })} {...chip(!status)}>88            All89          </Link>90          <Link href={href({ status: 'active', page: '' })} {...chip(status === 'active')}>91            Active (any)92          </Link>93          {facets.statuses.map((s) => (94            <Link key={s.k} href={href({ status: s.k, page: '' })} {...chip(status === s.k)}>95              {humanize(s.k)} <span className="ci-num text-ink-3">{fmtInt(s.n)}</span>96            </Link>97          ))}98        </nav>99        <nav aria-label="Filter by phase" className="flex flex-wrap items-center gap-1.5">100          <span className="ci-kicker mr-1">Phase</span>101          <Link href={href({ phase: '', page: '' })} {...chip(!phase)}>102            All103          </Link>104          {facets.phases.map((p) => (105            <Link key={p.k} href={href({ phase: p.k, page: '' })} {...chip(phase === p.k)}>106              {phaseLabel(p.k)} <span className="ci-num text-ink-3">{fmtInt(p.n)}</span>107            </Link>108          ))}109        </nav>110      </div>111      {rows.length ? (112        <>113          <TrialTable114            rows={rows}115            summary={116              <>117                Showing {fmtInt(info.from)}–{fmtInt(info.to)} of {fmtInt(total)} studies{status || phase ? ' matching the filters' : ''}118              </>119            }120          />121          <Pager total={total} pageSize={TRIAL_PAGE_SIZE} page={info.page} hrefFor={(p) => href({ page: p === 1 ? '' : p })} label="Trial pages" noun="studies" />122          <Freshness dataUpdatedAt={rows.reduce<Date | string | null>((m, t) => (m == null || String(t.updated_at) > String(m) ? t.updated_at : m), null)} sourceUpdatedAt={rows[0]?.last_update_posted_date ?? null} extra="source: clinicaltrials" />123        </>124      ) : (125        <EmptyState compact title="No study matches these filters" />126      )}127    </Section>128  );129}130