| |
1 | +import type { Metadata } from 'next'; |
| |
2 | +import Link from 'next/link'; |
| |
3 | +import { notFound } from 'next/navigation'; |
| |
4 | +import { PageHeader, Section, Note } from '@/components/ui/section'; |
| |
5 | +import { EmptyState } from '@/components/ui/empty-state'; |
| |
6 | +import { Freshness } from '@/components/ui/freshness'; |
| |
7 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; |
| |
8 | +import { SourceBadge } from '@/components/ui/source-badge'; |
| |
9 | +import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; |
| |
10 | +import { YEAR_MIN, yearApprovalCounts, yearApprovals, yearEpidemiology, yearLiterature, yearTopSponsors, yearTrialTotals, yearTrialsByCancer, yearTrialsByPhase, yearsWithData } from '@/lib/queries/year'; |
| |
11 | +import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format'; |
| |
12 | + |
| |
13 | +export const revalidate = 3600; |
| |
14 | + |
| |
15 | +type Params = { year: string }; |
| |
16 | + |
| |
17 | +function parseYear(raw: string): number | null { |
| |
18 | + if (!/^\d{4}$/.test(raw)) return null; |
| |
19 | + const y = Number(raw); |
| |
20 | + const max = new Date().getUTCFullYear(); |
| |
21 | + return y >= YEAR_MIN && y <= max ? y : null; |
| |
22 | +} |
| |
23 | + |
| |
24 | +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> { |
| |
25 | + const y = parseYear((await params).year); |
| |
26 | + return y ? { title: `${y} in cancer`, description: `Oncology approvals, registered studies, research output and registry data of ${y}, from dated records in the index.`, alternates: { canonical: `/year/${y}` } } : { title: 'Year' }; |
| |
27 | +} |
| |
28 | + |
| |
29 | +export default async function YearPage({ params }: { params: Promise<Params> }) { |
| |
30 | + const year = parseYear((await params).year); |
| |
31 | + if (year == null) notFound(); |
| |
32 | + const [years, approvalCounts, approvals, phases, totals, byCancer, sponsors, lit, epi] = await Promise.all([yearsWithData(), yearApprovalCounts(year), yearApprovals(year, 60), yearTrialsByPhase(year), yearTrialTotals(year), yearTrialsByCancer(year, 12), yearTopSponsors(year, 10), yearLiterature(year, 15), yearEpidemiology(year)]); |
| |
33 | + const current = new Date().getUTCFullYear(); |
| |
34 | + const partial = year === current; |
| |
35 | + const prev = years.find((y) => y < year); |
| |
36 | + const next = [...years].reverse().find((y) => y > year); |
| |
37 | + const totalApprovals = approvalCounts.reduce((s, r) => s + r.n, 0); |
| |
38 | + |
| |
39 | + return ( |
| |
40 | + <div className="pb-8"> |
| |
41 | + <PageHeader kicker="Year in cancer" title={`${year} in cancer`} lede={`What the index holds for ${year}: regulatory decisions dated that year, studies first posted that year, PubMed output per cancer for that year and the registry observations whose reference year it is. Every figure is a count of dated source records — nothing is estimated${partial ? '; the current year is incomplete by construction' : ''}.`}> |
| |
42 | + <nav aria-label="Years" className="mt-3 flex flex-wrap items-center gap-2 text-[13px]"> |
| |
43 | + {prev ? ( |
| |
44 | + <Link href={`/year/${prev}`} className="ci-chip"> |
| |
45 | + ← {prev} |
| |
46 | + </Link> |
| |
47 | + ) : null} |
| |
48 | + {next ? ( |
| |
49 | + <Link href={`/year/${next}`} className="ci-chip"> |
| |
50 | + {next} → |
| |
51 | + </Link> |
| |
52 | + ) : null} |
| |
53 | + <span className="text-ink-3">Years with data: {years.length ? `${years[years.length - 1]}–${years[0]}` : 'none'}</span> |
| |
54 | + <Link href="/pulse" className="ci-link"> |
| |
55 | + Last 30 days → |
| |
56 | + </Link> |
| |
57 | + </nav> |
| |
58 | + </PageHeader> |
| |
59 | + |
| |
60 | + {partial ? <Note tone="warn">{year} is still in progress: counts grow with each connector run and registries have not yet published {year} observations.</Note> : null} |
| |
61 | + |
| |
62 | + <div className="mt-6 grid gap-10 lg:grid-cols-[1.35fr_1fr]"> |
| |
63 | + <div className="space-y-10"> |
| |
64 | + <Section id="approvals" kicker="Regulatory" title={`Approval records dated ${year} (${fmtInt(totalApprovals)})`} description="Approved, accelerated or conditional records by authority; original applications and records naming one cancer are listed first. One record is one decision for one application or product identifier."> |
| |
65 | + {approvalCounts.length ? ( |
| |
66 | + <> |
| |
67 | + <p className="mb-2 flex flex-wrap gap-1.5 text-[12px]"> |
| |
68 | + {approvalCounts.map((r) => ( |
| |
69 | + <Badge key={`${r.authority}-${r.jurisdiction}`} tone="outline" title={`${r.drugs} distinct drugs · ${r.with_cancer} records naming one cancer`}> |
| |
70 | + {r.authority} · {r.jurisdiction}: {fmtInt(r.n)} records / {fmtInt(r.drugs)} drugs |
| |
71 | + </Badge> |
| |
72 | + ))} |
| |
73 | + </p> |
| |
74 | + <ul className="divide-y divide-rule text-[13.5px]"> |
| |
75 | + {approvals.map((a) => ( |
| |
76 | + <li key={a.id} className="grid gap-x-3 py-2 sm:grid-cols-[92px_1fr]"> |
| |
77 | + <span className="ci-mono text-[12px] text-ink-3">{fmtDate(a.approval_date)}</span> |
| |
78 | + <span className="min-w-0"> |
| |
79 | + <Link href={`/drug/${a.drug_slug}`} className="ci-link font-medium"> |
| |
80 | + {a.drug_name} |
| |
81 | + </Link>{' '} |
| |
82 | + <Badge tone="outline"> |
| |
83 | + {a.authority} · {a.jurisdiction} |
| |
84 | + </Badge>{' '} |
| |
85 | + {a.approval_type ? <Badge tone={a.approval_type === 'ORIG' ? 'accent' : 'neutral'}>{a.approval_type === 'ORIG' ? 'original application' : a.approval_type === 'SUPPL' ? 'supplement' : a.approval_type}</Badge> : null}{' '} |
| |
86 | + {a.accelerated ? <Badge tone="warn">accelerated</Badge> : null}{' '} |
| |
87 | + <StatusBadge status={a.status} />{' '} |
| |
88 | + {a.cancer_slug ? ( |
| |
89 | + <Link href={`/cancer/${a.cancer_slug}`} className="ci-link"> |
| |
90 | + {a.cancer_name} |
| |
91 | + </Link> |
| |
92 | + ) : a.tumor_agnostic ? ( |
| |
93 | + <Badge tone="accent">tumor-agnostic</Badge> |
| |
94 | + ) : ( |
| |
95 | + <span className="text-ink-3">cancer not stated in this record</span> |
| |
96 | + )} |
| |
97 | + <span className="mt-0.5 block text-[12.5px] text-ink-2" title={a.indication}> |
| |
98 | + {truncate(a.indication, 160)} |
| |
99 | + </span> |
| |
100 | + <SourceBadge p={{ sourceSlug: a.source_slug }} compact /> |
| |
101 | + </span> |
| |
102 | + </li> |
| |
103 | + ))} |
| |
104 | + </ul> |
| |
105 | + <p className="mt-2 flex items-center gap-2 text-[12px]"> |
| |
106 | + <ClaimBadge kind="regulatory" /> |
| |
107 | + <Link href={`/approvals?from=${year}&to=${year}`} className="ci-link"> |
| |
108 | + All {year} records with filters → |
| |
109 | + </Link> |
| |
110 | + </p> |
| |
111 | + </> |
| |
112 | + ) : ( |
| |
113 | + <EmptyState compact title={`No approval record dated ${year}`}>Approval records exist from the authorities ingested (FDA via openFDA, Health Canada, EMA); older decisions may be undated in the source.</EmptyState> |
| |
114 | + )} |
| |
115 | + </Section> |
| |
116 | + |
| |
117 | + <Section id="trials" kicker="Clinical research" title={`Studies first posted in ${year}`} description="ClinicalTrials.gov oncology studies whose first-posted date falls in the year (all study types unless stated). Cancer attribution uses reconciled conditions over descendants; one study can count for several top-level cancers."> |
| |
118 | + {totals ? ( |
| |
119 | + <> |
| |
120 | + <dl className="mb-3 grid grid-cols-2 gap-x-6 gap-y-2 text-[13.5px] sm:grid-cols-3 lg:grid-cols-6"> |
| |
121 | + {[ |
| |
122 | + ['Studies', totals.total], |
| |
123 | + ['Interventional', totals.interventional], |
| |
124 | + ['Phase III', totals.phase3], |
| |
125 | + ['Industry-led', totals.industry], |
| |
126 | + ['With posted results', totals.with_results], |
| |
127 | + ['Countries with sites', totals.countries], |
| |
128 | + ].map(([k, v]) => ( |
| |
129 | + <div key={String(k)}> |
| |
130 | + <dt className="ci-kicker">{k}</dt> |
| |
131 | + <dd className="ci-num text-left font-display text-2xl">{fmtInt(v as number)}</dd> |
| |
132 | + </div> |
| |
133 | + ))} |
| |
134 | + </dl> |
| |
135 | + <div className="grid gap-6 lg:grid-cols-2"> |
| |
136 | + <div className="ci-table-wrap"> |
| |
137 | + <table className="ci-table"> |
| |
138 | + <caption className="pb-1 text-[12.5px] text-ink-3">By phase (a study with two phases counts in both)</caption> |
| |
139 | + <thead> |
| |
140 | + <tr> |
| |
141 | + <th>Phase</th> |
| |
142 | + <th className="num">Studies</th> |
| |
143 | + <th className="num">Interventional</th> |
| |
144 | + <th className="num">Industry share</th> |
| |
145 | + </tr> |
| |
146 | + </thead> |
| |
147 | + <tbody> |
| |
148 | + {phases.map((p) => ( |
| |
149 | + <tr key={p.phase}> |
| |
150 | + <td>{p.phase === 'NA' ? 'Not applicable / not stated' : phaseLabel(p.phase)}</td> |
| |
151 | + <td className="num">{fmtInt(p.n)}</td> |
| |
152 | + <td className="num">{fmtInt(p.interventional)}</td> |
| |
153 | + <td className="num text-ink-3">{fmtPct(p.n ? p.industry / p.n : 0, 0)}</td> |
| |
154 | + </tr> |
| |
155 | + ))} |
| |
156 | + </tbody> |
| |
157 | + </table> |
| |
158 | + </div> |
| |
159 | + <div className="ci-table-wrap"> |
| |
160 | + <table className="ci-table"> |
| |
161 | + <caption className="pb-1 text-[12.5px] text-ink-3">Top-level cancers by studies (descendants included)</caption> |
| |
162 | + <thead> |
| |
163 | + <tr> |
| |
164 | + <th>Cancer</th> |
| |
165 | + <th className="num">Studies</th> |
| |
166 | + <th className="num">Phase III</th> |
| |
167 | + </tr> |
| |
168 | + </thead> |
| |
169 | + <tbody> |
| |
170 | + {byCancer.map((c) => ( |
| |
171 | + <tr key={c.id}> |
| |
172 | + <td> |
| |
173 | + <Link href={`/cancer/${c.slug}/trials`} className="ci-link"> |
| |
174 | + {c.canonical_name} |
| |
175 | + </Link> |
| |
176 | + </td> |
| |
177 | + <td className="num">{fmtInt(c.trials)}</td> |
| |
178 | + <td className="num">{fmtInt(c.phase3)}</td> |
| |
179 | + </tr> |
| |
180 | + ))} |
| |
181 | + </tbody> |
| |
182 | + </table> |
| |
183 | + </div> |
| |
184 | + </div> |
| |
185 | + <div className="mt-4 ci-table-wrap"> |
| |
186 | + <table className="ci-table"> |
| |
187 | + <caption className="pb-1 text-[12.5px] text-ink-3">Lead sponsors of interventional studies first posted in {year}</caption> |
| |
188 | + <thead> |
| |
189 | + <tr> |
| |
190 | + <th>Lead sponsor</th> |
| |
191 | + <th>Class</th> |
| |
192 | + <th className="num">Studies</th> |
| |
193 | + <th className="num">Phase III</th> |
| |
194 | + </tr> |
| |
195 | + </thead> |
| |
196 | + <tbody> |
| |
197 | + {sponsors.map((s) => ( |
| |
198 | + <tr key={s.lead_sponsor}> |
| |
199 | + <td> |
| |
200 | + <Link href={`/trials?q=${encodeURIComponent(s.lead_sponsor)}`} className="ci-link"> |
| |
201 | + {s.lead_sponsor} |
| |
202 | + </Link> |
| |
203 | + </td> |
| |
204 | + <td className="text-[12.5px] text-ink-3">{s.lead_sponsor_class ? humanize(s.lead_sponsor_class) : '—'}</td> |
| |
205 | + <td className="num">{fmtInt(s.n)}</td> |
| |
206 | + <td className="num">{fmtInt(s.phase3)}</td> |
| |
207 | + </tr> |
| |
208 | + ))} |
| |
209 | + </tbody> |
| |
210 | + </table> |
| |
211 | + </div> |
| |
212 | + <p className="mt-2 flex items-center gap-2 text-[12px]"> |
| |
213 | + <ClaimBadge kind="published" /> <span className="text-ink-3">source: clinicaltrials · first_posted_date as posted</span> |
| |
214 | + </p> |
| |
215 | + </> |
| |
216 | + ) : ( |
| |
217 | + <EmptyState compact title={`No study first posted in ${year} in the index`}>The ClinicalTrials.gov connector ingests oncology studies of every year; first-posted dates start in 1999 for most registrations.</EmptyState> |
| |
218 | + )} |
| |
219 | + </Section> |
| |
220 | + </div> |
| |
221 | + |
| |
222 | + <aside className="space-y-10"> |
| |
223 | + <Section id="literature" kicker="Research output" title={`PubMed records, ${year}`} description="Records matching each top-level cancer's stored PubMed query with a publication year equal to this year, with the previous year for comparison. A count of indexed records, not of scientific impact."> |
| |
224 | + {lit.length ? ( |
| |
225 | + <> |
| |
226 | + <div className="ci-table-wrap"> |
| |
227 | + <table className="ci-table"> |
| |
228 | + <thead> |
| |
229 | + <tr> |
| |
230 | + <th>Cancer</th> |
| |
231 | + <th className="num">{year}</th> |
| |
232 | + <th className="num">{year - 1}</th> |
| |
233 | + <th className="num">Change</th> |
| |
234 | + </tr> |
| |
235 | + </thead> |
| |
236 | + <tbody> |
| |
237 | + {lit.map((l) => { |
| |
238 | + const d = l.prev_count ? ((l.count - l.prev_count) / l.prev_count) * 100 : null; |
| |
239 | + return ( |
| |
240 | + <tr key={l.id}> |
| |
241 | + <td> |
| |
242 | + <Link href={`/cancer/${l.slug}/research`} className="ci-link" title={l.query}> |
| |
243 | + {l.canonical_name} |
| |
244 | + </Link> |
| |
245 | + </td> |
| |
246 | + <td className="num">{fmtInt(l.count)}</td> |
| |
247 | + <td className="num text-ink-3">{l.prev_count == null ? '—' : fmtInt(l.prev_count)}</td> |
| |
248 | + <td className={`num ${d == null ? 'text-ink-4' : d > 0 ? 'text-ok' : d < 0 ? 'text-danger' : 'text-ink-3'}`}>{d == null ? '—' : `${d > 0 ? '+' : ''}${d.toFixed(0)} %`}</td> |
| |
249 | + </tr> |
| |
250 | + ); |
| |
251 | + })} |
| |
252 | + </tbody> |
| |
253 | + </table> |
| |
254 | + </div> |
| |
255 | + <p className="mt-2 flex items-center gap-2 text-[12px]"> |
| |
256 | + <ClaimBadge kind="computed" /> <span className="text-ink-3">literature_counts · window y{year} · query stored per row (hover a cancer)</span> |
| |
257 | + </p> |
| |
258 | + <Freshness dataUpdatedAt={lit[0]?.computed_at ?? null} extra="source: pubmed" /> |
| |
259 | + </> |
| |
260 | + ) : ( |
| |
261 | + <EmptyState compact title={`No literature window for ${year}`}>Yearly PubMed windows are computed from 2016 onwards for entities with a stored query.</EmptyState> |
| |
262 | + )} |
| |
263 | + </Section> |
| |
264 | + |
| |
265 | + <Section id="epidemiology" kicker="Registries" title={`Observations with reference year ${year}`} description="What registries have published for this year: observations by source, metric and geography. Registries publish with a lag of one to three years."> |
| |
266 | + {epi.length ? ( |
| |
267 | + <div className="ci-table-wrap"> |
| |
268 | + <table className="ci-table"> |
| |
269 | + <thead> |
| |
270 | + <tr> |
| |
271 | + <th>Geography</th> |
| |
272 | + <th>Metric</th> |
| |
273 | + <th>Source</th> |
| |
274 | + <th className="num">Observations</th> |
| |
275 | + <th className="num">Cancers</th> |
| |
276 | + </tr> |
| |
277 | + </thead> |
| |
278 | + <tbody> |
| |
279 | + {epi.map((e) => ( |
| |
280 | + <tr key={`${e.geography_slug}-${e.metric}-${e.source_slug}`}> |
| |
281 | + <td> |
| |
282 | + <Link href={`/country/${e.geography_slug}?year=${year}`} className="ci-link"> |
| |
283 | + {e.geography_name} |
| |
284 | + </Link> |
| |
285 | + </td> |
| |
286 | + <td className="text-[12.5px]">{EPI_METRIC_LABEL[e.metric] ?? humanize(e.metric)}</td> |
| |
287 | + <td> |
| |
288 | + <SourceBadge p={{ sourceSlug: e.source_slug, sourceName: e.source_name }} compact /> |
| |
289 | + </td> |
| |
290 | + <td className="num">{fmtInt(e.n)}</td> |
| |
291 | + <td className="num">{fmtInt(e.cancers)}</td> |
| |
292 | + </tr> |
| |
293 | + ))} |
| |
294 | + </tbody> |
| |
295 | + </table> |
| |
296 | + </div> |
| |
297 | + ) : ( |
| |
298 | + <EmptyState compact title={`No registry observation for ${year} yet`}>Registries publish with a lag; explore available years in the Data explorer.</EmptyState> |
| |
299 | + )} |
| |
300 | + <p className="mt-2 text-[12px]"> |
| |
301 | + <Link href={`/explore?from=${year}&to=${year}`} className="ci-link"> |
| |
302 | + Open {year} in the Data explorer → |
| |
303 | + </Link> |
| |
304 | + </p> |
| |
305 | + </Section> |
| |
306 | + </aside> |
| |
307 | + </div> |
| |
308 | + </div> |
| |
309 | + ); |
| |
310 | +} |