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