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