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>
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