import type { Metadata } from 'next'; import Link from 'next/link'; import { STOP_REASON_CATEGORIES, STOP_REASON_RULES, STOP_REASON_RULES_VERSION, isStopReasonCategory, type StopReasonCategory } from '@cancerindex/ranking'; import { PageHeader, Note } from '@/components/ui/section'; import { EmptyState } from '@/components/ui/empty-state'; import { Freshness } from '@/components/ui/freshness'; import { Pager } from '@/components/ui/pager'; import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; import { TableProvenance } from '@/components/ui/source-badge'; import { BarChart } from '@/components/charts/bar-chart'; import { listTerminated, stoppedYears, STOPPED_STATUSES } from '@/lib/queries/trial-intelligence'; import { getCancerBySlug, getDescendantIds } from '@/lib/queries/cancers'; import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format'; import { reasonLabel } from '@/lib/trial-intel'; import { pageInfo } from '@/lib/pagination'; import { str, int, withParams, type SP } from '@/lib/search-params'; export const metadata: Metadata = { title: 'Terminated, withdrawn and suspended trials', description: 'Oncology studies that stopped early, with the reason as posted by the registrant on ClinicalTrials.gov and a keyword-rule category. Reasons are never inferred.' }; export const revalidate = 600; const PAGE_SIZE = 50; export default async function TerminatedTrialsPage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const cancerSlug = str(sp, 'cancer'); const reasonRaw = str(sp, 'reason'); const reason: StopReasonCategory | '' = isStopReasonCategory(reasonRaw) ? reasonRaw : ''; const status = str(sp, 'status'); const since = int(sp, 'since', 0, 1990, 2100) || null; const page = int(sp, 'page', 1, 1, 100_000); const cancer = cancerSlug ? await getCancerBySlug(cancerSlug) : null; const cancerIds = cancer ? await getDescendantIds(cancer.id) : null; const [years, { rows, total, stopped, breakdown }] = await Promise.all([stoppedYears(), listTerminated({ cancerIds, reason, status, since, page, pageSize: PAGE_SIZE })]); const info = pageInfo(page, PAGE_SIZE, total); const current = { cancer: cancerSlug, reason, status, since: since ?? '' }; const href = (o: Record) => `/trials/terminated${withParams(current, o)}`; const chart = STOP_REASON_CATEGORIES.map((c) => ({ label: reasonLabel(c), value: breakdown[c], href: href({ reason: c, page: '' }), muted: c === 'not_stated' || c === 'other_stated' })).filter((d) => d.value > 0).sort((a, b) => b.value - a.value); const stated = stopped - breakdown.not_stated; return (

{fmtInt(stopped)} stopped studies{cancer ? <> mapped to {cancer.canonical_name} and descendants : null} {since ? ` first posted since ${since}` : ''} {status ? ` with status ${humanize(status)}` : ''} {cancerSlug && !cancer ? — unknown cancer slug "{cancerSlug}" (ignored) : null} {stopped > 0 ? ( <> {' '} · {fmtPct(stopped ? stated / stopped : null, 0)} state a reason {reason ? ( <> {' '} · showing {fmtInt(total)} in category {reasonLabel(reason)} ) : null} ) : null}

{stopped === 0 ? (
Relax a filter or clear the cancer slug.
) : ( <>

Stated reasons by category

Over the {fmtInt(stopped)} studies matching the cancer, status and year filters (the category filter does not change this chart). Each study is counted once, in the first matching category; greyed bars are studies whose text matched no rule or was empty.

rules {STOP_REASON_RULES_VERSION} · keyword matching only

Classification rules

A category is assigned when one of its keywords appears in the posted text (case-insensitive, whole words). Rules are tested in this order and the first match wins; every match is kept in the API (reasonMatches).

    {STOP_REASON_RULES.map((r) => (
  1. {reasonLabel(r.category)} — {r.keywords.join(' · ')}
  2. ))}
  3. {reasonLabel('other_stated')} — text present, no keyword matched
  4. {reasonLabel('not_stated')} — no text posted
}> Showing {fmtInt(info.from)}–{fmtInt(info.to)} of {fmtInt(total)} studies, most recently updated first {rows.length === 0 ? ( ) : (
{rows.map((t) => ( ))}
NCT Title Phase Status First posted Lead sponsor Why stopped (as posted) Category
{t.nct_id} {truncate(t.brief_title, 90)} {t.acronym ? ({t.acronym}) : null} {t.phases.length ? t.phases.map(phaseLabel).join(' / ') : '—'} {fmtDate(t.first_posted_date)} {t.lead_sponsor ?? '—'} {t.why_stopped ? truncate(t.why_stopped, 100) : not stated} 1 ? `also matched: ${t.reason_matches.slice(1).map(reasonLabel).join(', ')}` : t.reason_category === 'not_stated' ? 'No text posted by the registrant' : t.reason_category === 'other_stated' ? 'Text posted, no keyword rule matched' : `Keyword rule: ${reasonLabel(t.reason_category)}`}> {reasonLabel(t.reason_category)}
)} href({ page: p === 1 ? '' : p })} label="Stopped study pages" noun="studies" /> Stop reasons are registrant-reported free text and are displayed verbatim (truncated; hover for the full text). Categories come from explicit keyword rules and are never inferred from the study design, sponsor or outcome; a study that stopped for several reasons is filed under the first rule that matched. A TERMINATED status does not imply a negative result. ((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" />
)}
); }