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.3 KB · 242 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, Note } from '@/components/ui/section';4import { Badge, ClaimBadge } from '@/components/ui/badge';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Pager } from '@/components/ui/pager';8import { SourceBadge } from '@/components/ui/source-badge';9import { PIPELINE_FUNNEL, PIPELINE_PAGE_SIZE, PIPELINE_STAGES, countPipelineRows, listPipelineRows, pipelineSourceSlugs, pipelineSummary, pipelineTopDrugsPerStage, topLevelCancerOptions, type PipelineRow, type PipelineStage } from '@/lib/queries/approvals';10import { fmtDate, fmtInt, phaseLabel } from '@/lib/format';11import { pageInfo } from '@/lib/pagination';12import { str, int, withParams, type SP } from '@/lib/search-params';1314export const metadata: Metadata = {15  title: 'Drug development pipeline',16  description: 'Development stage of oncology drugs per top-level cancer, derived from registered interventional trials and jurisdiction-aware approvals.',17  alternates: { canonical: '/pipeline' },18};19export const revalidate = 600;2021const STAGE_LABEL: Record<PipelineStage, string> = {22  phase_not_stated: 'Phase not stated',23  phase1: 'Phase 1',24  phase2: 'Phase 2',25  phase3: 'Phase 3',26  phase4: 'Phase 4',27  approved: 'Approved',28  withdrawn: 'Withdrawn',29};3031function StageBadge({ stage }: { stage: PipelineStage }) {32  const tone = stage === 'approved' ? 'ok' : stage === 'withdrawn' ? 'danger' : stage === 'phase_not_stated' ? 'outline' : 'neutral';33  return <Badge tone={tone}>{STAGE_LABEL[stage] ?? stage}</Badge>;34}3536/**37 * Funnel: one server-rendered SVG bar per stage (phase 1 → approved), width ∝ number of drugs,38 * with the representative drugs (most active trials) listed beside each bar.39 */40function Funnel({ stages, top, hrefFor }: { stages: Array<{ stage: PipelineStage; drugs: number; active_trials: number }>; top: Map<PipelineStage, PipelineRow[]>; hrefFor: (stage: PipelineStage) => string }) {41  const funnel = PIPELINE_FUNNEL.map((s) => stages.find((x) => x.stage === s) ?? { stage: s, drugs: 0, active_trials: 0 });42  const max = Math.max(...funnel.map((s) => s.drugs), 1);43  return (44    <ol className="m-0 list-none space-y-3 p-0">45      {funnel.map((s) => {46        const w = Math.max(s.drugs > 0 ? 1 : 0, Math.round((s.drugs / max) * 100));47        const reps = top.get(s.stage) ?? [];48        return (49          <li key={s.stage} className="grid gap-x-4 gap-y-1 sm:grid-cols-[140px_1fr]">50            <div className="text-[13.5px]">51              <Link className="ci-link font-medium" href={hrefFor(s.stage)}>52                {STAGE_LABEL[s.stage]}53              </Link>54              <p className="m-0 text-[12px] text-ink-3">55                <span className="ci-num">{fmtInt(s.drugs)}</span> drug{s.drugs === 1 ? '' : 's'} · <span className="ci-num">{fmtInt(s.active_trials)}</span> active trials56              </p>57            </div>58            <div className="min-w-0">59              <svg viewBox="0 0 100 10" preserveAspectRatio="none" width="100%" height="14" role="img" aria-label={`${STAGE_LABEL[s.stage]}: ${s.drugs} drugs`} className="block">60                <rect x="0" y="0" width="100" height="10" fill="var(--color-paper-2)" />61                <rect x="0" y="0" width={w} height="10" fill={s.stage === 'approved' ? 'var(--color-ink-2)' : 'var(--color-accent)'} />62              </svg>63              <p className="m-0 mt-1 text-[12.5px] text-ink-2">64                {reps.length ? (65                  reps.map((r, i) => (66                    <span key={r.id}>67                      {i > 0 ? ', ' : ''}68                      <Link className="ci-link" href={`/drug/${r.drug_slug}`} title={`${fmtInt(r.active_trials)} active trials`}>69                        {r.drug_name}70                      </Link>71                    </span>72                  ))73                ) : (74                  <span className="text-ink-4">no drug at this stage</span>75                )}76              </p>77            </div>78          </li>79        );80      })}81    </ol>82  );83}8485export default async function PipelinePage({ searchParams }: { searchParams: Promise<SP> }) {86  const sp = await searchParams;87  const cancerSlug = str(sp, 'cancer');88  const stage = (PIPELINE_STAGES as readonly string[]).includes(str(sp, 'stage')) ? (str(sp, 'stage') as PipelineStage) : '';89  const requestedPage = int(sp, 'page', 1, 1, 100_000);90  const options = await topLevelCancerOptions();91  const cancer = options.find((c) => c.slug === cancerSlug) ?? null;92  const scopeId = cancer?.id ?? null;93  const [summary, top, total, sources] = await Promise.all([pipelineSummary(scopeId), pipelineTopDrugsPerStage(scopeId, 5), countPipelineRows(scopeId, stage), pipelineSourceSlugs()]);94  const info = pageInfo(requestedPage, PIPELINE_PAGE_SIZE, total);95  const rows = total ? await listPipelineRows(scopeId, stage, info.page) : [];96  const current = { cancer: cancer?.slug ?? '', stage, page: info.page > 1 ? info.page : '' };97  const href = (o: Record<string, string | number | null | undefined>) => `/pipeline${withParams(current, o)}`;98  const side = summary.stages.filter((s) => s.stage === 'withdrawn' || s.stage === 'phase_not_stated');99100  return (101    <div>102      <PageHeader kicker="Derived" title={cancer ? `Development pipeline · ${cancer.canonical_name}` : 'Drug development pipeline'} lede="For each drug, the most advanced stage supported by registered interventional trials and jurisdiction-aware approval records. Approval in any ingested jurisdiction outranks trial phase; a drug is counted once, at its highest stage.">103        <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">104          <ClaimBadge kind="computed" />105          {summary.formulaVersion ? <span className="ci-mono text-ink-3">{summary.formulaVersion}</span> : null}106          <Link className="ci-link" href="/methodology/pipeline">107            Stage rules108          </Link>109          <Link className="ci-link" href="/approvals">110            Approvals feed →111          </Link>112        </p>113      </PageHeader>114115      <form method="get" action="/pipeline" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]">116        <label className="flex flex-col gap-1">117          <span className="ci-kicker">Top-level cancer</span>118          <select name="cancer" defaultValue={cancer?.slug ?? ''} className="max-w-[360px] border border-rule-strong bg-white px-2 py-1.5">119            <option value="">All cancers (one row per drug)</option>120            {options.map((c) => (121              <option key={c.id} value={c.slug}>122                {c.canonical_name} ({fmtInt(c.drugs)})123              </option>124            ))}125          </select>126        </label>127        <label className="flex flex-col gap-1">128          <span className="ci-kicker">Stage</span>129          <select name="stage" defaultValue={stage} className="border border-rule-strong bg-white px-2 py-1.5">130            <option value="">Any</option>131            {PIPELINE_STAGES.map((s) => (132              <option key={s} value={s}>133                {STAGE_LABEL[s]}134              </option>135            ))}136          </select>137        </label>138        <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">139          Apply140        </button>141        {cancer || stage ? (142          <Link className="ci-link text-[12.5px]" href="/pipeline">143            Clear144          </Link>145        ) : null}146      </form>147148      {summary.drugs === 0 ? (149        <div className="mt-4">150          <EmptyState knows={[{ label: 'Approvals feed', href: '/approvals' }, { label: 'Drugs', href: '/drugs' }]}>{cancer ? `No drug is linked to ${cancer.canonical_name} through a registered interventional trial or an approval record yet.` : 'The pipeline has not been computed on this environment yet (pnpm cix intel).'}</EmptyState>151        </div>152      ) : (153        <>154          <Section id="funnel" kicker="Funnel" title={`${fmtInt(summary.drugs)} drugs by highest stage`} description={cancer ? `Drugs reach ${cancer.canonical_name} through trial conditions or approval indications mapped to it or to one of its descendant entities.` : 'Across all cancers: a drug is placed once, at the highest stage it reaches anywhere. Representative drugs are those with the most active trials at that stage.'}>155            <Funnel stages={summary.stages} top={top} hrefFor={(s) => href({ stage: s, page: '' })} />156            {side.some((s) => s.drugs > 0) ? (157              <p className="mt-3 text-[12.5px] text-ink-3">158                Outside the funnel:{' '}159                {side160                  .filter((s) => s.drugs > 0)161                  .map((s, i) => (162                    <span key={s.stage}>163                      {i > 0 ? ' · ' : ''}164                      <Link className="ci-link" href={href({ stage: s.stage, page: '' })}>165                        {STAGE_LABEL[s.stage]}166                      </Link>{' '}167                      <span className="ci-num">{fmtInt(s.drugs)}</span>168                    </span>169                  ))}170                . "Withdrawn" = every approval record for the scope is withdrawn or superseded; "phase not stated" = trials exist but none states a phase.171              </p>172            ) : null}173            <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">174              <ClaimBadge kind="computed" />175              {sources.map((s) => (176                <SourceBadge key={s} p={{ sourceSlug: s, layer: 'derived', note: 'Inputs: trial_interventions × trial_conditions × clinical_trials (interventional) and drug_approvals.' }} />177              ))}178              {summary.formulaVersion ? <span className="ci-mono">{summary.formulaVersion}</span> : null}179            </div>180            <Freshness dataUpdatedAt={summary.computedAt} extra="recomputed with the intelligence layer" />181          </Section>182183          <Section id="drugs" kicker="Drugs" title={`${fmtInt(total)} ${stage ? STAGE_LABEL[stage].toLowerCase() : ''} drug${total === 1 ? '' : 's'}${cancer ? ` · ${cancer.canonical_name}` : ''}`} description="Most advanced stage first, then most active trials. Trial counts are interventional studies linking the drug (and, when a cancer is selected, the cancer).">184            {rows.length === 0 ? (185              <EmptyState compact>No drug at this stage for the selected scope.</EmptyState>186            ) : (187              <>188                <div className="ci-table-wrap">189                  <table className="ci-table">190                    <thead>191                      <tr>192                        <th scope="col">Drug</th>193                        <th scope="col">Stage</th>194                        <th scope="col">Max phase</th>195                        <th scope="col" className="num">Active</th>196                        <th scope="col" className="num">Recruiting</th>197                        <th scope="col" className="num">Phase 3</th>198                        <th scope="col" className="num">Total trials</th>199                        <th scope="col" className="num">Approvals</th>200                        <th scope="col">Jurisdictions</th>201                        <th scope="col">First approval</th>202                        <th scope="col">First trial</th>203                      </tr>204                    </thead>205                    <tbody>206                      {rows.map((r) => (207                        <tr key={r.id}>208                          <td className="min-w-[160px]">209                            <Link className="ci-link font-medium" href={`/drug/${r.drug_slug}#pipeline`}>210                              {r.drug_name}211                            </Link>212                          </td>213                          <td>214                            <StageBadge stage={r.stage} />215                          </td>216                          <td className="text-[12.5px] text-ink-2">{r.max_phase ? phaseLabel(r.max_phase) : '—'}</td>217                          <td className="num">{fmtInt(r.active_trials)}</td>218                          <td className="num">{fmtInt(r.recruiting_trials)}</td>219                          <td className="num">{fmtInt(r.phase3_trials)}</td>220                          <td className="num">{fmtInt(r.total_trials)}</td>221                          <td className="num">{fmtInt(r.approvals)}</td>222                          <td className="ci-mono text-[12px]">{r.jurisdictions.length ? r.jurisdictions.join(', ') : '—'}</td>223                          <td className="whitespace-nowrap">{fmtDate(r.first_approval_date)}</td>224                          <td className="whitespace-nowrap">{r.first_trial_date ? (r.first_trial_date.length >= 10 ? fmtDate(r.first_trial_date) : r.first_trial_date) : '—'}</td>225                        </tr>226                      ))}227                    </tbody>228                  </table>229                </div>230                <Pager total={total} pageSize={PIPELINE_PAGE_SIZE} page={info.page} hrefFor={(p) => href({ page: p > 1 ? p : '' })} label="Pipeline pages" noun="drugs" />231              </>232            )}233            <div className="mt-4">234              <Note tone="warn">Stages describe registered activity and ingested regulatory records, not efficacy. Only ingested jurisdictions (currently US and Canada) can place a drug at "approved"; trial phases are as registered by sponsors. Nothing here is a treatment recommendation.</Note>235            </div>236          </Section>237        </>238      )}239    </div>240  );241}242