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 { STOP_REASON_CATEGORIES, STOP_REASON_RULES, STOP_REASON_RULES_VERSION, isStopReasonCategory, type StopReasonCategory } from '@cancerindex/ranking';4import { PageHeader, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Pager } from '@/components/ui/pager';8import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';9import { TableProvenance } from '@/components/ui/source-badge';10import { BarChart } from '@/components/charts/bar-chart';11import { listTerminated, stoppedYears, STOPPED_STATUSES } from '@/lib/queries/trial-intelligence';12import { getCancerBySlug, getDescendantIds } from '@/lib/queries/cancers';13import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format';14import { reasonLabel } from '@/lib/trial-intel';15import { pageInfo } from '@/lib/pagination';16import { str, int, withParams, type SP } from '@/lib/search-params';1718export 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.' };19export const revalidate = 600;2021const PAGE_SIZE = 50;2223export default async function TerminatedTrialsPage({ searchParams }: { searchParams: Promise<SP> }) {24 const sp = await searchParams;25 const cancerSlug = str(sp, 'cancer');26 const reasonRaw = str(sp, 'reason');27 const reason: StopReasonCategory | '' = isStopReasonCategory(reasonRaw) ? reasonRaw : '';28 const status = str(sp, 'status');29 const since = int(sp, 'since', 0, 1990, 2100) || null;30 const page = int(sp, 'page', 1, 1, 100_000);31 const cancer = cancerSlug ? await getCancerBySlug(cancerSlug) : null;32 const cancerIds = cancer ? await getDescendantIds(cancer.id) : null;33 const [years, { rows, total, stopped, breakdown }] = await Promise.all([stoppedYears(), listTerminated({ cancerIds, reason, status, since, page, pageSize: PAGE_SIZE })]);34 const info = pageInfo(page, PAGE_SIZE, total);35 const current = { cancer: cancerSlug, reason, status, since: since ?? '' };36 const href = (o: Record<string, string | number | null | undefined>) => `/trials/terminated${withParams(current, o)}`;37 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);38 const stated = stopped - breakdown.not_stated;3940 return (41 <div>42 <PageHeader kicker="Clinical trials · failure tracking" title="Terminated, withdrawn and suspended studies" lede="Oncology studies whose overall status is TERMINATED, WITHDRAWN or SUSPENDED on ClinicalTrials.gov, with the stop reason exactly as the registrant posted it. A category is attached only when an explicit keyword rule matches the text; CancerIndex never infers why a study stopped.">43 <nav className="mt-3 flex flex-wrap gap-3 text-[12.5px]">44 <Link href="/trials/intelligence" className="ci-link">45 ← Trial intelligence46 </Link>47 <Link href="/trials" className="ci-link">48 All trials49 </Link>50 </nav>51 </PageHeader>5253 <form method="get" action="/trials/terminated" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[1.4fr_1.4fr_1fr_1fr_auto]" role="search" aria-label="Filter stopped studies">54 <label className="flex flex-col gap-1">55 <span className="ci-kicker">Cancer (slug, includes descendants)</span>56 <input name="cancer" defaultValue={cancerSlug} placeholder="e.g. glioblastoma" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />57 </label>58 <label className="flex flex-col gap-1">59 <span className="ci-kicker">Reason category</span>60 <select name="reason" defaultValue={reason} className="border border-rule-strong bg-white px-2 py-1.5">61 <option value="">Any</option>62 {STOP_REASON_CATEGORIES.map((c) => (63 <option key={c} value={c}>64 {reasonLabel(c)} ({fmtInt(breakdown[c])})65 </option>66 ))}67 </select>68 </label>69 <label className="flex flex-col gap-1">70 <span className="ci-kicker">Status</span>71 <select name="status" defaultValue={status} className="border border-rule-strong bg-white px-2 py-1.5">72 <option value="">Any</option>73 {STOPPED_STATUSES.map((s) => (74 <option key={s} value={s}>75 {humanize(s)}76 </option>77 ))}78 </select>79 </label>80 <label className="flex flex-col gap-1">81 <span className="ci-kicker">First posted since</span>82 <select name="since" defaultValue={since ?? ''} className="border border-rule-strong bg-white px-2 py-1.5">83 <option value="">Any year</option>84 {years.map((y) => (85 <option key={y} value={y}>86 {y}87 </option>88 ))}89 </select>90 </label>91 <div className="flex items-end">92 <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">93 Apply94 </button>95 </div>96 </form>9798 <p className="mt-3 text-[13px] text-ink-2" role="status">99 <span className="ci-num font-medium text-ink">{fmtInt(stopped)}</span> stopped studies{cancer ? <> mapped to <span className="font-medium">{cancer.canonical_name}</span> and descendants</> : null}100 {since ? ` first posted since ${since}` : ''}101 {status ? ` with status ${humanize(status)}` : ''}102 {cancerSlug && !cancer ? <span className="text-warn"> — unknown cancer slug "{cancerSlug}" (ignored)</span> : null}103 {stopped > 0 ? (104 <>105 {' '}106 · <span className="ci-num">{fmtPct(stopped ? stated / stopped : null, 0)}</span> state a reason107 {reason ? (108 <>109 {' '}110 · showing <span className="ci-num">{fmtInt(total)}</span> in category <span className="font-medium">{reasonLabel(reason)}</span>111 </>112 ) : null}113 </>114 ) : null}115 </p>116117 {stopped === 0 ? (118 <div className="mt-3">119 <EmptyState title="No stopped study matches these filters" knows={[{ label: 'Trial intelligence', href: '/trials/intelligence' }, { label: 'Trials explorer', href: '/trials' }]}>120 Relax a filter or clear the cancer slug.121 </EmptyState>122 </div>123 ) : (124 <>125 <section aria-labelledby="breakdown-title" className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">126 <div className="min-w-0">127 <h2 id="breakdown-title" className="text-lg">128 Stated reasons by category129 </h2>130 <p className="mb-2 text-[12.5px] text-ink-3">131 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.132 </p>133 <BarChart data={chart} unit="count" maxBars={10} ariaLabel="Stopped studies by registrant-reported reason category" />134 <p className="mt-1 flex flex-wrap items-center gap-2 text-[11.5px] text-ink-3">135 <ClaimBadge kind="computed" />136 <span>137 rules <span className="ci-mono">{STOP_REASON_RULES_VERSION}</span> · keyword matching only138 </span>139 </p>140 </div>141 <div className="min-w-0 text-[12.5px]">142 <h3 className="text-base">Classification rules</h3>143 <p className="mb-1.5 text-ink-3">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 (<code className="ci-mono">reasonMatches</code>).</p>144 <ol className="list-decimal space-y-0.5 pl-5 text-ink-2">145 {STOP_REASON_RULES.map((r) => (146 <li key={r.category}>147 <span className="font-medium">{reasonLabel(r.category)}</span> — <span className="ci-mono text-ink-3">{r.keywords.join(' · ')}</span>148 </li>149 ))}150 <li>151 <span className="font-medium">{reasonLabel('other_stated')}</span> — text present, no keyword matched152 </li>153 <li>154 <span className="font-medium">{reasonLabel('not_stated')}</span> — no text posted155 </li>156 </ol>157 </div>158 </section>159160 <div className="mt-5">161 <TableProvenance p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', layer: 'normalized', note: 'Status, dates, sponsor and "why stopped" are shown as posted by the registrant. Only the category column is computed.' }} claim={<ClaimBadge kind="observed" />}>162 Showing {fmtInt(info.from)}–{fmtInt(info.to)} of {fmtInt(total)} studies, most recently updated first163 </TableProvenance>164 {rows.length === 0 ? (165 <EmptyState compact title="No study in this category for these filters" />166 ) : (167 <div className="ci-table-wrap">168 <table className="ci-table">169 <thead>170 <tr>171 <th>NCT</th>172 <th>Title</th>173 <th>Phase</th>174 <th>Status</th>175 <th>First posted</th>176 <th>Lead sponsor</th>177 <th>Why stopped (as posted)</th>178 <th>Category</th>179 </tr>180 </thead>181 <tbody>182 {rows.map((t) => (183 <tr key={t.id}>184 <td className="ci-mono whitespace-nowrap">185 <Link className="ci-link" href={`/trial/${t.nct_id}`}>186 {t.nct_id}187 </Link>188 </td>189 <td className="max-w-[26rem]" title={t.brief_title}>190 {truncate(t.brief_title, 90)}191 {t.acronym ? <span className="ml-1 text-ink-3">({t.acronym})</span> : null}192 </td>193 <td className="whitespace-nowrap">{t.phases.length ? t.phases.map(phaseLabel).join(' / ') : '—'}</td>194 <td>195 <StatusBadge status={t.overall_status} />196 </td>197 <td className="whitespace-nowrap">{fmtDate(t.first_posted_date)}</td>198 <td className="max-w-[14rem] truncate" title={`${t.lead_sponsor ?? ''}${t.lead_sponsor_class ? ` (${humanize(t.lead_sponsor_class)})` : ''}`}>199 {t.lead_sponsor ?? '—'}200 </td>201 <td className="max-w-[22rem]" title={t.why_stopped ?? 'No reason posted'}>202 {t.why_stopped ? truncate(t.why_stopped, 100) : <span className="text-ink-4">not stated</span>}203 </td>204 <td>205 <Badge tone={t.reason_category === 'not_stated' ? 'outline' : t.reason_category === 'other_stated' ? 'neutral' : 'accent'} title={t.reason_matches.length > 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)}`}>206 {reasonLabel(t.reason_category)}207 </Badge>208 </td>209 </tr>210 ))}211 </tbody>212 </table>213 </div>214 )}215 <Pager page={info.page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} label="Stopped study pages" noun="studies" />216 <Note>217 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.218 </Note>219 <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" />220 </div>221 </>222 )}223 </div>224 );225}226