spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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