SPB Git

spb/cancerindex

Public

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 (Sep 11, 2026) parent a757f8d Browse files

5 changed files +518 −0

modified apps/web/qa/smoke.mjs +1 −0
@@ -51,6 +51,7 @@ const ROUTES = [
51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, 51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 },
52 { path: '/methodology/trial-map', expect: ['ISO'] }, 52 { path: '/methodology/trial-map', expect: ['ISO'] },
53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] }, 53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] },
54+ { path: '/year/2025', expect: ['2025 in cancer', 'Phase III'] },
54 { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] }, 55 { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] },
55 { path: '/data-updates', expect: ['Data update log', 'ING-'] }, 56 { path: '/data-updates', expect: ['Data update log', 'ING-'] },
56 { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true }, 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 <Link href="/data-updates" className="ci-link"> 45 <Link href="/data-updates" className="ci-link">
46 Data update log → 46 Data update log →
47 </Link> 47 </Link>
48+ <Link href={`/year/${new Date().getUTCFullYear()}`} className="ci-link">
49+ Year in cancer →
50+ </Link>
48 </p> 51 </p>
49 </PageHeader> 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 export const MORE_NAV = [ 21 export const MORE_NAV = [
22 { href: '/pulse', label: 'Pulse' }, 22 { href: '/pulse', label: 'Pulse' },
23 { href: '/data-updates', label: 'Data updates' }, 23 { href: '/data-updates', label: 'Data updates' },
24+ { href: '/year/2025', label: 'Year in cancer' },
24 { href: '/taxonomy', label: 'Taxonomy' }, 25 { href: '/taxonomy', label: 'Taxonomy' },
25 { href: '/countries', label: 'Countries' }, 26 { href: '/countries', label: 'Countries' },
26 { href: '/compare', label: 'Compare' }, 27 { href: '/compare', label: 'Compare' },
27 28
Menu

Theme