SPB Git

Repository Public

Commit

Regulatory layer: Health Canada DPD connector (ATC L01/L02/L03/V10, DIN-level approval records, drug minting + codes), /approvals feed, drug development pipeline (drug_pipeline) with /pipeline, duplicate-drug merge proposals, drug page identifiers/pipeline, API

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Sep 11, 2026 parent acd9e2b
35 changed files +3,266 −17 Browse files
35 changed files
modified apps/api/src/routes/approvals.ts +208 −5
@@ -1,10 +1,213 @@
1 +import { sql, type SQL } from 'drizzle-orm';
1 2 import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
3 +import { z } from 'zod';
4 +import { normalizeLabel } from '@cancerindex/shared';
5 +import { descendantIds } from '../lib/descendants.js';
6 +import { paginate } from '../lib/envelope.js';
7 +import { pageQuery } from '../lib/pagination.js';
8 +import { resolveCancer, resolveDrug } from '../lib/resolve.js';
9 +import { AnyList, AnyRecord, camel, num, ok, respond } from '../lib/respond.js';
10 +import { pluck } from '../lib/sources.js';
11 +
12 +const PIPELINE_STAGES = ['phase_not_stated', 'phase1', 'phase2', 'phase3', 'phase4', 'approved', 'withdrawn'] as const;
13 +const STAGE_RANK_SQL = sql.raw(`CASE p.stage WHEN 'approved' THEN 6 WHEN 'phase4' THEN 5 WHEN 'phase3' THEN 4 WHEN 'phase2' THEN 3 WHEN 'phase1' THEN 2 WHEN 'phase_not_stated' THEN 1 WHEN 'withdrawn' THEN 0 ELSE -1 END`);
14 +
15 +/** "2024" → "2024-01-01" (from) / "2024-12-31" (to); full ISO dates pass through. */
16 +function dateBound(v: string | undefined, edge: 'from' | 'to'): string | undefined {
17 + if (!v) return undefined;
18 + if (/^\d{4}$/.test(v)) return edge === 'from' ? `${v}-01-01` : `${v}-12-31`;
19 + return v;
20 +}
21 +
22 +const approvalSelect = (where: SQL, orderLimit: SQL) => sql`
23 + SELECT a.id, a.drug_id, a.cancer_id, a.biomarker_ids, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, a.line_of_therapy, a.disease_stage,
24 + a.approval_type, a.accelerated, a.conditional, a.approval_date, a.withdrawal_date, a.status, a.application_number, a.source_id, a.updated_at,
25 + a.raw->>'dpdStatus' AS source_status,
26 + d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name,
27 + p.source_url, p.retrieved_at, p.dataset, p.dataset_version, count(*) OVER() AS total
28 + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id LEFT JOIN provenance p ON p.id = a.provenance_id
29 + WHERE ${where} ${orderLimit}`;
30 +
31 +function shapeApproval(r: Record<string, unknown>): Record<string, unknown> {
32 + const { total: _t, drug_slug, drug_name, cancer_slug, cancer_name, source_url, retrieved_at, dataset, dataset_version, source_status, ...rest } = r;
33 + return {
34 + ...camel(rest),
35 + sourceStatus: source_status ?? null,
36 + drug: { id: r.drug_id, slug: drug_slug, name: drug_name },
37 + cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null,
38 + provenance: { sourceId: r.source_id, url: source_url, retrievedAt: retrieved_at, dataset, datasetVersion: dataset_version, category: 'regulatory_status' },
39 + };
40 +}
2 41
3 42 /**
4 − * Regulatory routes (SPEC §13-15, §63): `GET /approvals` (jurisdiction-aware, dated, filterable by
5 − * authority, cancer, drug, date range), `GET /approvals/recent`, `GET /pipeline` (drug development
6 − * stages). Filled by the Approvals & Pipeline work package.
43 + * Regulatory routes (SPEC §13-15, §63): jurisdiction-aware, dated approval records (one record = one
44 + * authority's decision for one application / DIN — never a bare "approved"), a recent feed grouped
45 + * by month, and the derived drug development pipeline (`drug_pipeline`, formula-versioned).
7 46 */
8 −export const approvalRoutes: FastifyPluginAsyncZod = async (_app) => {
9 − /* routes added by the approvals work package */
47 +export const approvalRoutes: FastifyPluginAsyncZod = async (app) => {
48 + app.get(
49 + '/approvals',
50 + {
51 + schema: {
52 + tags: ['regulatory'],
53 + summary: 'Approval records (jurisdiction-aware, dated), filterable by authority, jurisdiction, cancer (with descendants), drug, status, date range, text',
54 + querystring: z.object({
55 + authority: z.string().trim().min(1).max(60).optional().describe('e.g. FDA, Health Canada'),
56 + jurisdiction: z.string().trim().min(2).max(8).optional().describe('e.g. US, CA'),
57 + cancer: z.string().trim().min(1).optional().describe('CI-CAN id or slug; descendants included'),
58 + drug: z.string().trim().min(1).optional().describe('CI-DRUG id or slug'),
59 + status: z.enum(['approved', 'accelerated', 'conditional', 'withdrawn', 'superseded']).optional(),
60 + from: z.string().regex(/^\d{4}(-\d{2}-\d{2})?$/).optional().describe('approval_date ≥ (YYYY or YYYY-MM-DD)'),
61 + to: z.string().regex(/^\d{4}(-\d{2}-\d{2})?$/).optional().describe('approval_date ≤ (YYYY or YYYY-MM-DD)'),
62 + q: z.string().trim().min(1).max(100).optional().describe('drug name/alias or indication text'),
63 + ...pageQuery,
64 + }),
65 + response: ok(AnyList, true),
66 + },
67 + },
68 + async (req) => {
69 + const q = req.query;
70 + const conds: SQL[] = [sql`true`];
71 + if (q.authority) conds.push(sql`lower(a.authority) = lower(${q.authority})`);
72 + if (q.jurisdiction) conds.push(sql`upper(a.jurisdiction) = upper(${q.jurisdiction})`);
73 + if (q.status) conds.push(sql`a.status = ${q.status}`);
74 + const from = dateBound(q.from, 'from');
75 + const to = dateBound(q.to, 'to');
76 + if (from) conds.push(sql`a.approval_date >= ${from}`);
77 + if (to) conds.push(sql`a.approval_date <= ${to}`);
78 + if (q.cancer) {
79 + const c = await resolveCancer(app.db, q.cancer);
80 + const ids = await descendantIds(app.db, c.id);
81 + conds.push(sql`a.cancer_id = ANY(${sql.param(ids)}::text[])`);
82 + }
83 + if (q.drug) {
84 + const d = await resolveDrug(app.db, q.drug);
85 + conds.push(sql`a.drug_id = ${d.id}`);
86 + }
87 + if (q.q) {
88 + const norm = normalizeLabel(q.q);
89 + conds.push(sql`(EXISTS (SELECT 1 FROM drug_aliases al WHERE al.drug_id = a.drug_id AND (al.normalized = ${norm} OR al.normalized LIKE ${norm + '%'})) OR a.indication ILIKE ${'%' + q.q + '%'})`);
90 + }
91 + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(approvalSelect(sql.join(conds, sql` AND `), sql`ORDER BY a.approval_date DESC NULLS LAST, a.id DESC LIMIT ${q.limit} OFFSET ${q.offset}`));
92 + const total = rows.length ? num(rows[0]!.total) : 0;
93 + return respond(app, rows.map(shapeApproval), pluck(rows, 'source_id'), paginate(total, q.limit, q.offset));
94 + },
95 + );
96 +
97 + app.get(
98 + '/approvals/recent',
99 + {
100 + schema: {
101 + tags: ['regulatory'],
102 + summary: 'Latest dated approval records across authorities, grouped by month (data.months)',
103 + querystring: z.object({ days: z.coerce.number().int().min(1).max(3650).default(90), limit: z.coerce.number().int().min(1).max(500).default(100), authority: z.string().trim().min(1).max(60).optional() }),
104 + response: ok(AnyRecord),
105 + },
106 + },
107 + async (req) => {
108 + const { days, limit, authority } = req.query;
109 + const since = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
110 + const conds: SQL[] = [sql`a.approval_date IS NOT NULL`, sql`a.approval_date >= ${since}`, sql`a.approval_date <= to_char(now(), 'YYYY-MM-DD')`];
111 + if (authority) conds.push(sql`lower(a.authority) = lower(${authority})`);
112 + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(approvalSelect(sql.join(conds, sql` AND `), sql`ORDER BY a.approval_date DESC, a.id DESC LIMIT ${limit}`));
113 + const total = rows.length ? num(rows[0]!.total) : 0;
114 + const months = new Map<string, Record<string, unknown>[]>();
115 + for (const r of rows) {
116 + const m = String(r.approval_date).slice(0, 7);
117 + months.set(m, [...(months.get(m) ?? []), shapeApproval(r)]);
118 + }
119 + const data = {
120 + since,
121 + days,
122 + total,
123 + returned: rows.length,
124 + months: [...months.entries()].map(([month, approvals]) => ({ month, count: approvals.length, approvals })),
125 + };
126 + return respond(app, data, pluck(rows, 'source_id'));
127 + },
128 + );
129 +
130 + app.get(
131 + '/pipeline',
132 + {
133 + schema: {
134 + tags: ['regulatory'],
135 + summary: 'Drug development pipeline rows (derived): stage per drug, or per drug × top-level cancer',
136 + querystring: z.object({
137 + cancer: z.string().trim().min(1).optional().describe('Top-level cancer (CI-CAN id or slug): drug × cancer rows'),
138 + stage: z.enum(PIPELINE_STAGES).optional(),
139 + drug: z.string().trim().min(1).optional().describe('CI-DRUG id or slug'),
140 + scope: z.enum(['drug', 'cancer']).default('drug').describe('Without `cancer`: drug = across-all-cancers rows (default), cancer = every drug × top-level cancer row'),
141 + ...pageQuery,
142 + }),
143 + response: ok(AnyList, true),
144 + },
145 + },
146 + async (req) => {
147 + const q = req.query;
148 + const conds: SQL[] = [];
149 + if (q.cancer) {
150 + const c = await resolveCancer(app.db, q.cancer);
151 + conds.push(sql`p.cancer_id = ${c.id}`);
152 + } else conds.push(q.scope === 'cancer' ? sql`p.cancer_id IS NOT NULL` : sql`p.cancer_id IS NULL`);
153 + if (q.stage) conds.push(sql`p.stage = ${q.stage}`);
154 + if (q.drug) {
155 + const d = await resolveDrug(app.db, q.drug);
156 + conds.push(sql`p.drug_id = ${d.id}`);
157 + }
158 + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql`
159 + SELECT p.id, p.drug_id, p.cancer_id, p.stage, p.max_phase, p.active_trials, p.recruiting_trials, p.phase3_trials, p.total_trials, p.approvals, p.jurisdictions,
160 + p.first_approval_date, p.latest_approval_date, p.first_trial_date, p.formula_version, p.inputs, p.updated_at AS computed_at,
161 + d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, count(*) OVER() AS total
162 + FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id
163 + WHERE ${sql.join(conds, sql` AND `)}
164 + ORDER BY ${STAGE_RANK_SQL} DESC, p.active_trials DESC, p.total_trials DESC, d.name LIMIT ${q.limit} OFFSET ${q.offset}`);
165 + const total = rows.length ? num(rows[0]!.total) : 0;
166 + const data = rows.map((r) => {
167 + const { total: _t, drug_slug, drug_name, cancer_slug, cancer_name, ...rest } = r;
168 + return { ...camel(rest), drug: { id: r.drug_id, slug: drug_slug, name: drug_name }, cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, category: 'computed_metric' };
169 + });
170 + return respond(app, data, await pipelineSources(rows.map((r) => r.drug_id as string)), paginate(total, q.limit, q.offset));
171 + },
172 + );
173 +
174 + app.get(
175 + '/pipeline/summary',
176 + {
177 + schema: {
178 + tags: ['regulatory'],
179 + summary: 'Drug counts per development stage (derived), globally or for one top-level cancer',
180 + querystring: z.object({ cancer: z.string().trim().min(1).optional() }),
181 + response: ok(AnyRecord),
182 + },
183 + },
184 + async (req) => {
185 + let cancer: { id: string; slug: string } | null = null;
186 + if (req.query.cancer) cancer = await resolveCancer(app.db, req.query.cancer);
187 + const where = cancer ? sql`p.cancer_id = ${cancer.id}` : sql`p.cancer_id IS NULL`;
188 + const rows = await app.db.execute<{ stage: string; drugs: string; active_trials: string; total_trials: string }>(sql`
189 + SELECT p.stage, count(*) AS drugs, coalesce(sum(p.active_trials), 0) AS active_trials, coalesce(sum(p.total_trials), 0) AS total_trials
190 + FROM drug_pipeline p WHERE ${where} GROUP BY p.stage`);
191 + const meta = await app.db.execute<{ formula_version: string | null; computed_at: Date | null; rows: string }>(sql`SELECT max(formula_version) AS formula_version, max(updated_at) AS computed_at, count(*) AS rows FROM drug_pipeline`);
192 + const byStage = Object.fromEntries(PIPELINE_STAGES.map((s) => [s, { drugs: 0, activeTrials: 0, totalTrials: 0 }]));
193 + for (const r of rows) byStage[r.stage] = { drugs: num(r.drugs), activeTrials: num(r.active_trials), totalTrials: num(r.total_trials) };
194 + const data = {
195 + cancer: cancer ? { id: cancer.id, slug: cancer.slug } : null,
196 + stages: PIPELINE_STAGES.map((stage) => ({ stage, ...byStage[stage]! })),
197 + drugs: rows.reduce((s, r) => s + num(r.drugs), 0),
198 + formulaVersion: meta[0]?.formula_version ?? null,
199 + computedAt: meta[0]?.computed_at ?? null,
200 + category: 'computed_metric',
201 + };
202 + return respond(app, data, await pipelineSources([]));
203 + },
204 + );
205 +
206 + /** Sources behind derived pipeline rows: the trial registry plus the regulatory sources of the drugs' approvals. */
207 + async function pipelineSources(drugIds: string[]): Promise<string[]> {
208 + const regs = drugIds.length
209 + ? await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_approvals WHERE drug_id = ANY(${sql.param(drugIds)}::text[])`)
210 + : await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_approvals`);
211 + return ['clinicaltrials', ...pluck(regs, 'source_id')];
212 + }
10 213 };
added apps/web/src/app/approvals/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading oncology approvals" />;
5 +}
added apps/web/src/app/approvals/page.tsx +275 −0
@@ -0,0 +1,275 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Section, Note } from '@/components/ui/section';
4 +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';
5 +import { EmptyState } from '@/components/ui/empty-state';
6 +import { Freshness } from '@/components/ui/freshness';
7 +import { Pager } from '@/components/ui/pager';
8 +import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';
9 +import { APPROVALS_PAGE_SIZE, approvalFacets, approvalStats, countApprovals, listApprovals, type ApprovalFilters, type FeedApprovalRow } from '@/lib/queries/approvals';
10 +import { loadProvenance, toInfo } from '@/lib/queries/provenance';
11 +import { fmtDate, fmtInt, truncate } from '@/lib/format';
12 +import { pageInfo } from '@/lib/pagination';
13 +import { str, int, withParams, type SP } from '@/lib/search-params';
14 +
15 +export const metadata: Metadata = {
16 + title: 'Oncology approvals',
17 + description: 'Regulatory approval records for oncology drugs by authority and jurisdiction — dated, sourced, one record per application or DIN.',
18 + alternates: { canonical: '/approvals' },
19 +};
20 +// Filtered feed: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by list pages.
21 +export const revalidate = 600;
22 +
23 +const STATUSES = ['approved', 'accelerated', 'conditional', 'withdrawn', 'superseded'];
24 +
25 +function monthKey(d: string | null): string {
26 + return d && /^\d{4}-\d{2}/.test(d) ? d.slice(0, 7) : 'undated';
27 +}
28 +function monthLabel(k: string): string {
29 + return k === 'undated' ? 'Date not stated' : fmtDate(`${k}-01`, { year: 'numeric', month: 'long' });
30 +}
31 +
32 +export default async function ApprovalsPage({ searchParams }: { searchParams: Promise<SP> }) {
33 + const sp = await searchParams;
34 + const f: ApprovalFilters = { authority: str(sp, 'authority'), jurisdiction: str(sp, 'jurisdiction'), cancer: str(sp, 'cancer'), status: STATUSES.includes(str(sp, 'status')) ? str(sp, 'status') : '', year: /^\d{4}$/.test(str(sp, 'year')) ? str(sp, 'year') : '', q: str(sp, 'q').slice(0, 100) };
35 + const requestedPage = int(sp, 'page', 1, 1, 100_000);
36 + const [stats, facets, total] = await Promise.all([approvalStats(), approvalFacets(), countApprovals(f)]);
37 + const info = pageInfo(requestedPage, APPROVALS_PAGE_SIZE, total);
38 + const rows = total ? await listApprovals(f, info.page) : [];
39 + const prov = await loadProvenance(rows.map((r) => r.provenance_id));
40 + const filtered = Object.values(f).some(Boolean);
41 + const href = (o: Record<string, string | number | null | undefined>) => `/approvals${withParams({ ...f, page: info.page > 1 ? info.page : '' }, o)}`;
42 +
43 + // Group the page's rows by month, latest first (rows arrive sorted by approval_date desc).
44 + const groups: Array<{ key: string; rows: FeedApprovalRow[] }> = [];
45 + for (const r of rows) {
46 + const key = monthKey(r.approval_date);
47 + const g = groups[groups.length - 1];
48 + if (g && g.key === key) g.rows.push(r);
49 + else groups.push({ key, rows: [r] });
50 + }
51 + const latestUpdate = stats.byAuthority.reduce<Date | null>((m, a) => (a.updated_at && (!m || new Date(a.updated_at) > m) ? new Date(a.updated_at) : m), null);
52 +
53 + return (
54 + <div>
55 + <PageHeader kicker="Regulatory" title="Oncology approvals" lede="Every record is one authority's decision for one application or DIN, shown with its jurisdiction, date, indication text as published and status. A molecule approved in one jurisdiction for one indication is not 'approved' in general.">
56 + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">
57 + <ClaimBadge kind="regulatory" />
58 + <Link className="ci-link" href="/pipeline">
59 + Development pipeline →
60 + </Link>
61 + <Link className="ci-link" href="/methodology/pipeline">
62 + Methodology
63 + </Link>
64 + </p>
65 + </PageHeader>
66 +
67 + {stats.total === 0 ? (
68 + <EmptyState knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Sources', href: '/sources' }]}>No regulatory connector has run on this environment yet.</EmptyState>
69 + ) : (
70 + <>
71 + <Section id="overview" kicker="Coverage" title="Approval records by authority" description="Counts of records, not of drugs approved: one application can carry several dated records (original approval, efficacy supplements, one row per DIN in Canada).">
72 + <div className="grid gap-3 sm:grid-cols-4">
73 + {[
74 + { k: 'Approval records', v: stats.total },
75 + { k: 'Distinct drugs', v: stats.distinctDrugs },
76 + { k: 'Records naming a cancer', v: stats.withCancer },
77 + { k: 'Dated in the last 12 months', v: stats.last12m },
78 + ].map((t) => (
79 + <div key={t.k} className="border border-rule bg-paper-2 px-3 py-2">
80 + <p className="ci-kicker">{t.k}</p>
81 + <p className="ci-num text-xl font-medium">{fmtInt(t.v)}</p>
82 + </div>
83 + ))}
84 + </div>
85 + <div className="ci-table-wrap mt-3">
86 + <table className="ci-table">
87 + <thead>
88 + <tr>
89 + <th scope="col">Authority</th>
90 + <th scope="col">Jurisdiction</th>
91 + <th scope="col" className="num">Records</th>
92 + <th scope="col" className="num">Drugs</th>
93 + <th scope="col" className="num">In force</th>
94 + <th scope="col" className="num">Withdrawn / cancelled</th>
95 + <th scope="col" className="num">Last 12 months</th>
96 + <th scope="col">Latest dated record</th>
97 + </tr>
98 + </thead>
99 + <tbody>
100 + {stats.byAuthority.map((a) => (
101 + <tr key={`${a.authority}-${a.jurisdiction}`}>
102 + <td>
103 + <Link className="ci-link" href={href({ authority: a.authority, jurisdiction: a.jurisdiction, page: '' })}>
104 + {a.authority}
105 + </Link>
106 + </td>
107 + <td className="ci-mono">{a.jurisdiction}</td>
108 + <td className="num">{fmtInt(a.n)}</td>
109 + <td className="num">{fmtInt(a.distinct_drugs)}</td>
110 + <td className="num">{fmtInt(a.approved_like)}</td>
111 + <td className="num">{fmtInt(a.withdrawn)}</td>
112 + <td className="num">{fmtInt(a.last_12m)}</td>
113 + <td className="whitespace-nowrap">{fmtDate(a.latest_date)}</td>
114 + </tr>
115 + ))}
116 + </tbody>
117 + </table>
118 + </div>
119 + <Freshness dataUpdatedAt={latestUpdate} extra={`${stats.byAuthority.length} authority · jurisdiction pair${stats.byAuthority.length === 1 ? '' : 's'} ingested`} />
120 + </Section>
121 +
122 + <Section id="feed" kicker="Feed" title={`Approval records (${fmtInt(total)})`} description="Latest approval date first; records without a date are listed last. Filters combine.">
123 + <form method="get" action="/approvals" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]">
124 + <label className="flex flex-col gap-1">
125 + <span className="ci-kicker">Authority</span>
126 + <select name="authority" defaultValue={f.authority} className="border border-rule-strong bg-white px-2 py-1.5">
127 + <option value="">Any</option>
128 + {[...new Map(facets.authorities.map((a) => [a.authority, a])).values()].map((a) => (
129 + <option key={a.authority} value={a.authority}>
130 + {a.authority}
131 + </option>
132 + ))}
133 + </select>
134 + </label>
135 + <label className="flex flex-col gap-1">
136 + <span className="ci-kicker">Jurisdiction</span>
137 + <select name="jurisdiction" defaultValue={f.jurisdiction} className="border border-rule-strong bg-white px-2 py-1.5">
138 + <option value="">Any</option>
139 + {[...new Set(facets.authorities.map((a) => a.jurisdiction))].sort().map((j) => (
140 + <option key={j} value={j}>
141 + {j}
142 + </option>
143 + ))}
144 + </select>
145 + </label>
146 + <label className="flex flex-col gap-1">
147 + <span className="ci-kicker">Status</span>
148 + <select name="status" defaultValue={f.status} className="border border-rule-strong bg-white px-2 py-1.5">
149 + <option value="">Any</option>
150 + {facets.statuses.map((s) => (
151 + <option key={s.status} value={s.status}>
152 + {s.status} ({fmtInt(s.n)})
153 + </option>
154 + ))}
155 + </select>
156 + </label>
157 + <label className="flex flex-col gap-1">
158 + <span className="ci-kicker">Year</span>
159 + <select name="year" defaultValue={f.year} className="border border-rule-strong bg-white px-2 py-1.5">
160 + <option value="">Any</option>
161 + {facets.years.map((y) => (
162 + <option key={y.year} value={y.year}>
163 + {y.year} ({fmtInt(y.n)})
164 + </option>
165 + ))}
166 + </select>
167 + </label>
168 + <label className="flex flex-col gap-1">
169 + <span className="ci-kicker">Cancer slug</span>
170 + <input name="cancer" defaultValue={f.cancer} placeholder="e.g. malignant-lung-neoplasm" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />
171 + </label>
172 + <label className="flex flex-col gap-1">
173 + <span className="ci-kicker">Drug or indication text</span>
174 + <input name="q" defaultValue={f.q} placeholder="e.g. osimertinib, Keytruda, NSCLC" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />
175 + </label>
176 + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">
177 + Apply
178 + </button>
179 + {filtered ? (
180 + <Link className="ci-link text-[12.5px]" href="/approvals">
181 + Clear
182 + </Link>
183 + ) : null}
184 + </form>
185 +
186 + {rows.length === 0 ? (
187 + <div className="mt-3">
188 + <EmptyState title={filtered ? 'No approval record matches' : 'Data not yet available'} knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Pipeline', href: '/pipeline' }]}>
189 + {filtered ? 'Try fewer filters. Cancer filters only match records whose indication text named exactly one cancer; Health Canada records never name a cancer.' : 'No regulatory approval records have been ingested yet.'}
190 + </EmptyState>
191 + </div>
192 + ) : (
193 + <>
194 + {groups.map((g) => {
195 + const first = g.rows[0]!;
196 + const caption = toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name };
197 + const sources = new Set(g.rows.map((r) => r.source_slug));
198 + return (
199 + <section key={g.key} aria-labelledby={`m-${g.key}`} className="mt-5">
200 + <h3 id={`m-${g.key}`} className="text-lg">
201 + {monthLabel(g.key)} <span className="text-[13px] font-normal text-ink-3">· {fmtInt(g.rows.length)} record{g.rows.length === 1 ? '' : 's'} on this page</span>
202 + </h3>
203 + <TableProvenance p={caption} claim={<ClaimBadge kind="regulatory" />}>
204 + {sources.size > 1 ? `${sources.size} sources in this month (hover a row badge for its dataset)` : 'authority, jurisdiction, dates and indication text as published'}
205 + </TableProvenance>
206 + <div className="ci-table-wrap">
207 + <table className="ci-table">
208 + <thead>
209 + <tr>
210 + <th scope="col">Date</th>
211 + <th scope="col">Drug</th>
212 + <th scope="col">Authority · jurisdiction</th>
213 + <th scope="col">Cancer</th>
214 + <th scope="col">Indication (as published)</th>
215 + <th scope="col">Type</th>
216 + <th scope="col">Status</th>
217 + <th scope="col">Source</th>
218 + </tr>
219 + </thead>
220 + <tbody>
221 + {g.rows.map((a) => (
222 + <tr key={a.id}>
223 + <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td>
224 + <td className="min-w-[140px]">
225 + <Link className="ci-link font-medium" href={`/drug/${a.drug_slug}`}>
226 + {a.drug_name}
227 + </Link>
228 + </td>
229 + <td className="whitespace-nowrap">
230 + <span className="text-ink-2">{a.authority}</span> <Badge mono tone="outline">{a.jurisdiction}</Badge>
231 + </td>
232 + <td className="min-w-[160px] text-[12.5px]">
233 + {a.tumor_agnostic ? <Badge tone="accent">Tumor-agnostic</Badge> : null}
234 + {a.cancer_slug ? (
235 + <Link className="ci-link" href={`/cancer/${a.cancer_slug}`}>
236 + {a.cancer_name}
237 + </Link>
238 + ) : !a.tumor_agnostic ? (
239 + <span className="text-ink-3">cancer not stated in this record</span>
240 + ) : null}
241 + </td>
242 + <td className="min-w-[260px] max-w-[460px] text-[12.5px]" title={a.indication}>
243 + {truncate(a.indication, 140)}
244 + </td>
245 + <td className="ci-mono text-[12px]">{a.approval_type ?? '—'}{a.application_number ? <span className="block text-ink-3">{a.application_number}</span> : null}</td>
246 + <td>
247 + <StatusBadge status={a.status} />
248 + {a.source_status && a.source_status.toLowerCase() !== a.status ? <span className="block text-[11px] text-ink-3">source: {a.source_status}</span> : null}
249 + {a.withdrawal_date ? <span className="block text-[11px] text-danger">since {fmtDate(a.withdrawal_date)}</span> : null}
250 + </td>
251 + <td>
252 + <SourceBadge compact p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} />
253 + </td>
254 + </tr>
255 + ))}
256 + </tbody>
257 + </table>
258 + </div>
259 + </section>
260 + );
261 + })}
262 + <Pager total={total} pageSize={APPROVALS_PAGE_SIZE} page={info.page} hrefFor={(p) => href({ page: p > 1 ? p : '' })} label="Approval pages" noun="approval records" />
263 + </>
264 + )}
265 + <div className="mt-4">
266 + <Note>
267 + A record is one authority's decision for one application/DIN; the same molecule appears once per jurisdiction and indication. Health Canada DPD does not publish indications — its records state the brand, DIN and ATC class only, and a cancelled or dormant DIN is the status of one product, not of the molecule. FDA cancer mappings come from label text and are flagged probabilistic on the drug page.
268 + </Note>
269 + </div>
270 + </Section>
271 + </>
272 + )}
273 + </div>
274 + );
275 +}
modified apps/web/src/app/drug/[slug]/page.tsx +143 −4
@@ -3,24 +3,65 @@ import Link from 'next/link';
3 3 import { notFound } from 'next/navigation';
4 4 import { ExternalLink } from 'lucide-react';
5 5 import { PageHeader, Section, KV, Note } from '@/components/ui/section';
6 −import { Badge } from '@/components/ui/badge';
6 +import { Badge, ClaimBadge } from '@/components/ui/badge';
7 7 import { EmptyState } from '@/components/ui/empty-state';
8 8 import { Freshness } from '@/components/ui/freshness';
9 9 import { Pager } from '@/components/ui/pager';
10 +import { SourceBadge } from '@/components/ui/source-badge';
10 11 import { ApprovalsTable } from '@/components/data/approvals-table';
11 12 import { EvidenceTable } from '@/components/data/evidence-table';
12 13 import { TrialTable } from '@/components/data/trial-list';
13 −import { getDrugBySlug, approvalsForDrug } from '@/lib/queries/drugs';
14 +import { getDrugBySlug, approvalsForDrug, codesForDrug, pipelineForDrug, type DrugCodeRow } from '@/lib/queries/drugs';
14 15 import { evidenceForDrug, evidenceForDrugCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';
15 16 import { trialsForDrug, trialsForDrugCount, TRIAL_PAGE_SIZE } from '@/lib/queries/trials';
16 17 import { loadProvenance } from '@/lib/queries/provenance';
17 18 import { jsonLd, drugLd } from '@/lib/seo';
18 −import { fmtInt, humanize } from '@/lib/format';
19 +import { fmtDate, fmtInt, humanize, phaseLabel } from '@/lib/format';
19 20 import { pageInfo } from '@/lib/pagination';
20 21 import { str, int, withParams, type SP } from '@/lib/search-params';
21 22
22 23 export const revalidate = 3600;
23 24
25 +const CODE_SYSTEMS: Record<string, string> = {
26 + atc: 'ATC (WHO)',
27 + din: 'DIN (Health Canada)',
28 + hc_drug_code: 'DPD drug code',
29 + unii: 'UNII (FDA GSRS)',
30 + rxcui: 'RxCUI (RxNorm)',
31 + ncit: 'NCIt',
32 + chembl: 'ChEMBL',
33 + drugbank: 'DrugBank',
34 + pubchem_cid: 'PubChem CID',
35 + civic_therapy: 'CIViC therapy',
36 + ema_product: 'EMA product',
37 + mhra: 'MHRA',
38 + tga: 'TGA',
39 +};
40 +function codeSystemLabel(system: string): string {
41 + return CODE_SYSTEMS[system] ?? humanize(system);
42 +}
43 +
44 +/** External link for code systems with a stable public URL pattern; plain text otherwise. */
45 +function CodeLink({ c, codes }: { c: DrugCodeRow; codes: DrugCodeRow[] }) {
46 + let url: string | null = null;
47 + if (c.system === 'atc') url = `https://atcddd.fhi.no/atc_ddd_index/?code=${encodeURIComponent(c.code)}`;
48 + else if (c.system === 'hc_drug_code') url = `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=${encodeURIComponent(c.code)}`;
49 + else if (c.system === 'din') {
50 + // The DPD public page is keyed by drug code, not DIN: reuse the hc_drug_code row that carries the same brand label.
51 + const dc = codes.find((k) => k.system === 'hc_drug_code' && k.label === c.label);
52 + url = dc ? `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=${encodeURIComponent(dc.code)}` : null;
53 + } else if (c.system === 'chembl') url = `https://www.ebi.ac.uk/chembl/compound_report_card/${encodeURIComponent(c.code)}/`;
54 + else if (c.system === 'ncit') url = `https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${encodeURIComponent(c.code)}`;
55 + else if (c.system === 'pubchem_cid') url = `https://pubchem.ncbi.nlm.nih.gov/compound/${encodeURIComponent(c.code)}`;
56 + else if (c.system === 'drugbank') url = `https://go.drugbank.com/drugs/${encodeURIComponent(c.code)}`;
57 + if (!url) return <>{c.code}</>;
58 + return (
59 + <a className="ci-link inline-flex items-center gap-1" href={url} target="_blank" rel="noopener noreferrer">
60 + {c.code} <ExternalLink className="h-3 w-3" aria-hidden />
61 + </a>
62 + );
63 +}
64 +
24 65 export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
25 66 const d = await getDrugBySlug((await params).slug);
26 67 return d ? { title: `${d.name} — drug`, description: d.description ?? `${d.name}: regulatory approvals by jurisdiction, curated evidence and clinical trials.`, alternates: { canonical: `/drug/${d.slug}` } } : { title: 'Drug' };
@@ -35,7 +76,8 @@ export default async function DrugPage({ params, searchParams }: { params: Promi
35 76 const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);
36 77 const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, tTotal);
37 78 const aPage = int(sp, 'aPage', 1, 1, 100_000);
38 − const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([])]);
79 + const [approvals, evidence, trials, codes, pipeline] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([]), codesForDrug(d.id), pipelineForDrug(d.id)]);
80 + const hasCanada = approvals.some((a) => a.jurisdiction === 'CA');
39 81 const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]);
40 82 const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort();
41 83 const wanted = approvals.length ? str(sp, 'jurisdiction') : '';
@@ -91,6 +133,11 @@ export default async function DrugPage({ params, searchParams }: { params: Promi
91 133 ))}
92 134 </nav>
93 135 <ApprovalsTable rows={shown} prov={prov} showDrug={false} page={aPage} hrefFor={(p) => href({ aPage: p > 1 ? p : '' }, 'approvals')} />
136 + {hasCanada ? (
137 + <p className="mt-2 text-[12px] text-ink-3">
138 + Health Canada records are DIN-level: one row per marketed product (brand, strength, form). The Drug Product Database does not publish indications, so no cancer is stated for these rows, and a cancelled or dormant DIN is the status of that one product — not a withdrawal of the molecule.
139 + </p>
140 + ) : null}
94 141 <Freshness dataUpdatedAt={approvals.reduce<Date | string | null>((m, a) => (m == null || String(a.updated_at) > String(m) ? a.updated_at : m), null)} />
95 142 </>
96 143 ) : (
@@ -100,6 +147,70 @@ export default async function DrugPage({ params, searchParams }: { params: Promi
100 147 )}
101 148 </Section>
102 149
150 + <Section id="pipeline" kicker="Derived" title="Development pipeline" description="Most advanced stage across all cancers, then per top-level cancer reached through trial conditions or approval indications. Approval in any ingested jurisdiction outranks trial phase; counts are interventional studies.">
151 + {pipeline.length ? (
152 + <>
153 + <div className="ci-table-wrap">
154 + <table className="ci-table">
155 + <thead>
156 + <tr>
157 + <th scope="col">Scope</th>
158 + <th scope="col">Stage</th>
159 + <th scope="col">Max phase</th>
160 + <th scope="col" className="num">Active</th>
161 + <th scope="col" className="num">Recruiting</th>
162 + <th scope="col" className="num">Phase 3</th>
163 + <th scope="col" className="num">Trials</th>
164 + <th scope="col" className="num">Approvals</th>
165 + <th scope="col">Jurisdictions</th>
166 + <th scope="col">First approval</th>
167 + <th scope="col">First trial</th>
168 + </tr>
169 + </thead>
170 + <tbody>
171 + {pipeline.map((p) => (
172 + <tr key={p.id}>
173 + <td className="min-w-[160px]">
174 + {p.cancer_slug ? (
175 + <Link className="ci-link" href={`/pipeline?cancer=${p.cancer_slug}`}>
176 + {p.cancer_name}
177 + </Link>
178 + ) : (
179 + <span className="font-medium">All cancers</span>
180 + )}
181 + </td>
182 + <td>
183 + <Badge tone={p.stage === 'approved' ? 'ok' : p.stage === 'withdrawn' ? 'danger' : p.stage === 'phase_not_stated' ? 'outline' : 'neutral'}>{humanize(p.stage)}</Badge>
184 + </td>
185 + <td className="text-[12.5px] text-ink-2">{p.max_phase ? phaseLabel(p.max_phase) : '—'}</td>
186 + <td className="num">{fmtInt(p.active_trials)}</td>
187 + <td className="num">{fmtInt(p.recruiting_trials)}</td>
188 + <td className="num">{fmtInt(p.phase3_trials)}</td>
189 + <td className="num">{fmtInt(p.total_trials)}</td>
190 + <td className="num">{fmtInt(p.approvals)}</td>
191 + <td className="ci-mono text-[12px]">{p.jurisdictions.length ? p.jurisdictions.join(', ') : '—'}</td>
192 + <td className="whitespace-nowrap">{fmtDate(p.first_approval_date)}</td>
193 + <td className="whitespace-nowrap">{p.first_trial_date ? (p.first_trial_date.length >= 10 ? fmtDate(p.first_trial_date) : p.first_trial_date) : '—'}</td>
194 + </tr>
195 + ))}
196 + </tbody>
197 + </table>
198 + </div>
199 + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">
200 + <ClaimBadge kind="computed" />
201 + <SourceBadge p={{ sourceSlug: 'clinicaltrials', layer: 'derived', note: 'Inputs: interventional trials linking this drug (trial_interventions × trial_conditions) and its approval records.' }} />
202 + <span className="ci-mono">{pipeline[0]!.formula_version}</span>
203 + <Link className="ci-link" href="/methodology/pipeline">
204 + Stage rules
205 + </Link>
206 + </div>
207 + <Freshness dataUpdatedAt={pipeline[0]!.computed_at} />
208 + </>
209 + ) : (
210 + <EmptyState compact>No registered interventional trial or approval record places this drug in the pipeline yet.</EmptyState>
211 + )}
212 + </Section>
213 +
103 214 <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items in which this therapy appears, grouped by cancer context, then molecular profile. ${EVIDENCE_PAGE_SIZE} items per page.`}>
104 215 {evidence.length ? (
105 216 <>
@@ -153,6 +264,34 @@ export default async function DrugPage({ params, searchParams }: { params: Promi
153 264 />
154 265 <Freshness dataUpdatedAt={d.updated_at} />
155 266 </Section>
267 + <Section id="identifiers" kicker="Identifiers" title={`Codes (${fmtInt(codes.length)})`} level={3} description="Upstream identifiers kept as first-class codes; each links to its registry where a stable public URL exists.">
268 + {codes.length ? (
269 + <div className="ci-table-wrap">
270 + <table className="ci-table">
271 + <thead>
272 + <tr>
273 + <th scope="col">System</th>
274 + <th scope="col">Code</th>
275 + <th scope="col">Label</th>
276 + </tr>
277 + </thead>
278 + <tbody>
279 + {codes.map((c) => (
280 + <tr key={c.id}>
281 + <td className="whitespace-nowrap text-[12.5px] text-ink-2">{codeSystemLabel(c.system)}</td>
282 + <td className="ci-mono text-[12px]">
283 + <CodeLink c={c} codes={codes} />
284 + </td>
285 + <td className="max-w-[200px] text-[12.5px] text-ink-2">{c.label ?? '—'}</td>
286 + </tr>
287 + ))}
288 + </tbody>
289 + </table>
290 + </div>
291 + ) : (
292 + <EmptyState compact>No cross-reference code recorded yet beyond the record identifiers above.</EmptyState>
293 + )}
294 + </Section>
156 295 <Note tone="warn">Regulatory status is jurisdiction-specific and time-bound. Nothing on this page is a treatment recommendation.</Note>
157 296 </aside>
158 297 </div>
added apps/web/src/app/pipeline/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading drug development pipeline" />;
5 +}
added apps/web/src/app/pipeline/page.tsx +241 −0
@@ -0,0 +1,241 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Section, Note } from '@/components/ui/section';
4 +import { Badge, ClaimBadge } from '@/components/ui/badge';
5 +import { EmptyState } from '@/components/ui/empty-state';
6 +import { Freshness } from '@/components/ui/freshness';
7 +import { Pager } from '@/components/ui/pager';
8 +import { SourceBadge } from '@/components/ui/source-badge';
9 +import { PIPELINE_FUNNEL, PIPELINE_PAGE_SIZE, PIPELINE_STAGES, countPipelineRows, listPipelineRows, pipelineSourceSlugs, pipelineSummary, pipelineTopDrugsPerStage, topLevelCancerOptions, type PipelineRow, type PipelineStage } from '@/lib/queries/approvals';
10 +import { fmtDate, fmtInt, phaseLabel } from '@/lib/format';
11 +import { pageInfo } from '@/lib/pagination';
12 +import { str, int, withParams, type SP } from '@/lib/search-params';
13 +
14 +export 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 +};
19 +export const revalidate = 600;
20 +
21 +const 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 +};
30 +
31 +function 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 +}
35 +
36 +/**
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 + */
40 +function 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 trials
56 + </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 +}
84 +
85 +export 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');
99 +
100 + 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 rules
108 + </Link>
109 + <Link className="ci-link" href="/approvals">
110 + Approvals feed →
111 + </Link>
112 + </p>
113 + </PageHeader>
114 +
115 + <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 + Apply
140 + </button>
141 + {cancer || stage ? (
142 + <Link className="ci-link text-[12.5px]" href="/pipeline">
143 + Clear
144 + </Link>
145 + ) : null}
146 + </form>
147 +
148 + {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 + {side
160 + .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>
182 +
183 + <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 +}
modified apps/web/src/components/data/approvals-table.tsx +6 −1
@@ -78,7 +78,12 @@ export function ApprovalsTable({ rows: allRows, prov, showDrug = true, showCance
78 78 </td>
79 79 <td>
80 80 <StatusBadge status={a.status} />
81 − {a.withdrawal_date ? <span className="block text-[11px] text-danger">withdrawn {fmtDate(a.withdrawal_date)}</span> : null}
81 + {a.source_status && a.source_status.toLowerCase() !== a.status ? (
82 + <span className="block text-[11px] text-ink-3" title="Status as published by the source for this one product (DIN); not the status of the molecule.">
83 + source: {a.source_status}
84 + </span>
85 + ) : null}
86 + {a.withdrawal_date ? <span className="block text-[11px] text-danger">since {fmtDate(a.withdrawal_date)}</span> : null}
82 87 </td>
83 88 <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td>
84 89 <td>
added apps/web/src/components/home/approvals-module.tsx +88 −0
@@ -0,0 +1,88 @@
1 +import Link from 'next/link';
2 +import { Section } from '@/components/ui/section';
3 +import { EmptyState } from '@/components/ui/empty-state';
4 +import { Freshness } from '@/components/ui/freshness';
5 +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';
6 +import { SourceBadge } from '@/components/ui/source-badge';
7 +import { recentApprovals } from '@/lib/queries/approvals';
8 +import { fmtDate, toDate } from '@/lib/format';
9 +
10 +/**
11 + * Home module "New oncology approvals": the last dated approval records across every ingested
12 + * authority (one record = one authority's decision for one application/DIN — never a bare
13 + * "approved"). Regulatory claim label; source badge per row because authorities differ.
14 + */
15 +export async function ApprovalsModule({ limit = 8 }: { limit?: number }) {
16 + const rows = await recentApprovals(limit);
17 + return (
18 + <Section
19 + id="approvals"
20 + kicker="Regulatory"
21 + title="New oncology approvals"
22 + description="Latest dated approval records by authority and jurisdiction. A record is one decision for one application or DIN; Health Canada records do not state indications."
23 + actions={
24 + <Link href="/approvals" className="ci-link">
25 + Approvals feed →
26 + </Link>
27 + }
28 + >
29 + {rows.length === 0 ? (
30 + <EmptyState knows={[{ label: 'Drugs', href: '/drugs' }, { label: 'Sources', href: '/sources' }]}>No regulatory approval record has been ingested yet.</EmptyState>
31 + ) : (
32 + <>
33 + <div className="ci-table-wrap">
34 + <table className="ci-table">
35 + <thead>
36 + <tr>
37 + <th>Date</th>
38 + <th>Drug</th>
39 + <th>Authority</th>
40 + <th>Cancer</th>
41 + <th>Status</th>
42 + <th>Source</th>
43 + </tr>
44 + </thead>
45 + <tbody>
46 + {rows.map((a) => (
47 + <tr key={a.id}>
48 + <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td>
49 + <td>
50 + <Link className="ci-link font-medium" href={`/drug/${a.drug_slug}#approvals`}>
51 + {a.drug_name}
52 + </Link>
53 + {a.approval_type ? <span className="ml-1.5 ci-mono text-[11px] text-ink-3">{a.approval_type}</span> : null}
54 + </td>
55 + <td className="whitespace-nowrap">
56 + <span className="text-ink-2">{a.authority}</span> <Badge mono tone="outline">{a.jurisdiction}</Badge>
57 + </td>
58 + <td className="text-[12.5px]">
59 + {a.tumor_agnostic ? <Badge tone="accent">Tumor-agnostic</Badge> : null}
60 + {a.cancer_slug ? (
61 + <Link className="ci-link" href={`/cancer/${a.cancer_slug}`}>
62 + {a.cancer_name}
63 + </Link>
64 + ) : !a.tumor_agnostic ? (
65 + <span className="text-ink-3">not stated in this record</span>
66 + ) : null}
67 + </td>
68 + <td>
69 + <StatusBadge status={a.status} />
70 + </td>
71 + <td>
72 + <SourceBadge compact p={{ sourceSlug: a.source_slug, sourceName: a.source_name }} />
73 + </td>
74 + </tr>
75 + ))}
76 + </tbody>
77 + </table>
78 + </div>
79 + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">
80 + <ClaimBadge kind="regulatory" />
81 + <span>Jurisdiction-specific and time-bound; not a treatment recommendation.</span>
82 + </div>
83 + <Freshness dataUpdatedAt={rows.map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null} sourceUpdatedAt={rows[0]?.approval_date ?? null} />
84 + </>
85 + )}
86 + </Section>
87 + );
88 +}
added apps/web/src/lib/queries/approvals.ts +195 −0
@@ -0,0 +1,195 @@
1 +import 'server-only';
2 +import { run, sql, safe } from '@/lib/db';
3 +import type { SQL } from 'drizzle-orm';
4 +import type { ApprovalRow } from '@/lib/queries/drugs';
5 +
6 +export const APPROVALS_PAGE_SIZE = 50;
7 +export const PIPELINE_PAGE_SIZE = 50;
8 +/** Funnel order (least → most advanced); `withdrawn` is shown apart from the funnel. */
9 +export const PIPELINE_FUNNEL = ['phase1', 'phase2', 'phase3', 'phase4', 'approved'] as const;
10 +export const PIPELINE_STAGES = ['phase_not_stated', ...PIPELINE_FUNNEL, 'withdrawn'] as const;
11 +export type PipelineStage = (typeof PIPELINE_STAGES)[number];
12 +export const APPROVED_STATUSES = ['approved', 'accelerated', 'conditional'] as const;
13 +const MAX_DEPTH = 12;
14 +
15 +/** Feed row: an approval record plus the verbatim upstream status when the source has one (DPD). */
16 +export interface FeedApprovalRow extends ApprovalRow {
17 + source_status: string | null;
18 +}
19 +
20 +export interface ApprovalFilters {
21 + authority: string;
22 + jurisdiction: string;
23 + cancer: string; // slug
24 + status: string;
25 + year: string;
26 + q: string;
27 +}
28 +
29 +const FEED_SELECT = sql`
30 + SELECT a.*, a.raw->>'dpdStatus' AS source_status, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name
31 + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id`;
32 +
33 +function feedWhere(f: ApprovalFilters): SQL {
34 + const parts: SQL[] = [sql`true`];
35 + if (f.authority) parts.push(sql`lower(a.authority) = lower(${f.authority})`);
36 + if (f.jurisdiction) parts.push(sql`upper(a.jurisdiction) = upper(${f.jurisdiction})`);
37 + if (f.status) parts.push(sql`a.status = ${f.status}`);
38 + if (/^\d{4}$/.test(f.year)) parts.push(sql`a.approval_date >= ${`${f.year}-01-01`} AND a.approval_date <= ${`${f.year}-12-31`}`);
39 + if (f.cancer) {
40 + parts.push(sql`a.cancer_id IN (
41 + WITH RECURSIVE dsc AS (
42 + SELECT id, 0 AS depth FROM cancers WHERE slug = ${f.cancer}
43 + UNION
44 + SELECT h.child_id, dsc.depth + 1 FROM dsc JOIN cancer_hierarchy h ON h.parent_id = dsc.id WHERE dsc.depth < ${MAX_DEPTH}
45 + ) SELECT id FROM dsc)`);
46 + }
47 + if (f.q) {
48 + const like = `%${f.q}%`;
49 + parts.push(sql`(d.name ILIKE ${like} OR a.indication ILIKE ${like} OR EXISTS (SELECT 1 FROM drug_aliases al WHERE al.drug_id = a.drug_id AND al.alias ILIKE ${like}))`);
50 + }
51 + return sql.join(parts, sql` AND `);
52 +}
53 +
54 +export async function countApprovals(f: ApprovalFilters): Promise<number> {
55 + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id WHERE ${feedWhere(f)}`), [{ n: '0' }]);
56 + return Number(r[0]?.n ?? 0);
57 +}
58 +
59 +/** Feed page, latest approval date first (undated records last). */
60 +export async function listApprovals(f: ApprovalFilters, page: number, pageSize = APPROVALS_PAGE_SIZE): Promise<FeedApprovalRow[]> {
61 + return safe(() => run<FeedApprovalRow>(sql`${FEED_SELECT} WHERE ${feedWhere(f)} ORDER BY a.approval_date DESC NULLS LAST, a.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`), [] as FeedApprovalRow[]);
62 +}
63 +
64 +export interface AuthorityStat {
65 + authority: string;
66 + jurisdiction: string;
67 + n: number;
68 + distinct_drugs: number;
69 + approved_like: number;
70 + withdrawn: number;
71 + last_12m: number;
72 + latest_date: string | null;
73 + updated_at: Date | null;
74 +}
75 +
76 +export async function approvalStats(): Promise<{ byAuthority: AuthorityStat[]; total: number; distinctDrugs: number; withCancer: number; last12m: number }> {
77 + const since = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
78 + const byAuthority = await safe(
79 + () =>
80 + run<AuthorityStat>(sql`
81 + SELECT a.authority, a.jurisdiction, count(*)::int AS n, count(DISTINCT a.drug_id)::int AS distinct_drugs,
82 + count(*) FILTER (WHERE a.status IN ('approved','accelerated','conditional'))::int AS approved_like,
83 + count(*) FILTER (WHERE a.status = 'withdrawn')::int AS withdrawn,
84 + count(*) FILTER (WHERE a.approval_date >= ${since} AND a.approval_date <= to_char(now(), 'YYYY-MM-DD'))::int AS last_12m,
85 + max(a.approval_date) FILTER (WHERE a.approval_date <= to_char(now(), 'YYYY-MM-DD')) AS latest_date, max(a.updated_at) AS updated_at
86 + FROM drug_approvals a GROUP BY a.authority, a.jurisdiction ORDER BY n DESC`),
87 + [] as AuthorityStat[],
88 + );
89 + const totals = await safe(() => run<{ total: string; drugs: string; with_cancer: string }>(sql`SELECT count(*) AS total, count(DISTINCT drug_id) AS drugs, count(cancer_id) AS with_cancer FROM drug_approvals`), [{ total: '0', drugs: '0', with_cancer: '0' }]);
90 + return { byAuthority, total: Number(totals[0]?.total ?? 0), distinctDrugs: Number(totals[0]?.drugs ?? 0), withCancer: Number(totals[0]?.with_cancer ?? 0), last12m: byAuthority.reduce((s, a) => s + a.last_12m, 0) };
91 +}
92 +
93 +export async function approvalFacets(): Promise<{ authorities: Array<{ authority: string; jurisdiction: string; n: number }>; statuses: Array<{ status: string; n: number }>; years: Array<{ year: string; n: number }> }> {
94 + const [authorities, statuses, years] = await Promise.all([
95 + safe(() => run<{ authority: string; jurisdiction: string; n: number }>(sql`SELECT authority, jurisdiction, count(*)::int AS n FROM drug_approvals GROUP BY 1, 2 ORDER BY n DESC`), []),
96 + safe(() => run<{ status: string; n: number }>(sql`SELECT status, count(*)::int AS n FROM drug_approvals GROUP BY 1 ORDER BY n DESC`), []),
97 + safe(() => run<{ year: string; n: number }>(sql`SELECT left(approval_date, 4) AS year, count(*)::int AS n FROM drug_approvals WHERE approval_date ~ '^\\d{4}' GROUP BY 1 ORDER BY 1 DESC`), []),
98 + ]);
99 + return { authorities, statuses, years };
100 +}
101 +
102 +/** Last dated approvals across authorities (no future-dated records). */
103 +export async function recentApprovals(limit = 8): Promise<FeedApprovalRow[]> {
104 + return safe(() => run<FeedApprovalRow>(sql`${FEED_SELECT} WHERE a.approval_date IS NOT NULL AND a.approval_date <= to_char(now(), 'YYYY-MM-DD') ORDER BY a.approval_date DESC, a.id DESC LIMIT ${limit}`), [] as FeedApprovalRow[]);
105 +}
106 +
107 +/* ------------------------------------------------------------------------------------------------
108 + * Pipeline (derived, drug_pipeline)
109 + * ---------------------------------------------------------------------------------------------- */
110 +
111 +export interface PipelineRow {
112 + id: number;
113 + drug_id: string;
114 + drug_slug: string;
115 + drug_name: string;
116 + drug_kind: string | null;
117 + cancer_id: string | null;
118 + cancer_slug: string | null;
119 + cancer_name: string | null;
120 + stage: PipelineStage;
121 + max_phase: string | null;
122 + active_trials: number;
123 + recruiting_trials: number;
124 + phase3_trials: number;
125 + total_trials: number;
126 + approvals: number;
127 + jurisdictions: string[];
128 + first_approval_date: string | null;
129 + latest_approval_date: string | null;
130 + first_trial_date: string | null;
131 + formula_version: string;
132 + computed_at: Date;
133 +}
134 +
135 +const PIPELINE_SELECT = sql`
136 + SELECT p.*, p.updated_at AS computed_at, d.slug AS drug_slug, d.name AS drug_name, d.kind AS drug_kind, c.slug AS cancer_slug, c.canonical_name AS cancer_name
137 + FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id`;
138 +const STAGE_RANK = sql`CASE p.stage WHEN 'approved' THEN 6 WHEN 'phase4' THEN 5 WHEN 'phase3' THEN 4 WHEN 'phase2' THEN 3 WHEN 'phase1' THEN 2 WHEN 'phase_not_stated' THEN 1 ELSE 0 END`;
139 +const scopeWhere = (cancerId: string | null) => (cancerId ? sql`p.cancer_id = ${cancerId}` : sql`p.cancer_id IS NULL`);
140 +
141 +export async function topLevelCancerOptions(): Promise<Array<{ id: string; slug: string; canonical_name: string; drugs: number }>> {
142 + return safe(
143 + () =>
144 + run<{ id: string; slug: string; canonical_name: string; drugs: number }>(sql`
145 + SELECT c.id, c.slug, c.canonical_name, (SELECT count(*) FROM drug_pipeline p WHERE p.cancer_id = c.id)::int AS drugs
146 + FROM cancers c WHERE c.top_level AND c.status = 'active' ORDER BY c.canonical_name`),
147 + [],
148 + );
149 +}
150 +
151 +export async function pipelineSummary(cancerId: string | null): Promise<{ stages: Array<{ stage: PipelineStage; drugs: number; active_trials: number }>; drugs: number; formulaVersion: string | null; computedAt: Date | null }> {
152 + const rows = await safe(() => run<{ stage: PipelineStage; drugs: number; active_trials: number }>(sql`SELECT p.stage, count(*)::int AS drugs, coalesce(sum(p.active_trials), 0)::int AS active_trials FROM drug_pipeline p WHERE ${scopeWhere(cancerId)} GROUP BY p.stage`), []);
153 + const meta = await safe(() => run<{ formula_version: string | null; computed_at: Date | null }>(sql`SELECT max(formula_version) AS formula_version, max(updated_at) AS computed_at FROM drug_pipeline`), [{ formula_version: null, computed_at: null }]);
154 + const by = new Map(rows.map((r) => [r.stage, r]));
155 + return {
156 + stages: PIPELINE_STAGES.map((stage) => ({ stage, drugs: by.get(stage)?.drugs ?? 0, active_trials: by.get(stage)?.active_trials ?? 0 })),
157 + drugs: rows.reduce((s, r) => s + r.drugs, 0),
158 + formulaVersion: meta[0]?.formula_version ?? null,
159 + computedAt: meta[0]?.computed_at ?? null,
160 + };
161 +}
162 +
163 +/** Up to `perStage` representative drugs per stage (most active trials first). */
164 +export async function pipelineTopDrugsPerStage(cancerId: string | null, perStage = 5): Promise<Map<PipelineStage, PipelineRow[]>> {
165 + const rows = await safe(
166 + () =>
167 + run<PipelineRow>(sql`
168 + SELECT * FROM (SELECT p.*, p.updated_at AS computed_at, d.slug AS drug_slug, d.name AS drug_name, d.kind AS drug_kind, c.slug AS cancer_slug, c.canonical_name AS cancer_name,
169 + row_number() OVER (PARTITION BY p.stage ORDER BY p.active_trials DESC, p.total_trials DESC, d.name) AS rn
170 + FROM drug_pipeline p JOIN drugs d ON d.id = p.drug_id LEFT JOIN cancers c ON c.id = p.cancer_id WHERE ${scopeWhere(cancerId)}) x
171 + WHERE rn <= ${perStage} ORDER BY stage, rn`),
172 + [] as PipelineRow[],
173 + );
174 + const out = new Map<PipelineStage, PipelineRow[]>();
175 + for (const r of rows) out.set(r.stage, [...(out.get(r.stage) ?? []), r]);
176 + return out;
177 +}
178 +
179 +export async function countPipelineRows(cancerId: string | null, stage: string): Promise<number> {
180 + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drug_pipeline p WHERE ${scopeWhere(cancerId)} ${stage ? sql`AND p.stage = ${stage}` : sql``}`), [{ n: '0' }]);
181 + return Number(r[0]?.n ?? 0);
182 +}
183 +
184 +export async function listPipelineRows(cancerId: string | null, stage: string, page: number, pageSize = PIPELINE_PAGE_SIZE): Promise<PipelineRow[]> {
185 + return safe(
186 + () => run<PipelineRow>(sql`${PIPELINE_SELECT} WHERE ${scopeWhere(cancerId)} ${stage ? sql`AND p.stage = ${stage}` : sql``} ORDER BY ${STAGE_RANK} DESC, p.active_trials DESC, p.total_trials DESC, d.name LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`),
187 + [] as PipelineRow[],
188 + );
189 +}
190 +
191 +/** Sources feeding the derived pipeline (registry + regulatory sources present in drug_approvals). */
192 +export async function pipelineSourceSlugs(): Promise<string[]> {
193 + const rows = await safe(() => run<{ slug: string }>(sql`SELECT DISTINCT s.slug FROM sources s WHERE s.slug = 'clinicaltrials' OR s.id IN (SELECT DISTINCT source_id FROM drug_approvals) ORDER BY 1`), [] as Array<{ slug: string }>);
194 + return rows.map((r) => r.slug);
195 +}
modified apps/web/src/lib/queries/drugs.ts +64 −1
@@ -79,10 +79,12 @@ export interface ApprovalRow {
79 79 source_name: string;
80 80 provenance_id: number;
81 81 updated_at: Date;
82 + /** Verbatim upstream status when the source publishes one (Health Canada DPD: "Cancelled Post Market", "Dormant"…). */
83 + source_status?: string | null;
82 84 }
83 85
84 86 const APPROVAL_SELECT = sql`
85 − SELECT a.*, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name
87 + SELECT a.*, a.raw->>'dpdStatus' AS source_status, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name
86 88 FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id`;
87 89
88 90 export async function approvalsForDrug(drugId: string): Promise<ApprovalRow[]> {
@@ -94,6 +96,67 @@ export async function approvalsForCancer(cancerIds: string[]): Promise<ApprovalR
94 96 return safe(() => run<ApprovalRow>(sql`${APPROVAL_SELECT} WHERE a.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) OR a.tumor_agnostic ORDER BY d.name, a.jurisdiction, a.approval_date DESC NULLS LAST LIMIT 500`), [] as ApprovalRow[]);
95 97 }
96 98
99 +/* ------------------------------------------------------------------------------------------------
100 + * Identifiers (drug_codes) and development pipeline (drug_pipeline) for the drug page
101 + * ---------------------------------------------------------------------------------------------- */
102 +
103 +export interface DrugCodeRow {
104 + id: number;
105 + system: string;
106 + code: string;
107 + label: string | null;
108 + match_type: string;
109 + source_slug: string | null;
110 + source_name: string | null;
111 +}
112 +
113 +/** Display order of identifier systems on the drug page. */
114 +export const CODE_SYSTEM_ORDER = ['atc', 'din', 'hc_drug_code', 'unii', 'rxcui', 'ncit', 'chembl', 'drugbank', 'pubchem_cid', 'civic_therapy', 'ema_product', 'mhra', 'tga'] as const;
115 +
116 +export async function codesForDrug(drugId: string): Promise<DrugCodeRow[]> {
117 + const order = sql.raw(`CASE k.system ${CODE_SYSTEM_ORDER.map((s, i) => `WHEN '${s}' THEN ${i}`).join(' ')} ELSE 99 END`);
118 + return safe(
119 + () =>
120 + run<DrugCodeRow>(sql`
121 + SELECT k.id, k.system, k.code, k.label, k.match_type, s.slug AS source_slug, s.name AS source_name
122 + FROM drug_codes k LEFT JOIN sources s ON s.id = k.source_id WHERE k.drug_id = ${drugId} ORDER BY ${order}, k.code`),
123 + [] as DrugCodeRow[],
124 + );
125 +}
126 +
127 +export interface DrugPipelineRow {
128 + id: number;
129 + cancer_id: string | null;
130 + cancer_slug: string | null;
131 + cancer_name: string | null;
132 + stage: string;
133 + max_phase: string | null;
134 + active_trials: number;
135 + recruiting_trials: number;
136 + phase3_trials: number;
137 + total_trials: number;
138 + approvals: number;
139 + jurisdictions: string[];
140 + first_approval_date: string | null;
141 + latest_approval_date: string | null;
142 + first_trial_date: string | null;
143 + formula_version: string;
144 + computed_at: Date;
145 +}
146 +
147 +/** Across-all-cancers row first, then one row per top-level cancer (most advanced stage first). */
148 +export async function pipelineForDrug(drugId: string): Promise<DrugPipelineRow[]> {
149 + return safe(
150 + () =>
151 + run<DrugPipelineRow>(sql`
152 + SELECT p.id, p.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, p.stage, p.max_phase, p.active_trials, p.recruiting_trials, p.phase3_trials, p.total_trials, p.approvals, p.jurisdictions,
153 + p.first_approval_date, p.latest_approval_date, p.first_trial_date, p.formula_version, p.updated_at AS computed_at
154 + FROM drug_pipeline p LEFT JOIN cancers c ON c.id = p.cancer_id WHERE p.drug_id = ${drugId}
155 + ORDER BY (p.cancer_id IS NOT NULL), CASE p.stage WHEN 'approved' THEN 6 WHEN 'phase4' THEN 5 WHEN 'phase3' THEN 4 WHEN 'phase2' THEN 3 WHEN 'phase1' THEN 2 WHEN 'phase_not_stated' THEN 1 ELSE 0 END DESC, p.active_trials DESC, c.canonical_name`),
156 + [] as DrugPipelineRow[],
157 + );
158 +}
159 +
97 160 export async function drugSlugsForSitemap(offset: number, limit: number): Promise<Array<{ slug: string; updated_at: Date }>> {
98 161 return safe(() => run<{ slug: string; updated_at: Date }>(sql`SELECT slug, updated_at FROM drugs ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []);
99 162 }
added docs/connectors/health-canada-dpd.md +154 −0
@@ -0,0 +1,154 @@
1 +# Connector `health-canada-dpd` — Health Canada Drug Product Database (DPD)
2 +
3 +| | |
4 +|---|---|
5 +| Source | https://health-products.canada.ca/dpd-bdpp/ — Health Canada, **Drug Product Database** (human, veterinary, radiopharmaceutical and disinfectant products authorized in Canada; one record per DIN) |
6 +| Tier / category | 3 / regulatory (CLAUDE.md §13 — jurisdiction **CA**, authority **Health Canada**; never a bare `approved = true`) |
7 +| Access | REST `https://health-products.canada.ca/api/drug/`, no authentication, JSON (`type=json`) or XML, `lang=en|fr` |
8 +| Docs verified | 2026-09-11 — https://health-products.canada.ca/api/documentation/dpd-documentation-en.html ("DPD API Guide") + live API with `User-Agent: CancerIndex/0.1` |
9 +| License | **Open Government Licence – Canada** (https://open.canada.ca/en/open-government-licence-canada) — `licenseStatus: approved`, `commercialUse: allowed`, `redistribution: attribution`; attribution: *"Contains information licensed under the Open Government Licence – Canada. Source: Health Canada, Drug Product Database."* |
10 +| Code | `packages/connectors/src/connectors/health-canada-dpd/` (`manifest.ts`, `normalize.ts`, `index.ts`, `health-canada-dpd.test.ts`, `fixtures/`) |
11 +
12 +## Terms and licence (quoted)
13 +
14 +The DPD web application carries no licence statement of its own (its footer links to the Canada.ca
15 +terms and conditions, verified 2026-09-11). The DPD **data extract** is published on the Open
16 +Government portal (dataset `bf55e42a-63cb-4556-bfd8-44f26e5a36fe`, "Drug Product Database - All
17 +Files") with **"Licence: Open Government Licence - Canada"**. From that licence
18 +(https://open.canada.ca/en/open-government-licence-canada), stored verbatim in `manifest.termsNotes`:
19 +
20 +> The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below. You are free to: Copy, modify, publish, translate, adapt, distribute or otherwise use the Information in any medium, mode or format for any lawful purpose. You must, where you do any of the above: Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence. […] you must use the following attribution statement: Contains information licensed under the Open Government Licence – Canada.
21 +
22 +CancerIndex shows every DPD record as a dated, sourced **market authorization for one product
23 +(DIN)** — never as an indication (the DPD publishes none) and never as a treatment recommendation.
24 +
25 +Rate limits: none documented (2026-09-11). The connector self-limits to **2 requests/s, one at a
26 +time** (`rateLimits`), i.e. ≈ 1,500 products × 4 requests ≈ 50 min per full pass; the pass is
27 +restartable per product (below). No 429 was observed during the first full pass.
28 +
29 +## Verified endpoints (2026-09-11)
30 +
31 +| Endpoint | Verified behaviour |
32 +|---|---|
33 +| `GET /therapeuticclass/?lang=en&type=json` | Bulk list, **48,042 rows** `{drug_code, tc_atc_number, tc_atc}` (3.7 MB, 0.2 s), one row per `drug_code`. Oncology groups kept: **L01** antineoplastic agents 1,156, **L02** endocrine therapy 255, **L03** immunostimulants 114 (filgrastim, interferons, BCG…), **V10** therapeutic radiopharmaceuticals 14 → **1,539 products**; the ATC group is tagged in `raw.atc.group`. L04 (immunosuppressants, 675) is out of scope. |
34 +| `GET /therapeuticclass/?lang=en&type=json&id=92551` | `[{drug_code: 92551, tc_atc_number: "L01FF02", tc_atc: "PEMBROLIZUMAB"}]` — **health check** (must return L01FF02). `tc_atc` is the ATC 5th-level substance name, not a class label. |
35 +| `GET /drugproduct/?lang=en&type=json&id=92551` | `{drug_code, class_name "Human" \| "Radiopharmaceutical" \| "Veterinary" \| "Disinfectant", drug_identification_number "02441152", brand_name "KEYTRUDA", descriptor, number_of_ais "1", ai_group_no, company_name, last_update_date "2026-08-13"}`. Brand names often embed the presentation ("PROCYTOX TABLETS 50MG", "VELBE 1MG/ML"). |
36 +| `GET /activeingredient/?lang=en&type=json&id=` | `[{ingredient_name "PEMBROLIZUMAB" \| "IMATINIB (IMATINIB MESYLATE)" \| "BORTEZOMIB (BORTEZOMIB MANNITOL BORONIC ESTER)", strength, strength_unit, dosage_value, dosage_unit}]`. Guide: *"Information enclosed within brackets represents the salt and identifies how the ingredient is supplied."* Kits list diluents (WATER, SODIUM CHLORIDE, BUFFER SOLUTION) as ingredients. |
37 +| `GET /status/?lang=en&type=json&id=` | `{status, history_date, original_market_date, external_status_code, expiration_date, lot_number}`. Status vocabulary (guide, `drugproduct/?status=`): 1 Approved, 2 Marketed, 3 Cancelled Pre Market, 4 Cancelled Post Market, 6 Dormant, 9 Cancelled (Unreturned Annual), 10 Cancelled (Safety Issue), 11 Authorized By Interim Order, 12 Authorized… `original_market_date` is **null** for "Approved" (not yet marketed) products. |
38 +| `GET /route/?lang=en&type=json&id=` | `[{route_of_administration_code, route_of_administration_name "Intravenous"}]` |
39 +| Unknown `drug_code` | Object endpoints (`drugproduct`, `status`) answer **HTTP 200 with zeros/nulls** (`{drug_code: 0, brand_name: null, …}`); array endpoints (`activeingredient`, `route`, `therapeuticclass`) answer **HTTP 404 with an empty body**. Both are treated as *missing*, never as a failure (`isMissingProduct`, `getArrayOrEmpty`). |
40 +| Public product page | `https://health-products.canada.ca/dpd-bdpp/info?lang=eng&code=<drug_code>` (HTTP 200 verified) — used as `provenance.source_url`. |
41 +
42 +Schedule/form endpoints exist but are not fetched (4 requests per product: product, ingredients,
43 +status, routes).
44 +
45 +## Ingestion
46 +
47 +Scope (bounded, CLAUDE.md §227): the oncology therapeutic-class rows, **sorted by `drug_code`**.
48 +`ctx.cursor.lastDrugCode` is advanced after each fully persisted product (checkpoint every 50
49 +records or 60 s, `manifest.checkpointEvery`), so a run stopped by the time budget (`--max-minutes`)
50 +or a signal resumes exactly where it stopped; a completed pass (`cursor.completedAt`) starts a
51 +fresh one on the next schedule (`passCount` increments; change events are recorded from pass 2).
52 +
53 +1. **Anomaly guard**: `ctx.guardCount('product', n)` on the filtered bulk list (1,539) against the
54 + previous successful run; plus a hard floor of 10,000 bulk rows (`anomaly:` error) — a truncated
55 + list never triggers deletions (nothing is ever deleted by this connector).
56 +2. **Per product** (`fetchBundle`): product → skip if missing; ingredients, status, routes. Each
57 + sub-object is validated (zod) and observed for schema drift (`product`, `active_ingredient`,
58 + `status`, `route`). One **`product` source record** per `drug_code` with the composite payload
59 + `{therapeuticClass, product, ingredients, status, routes}` — self-contained, so `--mode backfill`
60 + can replay it. Non-human classes (Veterinary, Disinfectant) are recorded and then skipped.
61 +3. **Ingredient → drug** (deterministic, no LLM; `parseIngredientName`): the bracketed salt is
62 + split off ("IMATINIB (IMATINIB MESYLATE)" → molecule IMATINIB, salt form IMATINIB MESYLATE), a
63 + trailing salt token is stripped ("DOXORUBICIN HYDROCHLORIDE" → DOXORUBICIN; the salt list is
64 + `SALT_TOKENS` shared with trial reconciliation). Excipients listed as kit components (WATER,
65 + SODIUM CHLORIDE, BUFFER SOLUTION…) are skipped. Lookup in `drug_aliases.normalized` (index
66 + loaded once per run and kept in sync): the full name first, then the molecule. Exactly one
67 + drug → resolved (`match_type ALIAS`); several → the one whose alias is `generic`, else the one
68 + whose own name matches, else **ambiguous** → `unresolved_labels` (`reason ambiguous_alias`).
69 + - **Unknown molecule, single-ingredient product** → a drug is **minted** (`mintId 'DRUG'`,
70 + `name` = molecule in Title Case — "Vinblastine", not "Vinblastine Sulfate" (CLAUDE.md §7:
71 + the base molecule is the entity, salts are aliases), `slug` = slugify(name) with a
72 + `-hc-<drug_code>` suffix on clash, `kind` null, `developmentStatus 'marketed_ca'` when the DPD
73 + status maps to approved), aliases `generic` (molecule), `salt` (salt form) and `brand`
74 + (cleaned brand), change event `created`.
75 + - **Unknown molecule, multi-ingredient product** → `unresolved_labels` (`reason
76 + component_of_multi_ingredient_product`, with DIN, brand, ATC); the approval row is linked to
77 + every component that *did* resolve.
78 +4. **Codes and aliases** for every resolved drug: `drug_codes` `atc` (code `tc_atc_number`, label
79 + `tc_atc`), `din` (label = brand as published), `hc_drug_code` (label = brand), `match_type
80 + EXACT_IDENTIFIER`; a `brand` alias with the presentation noise removed (`cleanBrandName`:
81 + "PROCYTOX TABLETS 50MG" → "PROCYTOX", "THIO TEPA INJ 15MG/VIAL" → "THIO TEPA") and a `salt`
82 + alias when the ingredient carried one.
83 +5. **Approval row** per (product, drug) — `drug_approvals`: `jurisdiction 'CA'`, `authority
84 + 'Health Canada'`, `approval_type 'DIN'`, `application_number` = DIN (8 digits), `approval_date`
85 + = `original_market_date` (null when the product is approved but not marketed — never
86 + fabricated), `status`: Marketed / Approved / Authorized… → **`approved`**; Cancelled Post Market
87 + / Pre Market / (Safety Issue) / (Unreturned Annual) / Dormant → **`withdrawn`** with
88 + `withdrawal_date = history_date`; anything else → no row + warning (`statusUnknown`). The DPD
89 + status is kept verbatim in `raw.dpdStatus` and surfaced on the pages: **a cancelled or dormant
90 + DIN is one product's status, not a withdrawal of the molecule** (KEYTRUDA DIN 02441152 is
91 + "Cancelled Post Market" since 2019-12-04 while other pembrolizumab DINs are marketed).
92 + `indication` is honest text: *"Marketed in Canada as KEYTRUDA (DIN 02441152) under ATC L01FF02
93 + PEMBROLIZUMAB. Indications are not published in the Drug Product Database — see the Health Canada
94 + Product Monograph."*; `cancer_id NULL`, `tumor_agnostic false` — **no cancer is ever inferred
95 + from an ATC class**. `raw` keeps drug code, DIN, brand, statuses/dates, ATC (code, label, group),
96 + class, company, descriptor, all ingredients with strengths, routes, `last_update_date`.
97 +6. **Provenance**: one row per product and run when the source record changed (`sourceUrl` =
98 + public product page, `dataset 'Health Canada DPD'`, `datasetVersion dpd-<run date>`,
99 + `evidenceType regulatory`, `accessLevel open`, `geography 'CA'`, `publishedAt` =
100 + original market date, `updatedAtSource` = `last_update_date`, methodology text naming the
101 + ingredient and how it was reconciled). Unchanged product + existing row → the previous
102 + provenance row is reused (no duplicate evidence per weekly run).
103 +7. **Idempotency**: rows are keyed by `(source_id, drug_id, jurisdiction 'CA', application_number =
104 + DIN)` — existing rows are updated in place (status changes are recorded as
105 + `approval_status_changed` from pass 2), new rows inserted (`approval_added` from pass 2). Nothing
106 + is deleted: a DIN that disappears from the bulk list keeps its last known status.
107 +
108 +`dry_run` walks the first 20 products of the list without writing (resolution against the alias
109 +index is logged as "→ CI-DRUG-…", "would mint …" or "AMBIGUOUS"). `--mode backfill` re-derives drugs,
110 +codes, aliases and approval rows from the composite payloads in the raw lake — no HTTP, cursor
111 +untouched.
112 +
113 +Health check: `therapeuticclass?id=92551` must contain `L01FF02`.
114 +
115 +## Observed runs (local copy of production, 2026-09-11)
116 +
117 +| | |
118 +|---|---|
119 +| Dry run | 20 products, 81 requests, 40 s; 10 resolved by alias, 10 "would mint" (Vinblastine, Diethylstilbestrol, Dactinomycin, Procarbazine, Busulfan, Thiotepa, Bleomycin…) |
120 +| 10-record smoke (`--max-records 10`) | 41 requests, 19 s, status `partial`, cursor `lastDrugCode 960`; 5 drugs minted, 10 approval rows, 27 codes; rerun resumed from the cursor |
121 +| Full pass (`--reset-cursor --max-minutes 58`, run ING-HEALTHCANADADPD-20260911-000003) | Still running at reporting time — **455 / 1,539 products** processed (cursor `lastDrugCode 80770`, ≈ 2.1 s per product = 4 requests at 2 req/s, 0 HTTP failures, 0 warnings). The cursor resumes the pass on the next `pnpm cix run health-canada-dpd`. |
122 +
123 +Counts after those 455 products (plus the 10-record smoke): 507 `product` source records; **52 drugs minted**
124 +(Vinblastine, Busulfan, Dactinomycin, Procarbazine, Thiotepa, Bleomycin, Interferon Alfa-2b…); ingredients
125 +resolved by alias 303 + 106 after salt stripping; **502 CA approval rows** on 161 drugs (165 `approved`,
126 +337 `withdrawn` — old DINs of long-marketed molecules dominate the low drug codes); `drug_codes` 502 DIN +
127 +502 hc_drug_code + 119 ATC; aliases 186 brand + 52 generic + 47 salt; 8 labels in `unresolved_labels`
128 +(components of multi-ingredient kits: RIBAVIRIN of REBETRON, ANCESTIM of STEMGEN, FORMESTANE, MELACINE
129 +lysate components…), 0 ambiguous. `pnpm cix reconcile-drugs` afterwards linked **69,207 → 74,435 trial
130 +intervention rows** (+5,228; 5,155 of them to the minted drugs — e.g. Busulfan, Bleomycin, Vinblastine
131 +trials were unresolved before).
132 +
133 +## Limitations / notes
134 +
135 +- **No indications**: the DPD is a product registry; indications live in the Product Monograph
136 + (PDF, not in the API). Canadian rows therefore never carry a cancer and never feed cancer-scoped
137 + pipeline stages or `approved_drug_count` per cancer; they do make the unscoped pipeline row
138 + "approved" and appear in the approvals feed and on the drug page.
139 +- **DIN-level granularity**: one molecule has many DINs (brands, generics, strengths); the feed
140 + counts records, not molecules. Status is per DIN.
141 +- **Dates**: `original_market_date` is the first marketing date of *that DIN*, often a generic's
142 + date; `approval_date` on a CA row is therefore not "the Canadian approval of the molecule" — the
143 + earliest CA row of a drug is the best available proxy. Historic products carry year-end
144 + placeholders from the registry itself ("1954-12-31") — kept as published.
145 +- **Scope by ATC**: products without an ATC assignment in the DPD, or classed outside L01/L02/L03/
146 + V10 (e.g. supportive-care antiemetics, L04 immunosuppressants), are not ingested. L03 brings
147 + non-oncology immunostimulants (interferon beta for MS) — they are drugs, honestly recorded with
148 + their ATC group, but should not be read as oncology approvals.
149 +- **Minted drugs** start with no `kind`, mechanism or targets; ChEMBL/openFDA enrichment and
150 + `pnpm cix reconcile-drugs` (trial interventions) run afterwards. Salt-form duplicates against
151 + existing entities are avoided by minting the base molecule; remaining duplicates are proposed by
152 + `packages/ranking/src/drug-duplicates.ts` (`entity_merges`, status `proposed`).
153 +- Only human and radiopharmaceutical classes are ingested; veterinary/disinfectant products are
154 + recorded as source records and skipped.
added docs/methodology/pipeline.md +99 −0
@@ -0,0 +1,99 @@
1 +# Drug development pipeline — stage rules (`drug_pipeline`, formula `ci-drug-pipeline-v1`)
2 +
3 +The pipeline is a **derived** layer (claim category `computed_metric`): for each drug, and for each
4 +(drug, top-level cancer) pair, the most advanced development stage supported by two canonical
5 +relations — registered **interventional** clinical trials (`trial_interventions` ×
6 +`trial_conditions` × `clinical_trials`) and jurisdiction-aware **approval records**
7 +(`drug_approvals`). It is recomputed by `pnpm cix intel` (`packages/ranking/src/drug-pipeline.ts`,
8 +`computeDrugPipeline`) in one transaction, set-based SQL over temp tables; the stage itself is
9 +decided by a pure, unit-tested function (`stageFor`, `drug-pipeline.test.ts`). Every row carries
10 +`formula_version` and its `inputs` (scope, approval counts, phase rank, the status lists and the
11 +rule text) so a stage can be reproduced.
12 +
13 +Code: `/Users/simon-pierreboucher/Desktop/Projets/apps-web/cancerindex/packages/ranking/src/drug-pipeline.ts`.
14 +Pages: `/pipeline` (funnel + table, `?cancer=<top-level slug>&stage=`), drug page section
15 +"Development pipeline"; API `GET /v1/pipeline`, `GET /v1/pipeline/summary`.
16 +
17 +## Rows
18 +
19 +| Row | `cancer_id` | Trials counted | Approvals counted |
20 +|---|---|---|---|
21 +| Drug, across all cancers | `NULL` | every interventional trial that lists the drug as an intervention (`trial_interventions.drug_id`) | every `drug_approvals` row of the drug, with or without a cancer |
22 +| Drug × top-level cancer | the top-level cancer | trials above whose `trial_conditions.cancer_id` is the top-level cancer **or one of its descendants** | approvals whose `cancer_id` is the top-level cancer or one of its descendants |
23 +
24 +A row is written only when the scope has ≥ 1 trial or ≥ 1 approval (no "preclinical" rows are
25 +inferred: absence of registered activity is not evidence of preclinical work).
26 +
27 +### Ancestor mapping
28 +
29 +Descendants are resolved with a recursive CTE over `cancer_hierarchy` (all hierarchy types —
30 +NCIt and OncoTree — like `entity_counters` and `GET /cancers/:id/descendants`), starting from every
31 +`cancers.top_level = true AND status = 'active'` concept, depth ≤ 12 (`PIPELINE_THRESHOLDS.
32 +maxHierarchyDepth`). A concept with several top-level ancestors (e.g. a lymphoma subtype under
33 +both "Lymphoma" and a hematologic family) feeds every one of them — a trial is never lost, and a
34 +drug may legitimately appear under two top-level cancers. Trials are counted DISTINCT per scope;
35 +a study with three mapped conditions under one top-level cancer counts once there.
36 +
37 +Approvals without a `cancer_id` (FDA supplements, label bullets naming 0 or ≥ 2 cancers, every
38 +**Health Canada DPD** row — the DPD publishes no indications) feed only the across-all-cancers
39 +row. They never place a drug at "approved" for a specific cancer.
40 +
41 +## Stage rule (`stageFor`)
42 +
43 +Evaluated in this order, on the counts of the scope:
44 +
45 +1. **approved** — at least one approval record with `status ∈ {approved, accelerated,
46 + conditional}` (`PIPELINE_APPROVED_STATUSES`). Approval in **any ingested jurisdiction** (US/FDA,
47 + CA/Health Canada today) suffices; `jurisdictions` lists which.
48 +2. **withdrawn** — approval records exist but every one is `withdrawn` or `superseded`. For a DPD
49 + product this means every Canadian DIN of the molecule in scope is cancelled/dormant *and* no
50 + other jurisdiction has an in-force record.
51 +3. otherwise, by the **highest registry phase** among the scope's interventional trials
52 + (`maxPhase`, rank PHASE4 4 > PHASE3 3 > PHASE2 2 > PHASE1 = EARLY_PHASE1 1 > NA 0; a trial
53 + labelled `PHASE2, PHASE3` ranks 3, `PHASE1, PHASE2` ranks 2):
54 + - `PHASE4` → **phase4**
55 + - `PHASE3` → **phase3**
56 + - `PHASE2` → **phase2**
57 + - `PHASE1` or `EARLY_PHASE1` → **phase1**
58 + - only `NA` / empty phases → **phase_not_stated**
59 +4. no trials and no approvals → **no row**.
60 +
61 +`max_phase` stores the label (PHASE1 wins over EARLY_PHASE1 when both occur at rank 1) even when
62 +the stage is `approved`, so "approved, still in phase 4 trials" stays visible.
63 +
64 +### Counts and dates
65 +
66 +| Column | Definition |
67 +|---|---|
68 +| `total_trials` | DISTINCT interventional trials in scope |
69 +| `active_trials` | those with `overall_status ∈ {RECRUITING, NOT_YET_RECRUITING, ENROLLING_BY_INVITATION, ACTIVE_NOT_RECRUITING}` (same list as trial intelligence and counters) |
70 +| `recruiting_trials` | `overall_status = RECRUITING` |
71 +| `phase3_trials` | trials whose `phases` contains `PHASE3` (so `PHASE2, PHASE3` counts) |
72 +| `approvals` | approval records in scope, all statuses |
73 +| `jurisdictions` | DISTINCT `jurisdiction` of those records |
74 +| `first_approval_date` / `latest_approval_date` | min / max `approval_date` over in-force records only |
75 +| `first_trial_date` | min `start_date` (registry text `YYYY-MM-DD` or `YYYY-MM`; lexicographic min) |
76 +
77 +## Caveats
78 +
79 +- **Registry phases are declared by sponsors**; `NA` is common for device, behavioural or
80 + surgical arms that carry a drug intervention. The stage says nothing about efficacy or about
81 + the drug's role (experimental arm vs comparator vs background therapy): a phase 3 trial using
82 + cisplatin as backbone places cisplatin at phase 3 for that cancer.
83 +- **Only ingested jurisdictions** can produce "approved". A molecule approved by the EMA only is
84 + shown at its trial phase until an EMA connector exists. Conversely a Canadian DIN (Health
85 + Canada) is a market authorization for a product with no stated indication: the unscoped row
86 + becomes "approved" while cancer-scoped rows still follow trial phases.
87 +- **Trial ↔ drug linking** is by alias reconciliation of intervention names (`match_type ALIAS`;
88 + `pnpm cix reconcile-drugs`). Unresolved intervention names (queued in `unresolved_labels`) do not
89 + contribute; new drug entities (e.g. minted by the Health Canada connector) gain their trials after
90 + the next reconciliation.
91 +- **Trial ↔ cancer linking** depends on `trial_conditions.cancer_id` (CancerResolver, EXACT or
92 + ALIAS matches); trials whose conditions stayed unresolved only feed the unscoped row.
93 +- **Duplicate drug entities** (salt forms: "Imatinib" and "Imatinib Mesylate") each get their own
94 + rows until curators accept a merge. `computeDrugPipeline` ends by *proposing* such merges
95 + (`entity_merges`, status `proposed`, `packages/ranking/src/drug-duplicates.ts`: equal names after
96 + stripping salt tokens, or ≥ 2 shared generic/brand/development-code aliases; keep = INN-like,
97 + shortest name). Nothing is merged automatically.
98 +- Rows are rebuilt from scratch on each run (`DELETE` + `INSERT` in one transaction); `computed_at`
99 + is the run time shown by the Freshness line.
added packages/connectors/src/connectors/health-canada-dpd/fixtures/activeingredient-101045.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/activeingredient-92551.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-101045.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-92551.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-malformed.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/drugproduct-missing.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/empty-array.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/route-101045.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/route-92551.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/status-101045.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/status-92551.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/status-missing.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/therapeuticclass-92551.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/fixtures/therapeuticclass-sample.json +170 −0
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/health-canada-dpd.test.ts +229 −0
Large diff (229 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/index.ts +488 −0
Large diff (488 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/manifest.ts +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/health-canada-dpd/normalize.ts +230 −0
Large diff (230 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/registry.ts +2 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/drug-duplicates.test.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/drug-duplicates.ts +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added packages/ranking/src/drug-pipeline.test.ts +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
modified packages/ranking/src/drug-pipeline.ts +259 −5
Large diff (276 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme