SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
16.0 KB · 276 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, Note } from '@/components/ui/section';4import { Badge, ClaimBadge, 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