spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, Note } from '@/components/ui/section';4import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Pager } from '@/components/ui/pager';8import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';9import { APPROVALS_PAGE_SIZE, approvalFacets, approvalStats, countApprovals, listApprovals, type ApprovalFilters, type FeedApprovalRow } from '@/lib/queries/approvals';10import { loadProvenance, toInfo } from '@/lib/queries/provenance';11import { fmtDate, fmtInt, truncate } from '@/lib/format';12import { pageInfo } from '@/lib/pagination';13import { str, int, withParams, type SP } from '@/lib/search-params';1415export 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.21export const revalidate = 600;2223const STATUSES = ['approved', 'accelerated', 'conditional', 'withdrawn', 'superseded'];2425function monthKey(d: string | null): string {26 return d && /^\d{4}-\d{2}/.test(d) ? d.slice(0, 7) : 'undated';27}28function monthLabel(k: string): string {29 return k === 'undated' ? 'Date not stated' : fmtDate(`${k}-01`, { year: 'numeric', month: 'long' });30}3132export 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)}`;4243 // 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);5253 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 Methodology63 </Link>64 </p>65 </PageHeader>6667 {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>121122 <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 Apply178 </button>179 {filtered ? (180 <Link className="ci-link text-[12.5px]" href="/approvals">181 Clear182 </Link>183 ) : null}184 </form>185186 {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}276