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%

Year in cancer (/year/[year]): approvals, studies by phase/cancer/sponsor, PubMed output per cancer, registry observations for the year

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent a757f8d

5 changed files +518 −0

modified apps/web/qa/smoke.mjs +1 −0
@@ -51,6 +51,7 @@ const ROUTES = [
51 51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 },
52 52 { path: '/methodology/trial-map', expect: ['ISO'] },
53 53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] },
54 + { path: '/year/2025', expect: ['2025 in cancer', 'Phase III'] },
54 55 { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] },
55 56 { path: '/data-updates', expect: ['Data update log', 'ING-'] },
56 57 { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true },
modified apps/web/src/app/pulse/page.tsx +3 −0
@@ -45,6 +45,9 @@ export default async function PulsePage() {
45 45 <Link href="/data-updates" className="ci-link">
46 46 Data update log →
47 47 </Link>
48 + <Link href={`/year/${new Date().getUTCFullYear()}`} className="ci-link">
49 + Year in cancer →
50 + </Link>
48 51 </p>
49 52 </PageHeader>
50 53
added apps/web/src/app/year/[year]/page.tsx +310 −0
@@ -0,0 +1,310 @@
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 +}
added apps/web/src/lib/queries/year.ts +203 −0
@@ -0,0 +1,203 @@
1 +import 'server-only';
2 +import { run, sql, safe } from '@/lib/db';
3 +
4 +/**
5 + * "Year in cancer" (SPEC §112): everything dated within one calendar year, from records already in
6 + * the index — approvals, registered studies, literature counts per entity, epidemiology observations
7 + * published for that year. No synthesis; each block names its source and rule.
8 + */
9 +
10 +export const YEAR_MIN = 1999;
11 +
12 +export interface YearApprovalCounts {
13 + authority: string;
14 + jurisdiction: string;
15 + n: number;
16 + drugs: number;
17 + with_cancer: number;
18 +}
19 +export async function yearApprovalCounts(year: number): Promise<YearApprovalCounts[]> {
20 + return safe(
21 + () =>
22 + run<YearApprovalCounts>(sql`
23 + SELECT authority, jurisdiction, count(*)::int AS n, count(DISTINCT drug_id)::int AS drugs, count(*) FILTER (WHERE cancer_id IS NOT NULL)::int AS with_cancer
24 + FROM drug_approvals WHERE approval_date LIKE ${`${year}-%`} AND status IN ('approved','accelerated','conditional')
25 + GROUP BY authority, jurisdiction ORDER BY n DESC`),
26 + [] as YearApprovalCounts[],
27 + );
28 +}
29 +
30 +export interface YearApproval {
31 + id: number;
32 + approval_date: string;
33 + authority: string;
34 + jurisdiction: string;
35 + status: string;
36 + approval_type: string | null;
37 + accelerated: boolean | null;
38 + indication: string;
39 + drug_slug: string;
40 + drug_name: string;
41 + cancer_slug: string | null;
42 + cancer_name: string | null;
43 + tumor_agnostic: boolean;
44 + source_slug: string;
45 +}
46 +/** Original approvals first (new molecules / first indications), then supplements; all with a mapped cancer first. */
47 +export async function yearApprovals(year: number, limit = 60): Promise<YearApproval[]> {
48 + return safe(
49 + () =>
50 + run<YearApproval>(sql`
51 + SELECT a.id, a.approval_date, a.authority, a.jurisdiction, a.status, a.approval_type, a.accelerated, a.indication, a.tumor_agnostic,
52 + 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
53 + 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
54 + WHERE a.approval_date LIKE ${`${year}-%`} AND a.status IN ('approved','accelerated','conditional')
55 + ORDER BY (a.approval_type = 'ORIG') DESC, (a.cancer_id IS NOT NULL) DESC, a.approval_date DESC LIMIT ${limit}`),
56 + [] as YearApproval[],
57 + );
58 +}
59 +
60 +export interface YearTrialPhase {
61 + phase: string;
62 + n: number;
63 + interventional: number;
64 + industry: number;
65 +}
66 +export async function yearTrialsByPhase(year: number): Promise<YearTrialPhase[]> {
67 + return safe(
68 + () =>
69 + run<YearTrialPhase>(sql`
70 + SELECT ph AS phase, count(*)::int AS n, count(*) FILTER (WHERE t.study_type = 'INTERVENTIONAL')::int AS interventional, count(*) FILTER (WHERE t.lead_sponsor_class = 'INDUSTRY')::int AS industry
71 + FROM clinical_trials t, unnest(CASE WHEN cardinality(t.phases) = 0 THEN ARRAY['NA'] ELSE t.phases END) ph
72 + WHERE t.first_posted_date LIKE ${`${year}-%`}
73 + GROUP BY ph ORDER BY CASE ph WHEN 'EARLY_PHASE1' THEN 1 WHEN 'PHASE1' THEN 2 WHEN 'PHASE2' THEN 3 WHEN 'PHASE3' THEN 4 WHEN 'PHASE4' THEN 5 ELSE 9 END`),
74 + [] as YearTrialPhase[],
75 + );
76 +}
77 +
78 +export interface YearTrialTotals {
79 + total: number;
80 + interventional: number;
81 + phase3: number;
82 + industry: number;
83 + with_results: number;
84 + countries: number;
85 +}
86 +export async function yearTrialTotals(year: number): Promise<YearTrialTotals | null> {
87 + const rows = await safe(
88 + () =>
89 + run<YearTrialTotals>(sql`
90 + SELECT count(*)::int AS total, count(*) FILTER (WHERE study_type = 'INTERVENTIONAL')::int AS interventional, count(*) FILTER (WHERE 'PHASE3' = ANY(phases))::int AS phase3,
91 + count(*) FILTER (WHERE lead_sponsor_class = 'INDUSTRY')::int AS industry, count(*) FILTER (WHERE has_results)::int AS with_results,
92 + (SELECT count(DISTINCT c) FROM clinical_trials t2, unnest(t2.countries) c WHERE t2.first_posted_date LIKE ${`${year}-%`})::int AS countries
93 + FROM clinical_trials WHERE first_posted_date LIKE ${`${year}-%`}`),
94 + [] as YearTrialTotals[],
95 + );
96 + const r = rows[0];
97 + return r && r.total > 0 ? r : null;
98 +}
99 +
100 +export interface YearCancerTrials {
101 + id: string;
102 + slug: string;
103 + canonical_name: string;
104 + trials: number;
105 + phase3: number;
106 +}
107 +/** Top-level cancers by studies first posted in the year (conditions mapped to the cancer or any descendant, distinct studies). */
108 +export async function yearTrialsByCancer(year: number, limit = 12): Promise<YearCancerTrials[]> {
109 + return safe(
110 + () =>
111 + run<YearCancerTrials>(sql`
112 + WITH RECURSIVE tops AS (
113 + SELECT id AS top_id, id AS cancer_id, 0 AS depth FROM cancers WHERE top_level AND status = 'active'
114 + UNION
115 + SELECT tops.top_id, h.child_id, tops.depth + 1 FROM tops JOIN cancer_hierarchy h ON h.parent_id = tops.cancer_id WHERE tops.depth < 12
116 + ),
117 + yr AS (SELECT id, phases FROM clinical_trials WHERE first_posted_date LIKE ${`${year}-%`}),
118 + m AS (
119 + SELECT DISTINCT tops.top_id, yr.id AS trial_id, ('PHASE3' = ANY(yr.phases)) AS p3
120 + FROM yr JOIN trial_conditions tc ON tc.trial_id = yr.id AND tc.cancer_id IS NOT NULL JOIN tops ON tops.cancer_id = tc.cancer_id
121 + )
122 + SELECT c.id, c.slug, c.canonical_name, count(*)::int AS trials, count(*) FILTER (WHERE p3)::int AS phase3
123 + FROM m JOIN cancers c ON c.id = m.top_id GROUP BY c.id, c.slug, c.canonical_name ORDER BY trials DESC LIMIT ${limit}`),
124 + [] as YearCancerTrials[],
125 + );
126 +}
127 +
128 +export interface YearSponsor {
129 + lead_sponsor: string;
130 + lead_sponsor_class: string | null;
131 + n: number;
132 + phase3: number;
133 +}
134 +export async function yearTopSponsors(year: number, limit = 10): Promise<YearSponsor[]> {
135 + return safe(
136 + () =>
137 + run<YearSponsor>(sql`
138 + SELECT lead_sponsor, min(lead_sponsor_class) AS lead_sponsor_class, count(*)::int AS n, count(*) FILTER (WHERE 'PHASE3' = ANY(phases))::int AS phase3
139 + FROM clinical_trials WHERE first_posted_date LIKE ${`${year}-%`} AND study_type = 'INTERVENTIONAL' AND lead_sponsor IS NOT NULL
140 + GROUP BY lead_sponsor ORDER BY n DESC LIMIT ${limit}`),
141 + [] as YearSponsor[],
142 + );
143 +}
144 +
145 +export interface YearLiterature {
146 + id: string;
147 + slug: string;
148 + canonical_name: string;
149 + count: number;
150 + prev_count: number | null;
151 + query: string;
152 + computed_at: Date | string;
153 +}
154 +/** Top-level cancers by PubMed records for the year (window_key yYYYY, query stored with each count). */
155 +export async function yearLiterature(year: number, limit = 15): Promise<YearLiterature[]> {
156 + return safe(
157 + () =>
158 + run<YearLiterature>(sql`
159 + SELECT c.id, c.slug, c.canonical_name, l.count, p.count AS prev_count, l.query, l.updated_at AS computed_at
160 + FROM literature_counts l JOIN cancers c ON c.id = l.cancer_id
161 + LEFT JOIN literature_counts p ON p.cancer_id = l.cancer_id AND p.window_key = ${`y${year - 1}`}
162 + WHERE l.window_key = ${`y${year}`} AND c.top_level AND c.status = 'active'
163 + ORDER BY l.count DESC LIMIT ${limit}`),
164 + [] as YearLiterature[],
165 + );
166 +}
167 +
168 +export interface YearEpi {
169 + source_slug: string;
170 + source_name: string;
171 + metric: string;
172 + geography_name: string;
173 + geography_slug: string;
174 + n: number;
175 + cancers: number;
176 +}
177 +/** Epidemiology observations whose reference year is this year (what registries published for it). */
178 +export async function yearEpidemiology(year: number): Promise<YearEpi[]> {
179 + return safe(
180 + () =>
181 + run<YearEpi>(sql`
182 + SELECT s.slug AS source_slug, s.name AS source_name, o.metric, g.name AS geography_name, g.slug AS geography_slug, count(*)::int AS n, count(DISTINCT o.cancer_id)::int AS cancers
183 + FROM epidemiology_observations o JOIN sources s ON s.id = o.source_id JOIN geographies g ON g.id = o.geography_id
184 + WHERE o.year = ${year} GROUP BY s.slug, s.name, o.metric, g.name, g.slug ORDER BY g.name, o.metric, s.slug`),
185 + [] as YearEpi[],
186 + );
187 +}
188 +
189 +/** Years for which at least one dated fact exists (approvals, trials, literature or observations). */
190 +export async function yearsWithData(): Promise<number[]> {
191 + const rows = await safe(
192 + () =>
193 + run<{ y: number }>(sql`
194 + SELECT DISTINCT y FROM (
195 + SELECT substr(approval_date, 1, 4)::int AS y FROM drug_approvals WHERE approval_date ~ '^\\d{4}'
196 + UNION SELECT substr(first_posted_date, 1, 4)::int FROM clinical_trials WHERE first_posted_date ~ '^\\d{4}'
197 + UNION SELECT year FROM epidemiology_observations
198 + UNION SELECT substr(window_key, 2)::int FROM literature_counts WHERE window_key ~ '^y\\d{4}$'
199 + ) x WHERE y >= ${YEAR_MIN} AND y <= extract(year FROM now())::int ORDER BY y DESC`),
200 + [] as Array<{ y: number }>,
201 + );
202 + return rows.map((r) => Number(r.y));
203 +}
modified apps/web/src/lib/site.ts +1 −0
@@ -21,6 +21,7 @@ export const NAV = [
21 21 export const MORE_NAV = [
22 22 { href: '/pulse', label: 'Pulse' },
23 23 { href: '/data-updates', label: 'Data updates' },
24 + { href: '/year/2025', label: 'Year in cancer' },
24 25 { href: '/taxonomy', label: 'Taxonomy' },
25 26 { href: '/countries', label: 'Countries' },
26 27 { href: '/compare', label: 'Compare' },
27 28