SPB Git

spb/cancerindex

Public

Wave 2: SDK checkpoints/abort/anomaly guard/alerts, cix doctor, backups, perf indexes, migration 0001; MeSH, ChEMBL, openFDA, cBioPortal connectors; web pagination/QA/tests/caching, countries, compare, key figures; USCS sex-specific rate fix

Simon-Pierre Boucher committed (Sep 8, 2026) parent 4354e64 Browse files

159 changed files +18,136 −784

159 changed files
modified apps/api/src/routes/admin.ts +8 −0
@@ -151,6 +151,14 @@ export const adminRoutes: FastifyPluginAsyncZod = async (app) => {
151 151 });
152 152 }
153 153
154 + app.get('/alerts', { schema: { tags: ['admin'], summary: 'Internal alerts (§170): connector failures, anomalies, schema drift, stale/failing sources', security: [{ adminToken: [] }], querystring: z.object({ status: z.enum(['open', 'acknowledged', 'resolved', 'active']).default('active'), connectorId: z.string().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }), response: ok(AnyList) } }, async (req) => {
155 + const q = req.query;
156 + const conds = [q.status === 'active' ? sql`status IN ('open','acknowledged')` : sql`status = ${q.status}`];
157 + if (q.connectorId) conds.push(sql`connector_id = ${q.connectorId}`);
158 + const rows = await app.db.execute<Record<string, unknown>>(sql`SELECT * FROM system_alerts WHERE ${sql.join(conds, sql` AND `)} ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warn' THEN 1 ELSE 2 END, last_seen_at DESC LIMIT ${q.limit}`);
159 + return respond(app, camelRows(rows), []);
160 + });
161 +
154 162 app.get('/audit', { schema: { tags: ['admin'], summary: 'Recent audit log entries', security: [{ adminToken: [] }], querystring: z.object({ limit: z.coerce.number().int().min(1).max(500).default(100) }), response: ok(AnyList) } }, async (req) => {
155 163 const rows = await app.db.execute<Record<string, unknown>>(sql`SELECT * FROM audit_log ORDER BY created_at DESC, id DESC LIMIT ${req.query.limit}`);
156 164 return respond(app, camelRows(rows), []);
modified apps/web/next.config.ts +9 −0
@@ -37,6 +37,11 @@ const nextConfig: NextConfig = {
37 37 },
38 38 outputFileTracingRoot: path.resolve(__dirname, '../..'),
39 39 async headers() {
40 + // Static-ish public routes: let ngrok/CDN/browser caches share a rendered page for 10 min and serve
41 + // stale for an hour while revalidating. Never for /admin, /search, /api (user- or query-specific).
42 + const cacheable = ['/cancer/:path*', '/gene/:path*', '/variant/:path*', '/drug/:path*', '/trial/:path*', '/publication/:path*', '/source/:path*', '/rankings', '/rankings/:path*'];
43 + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=600, stale-while-revalidate=3600' };
44 + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' };
40 45 return [
41 46 {
42 47 source: '/(.*)',
@@ -47,6 +52,10 @@ const nextConfig: NextConfig = {
47 52 { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
48 53 ],
49 54 },
55 + ...cacheable.map((source) => ({ source, headers: [PUBLIC_CACHE] })),
56 + { source: '/admin/:path*', headers: [NO_STORE] },
57 + { source: '/search', headers: [NO_STORE] },
58 + { source: '/api/:path*', headers: [NO_STORE] },
50 59 ];
51 60 },
52 61 };
modified apps/web/package.json +3 −1
@@ -7,7 +7,9 @@
7 7 "build": "next build --webpack",
8 8 "start": "next start -p 8250",
9 9 "typecheck": "tsc -p tsconfig.json --noEmit",
10 − "test": "vitest run --passWithNoTests"
10 + "test": "vitest run --passWithNoTests",
11 + "qa": "node qa/smoke.mjs",
12 + "qa:prod": "BASE_URL=https://www.cancerindex.io node qa/smoke.mjs"
11 13 },
12 14 "dependencies": {
13 15 "@cancerindex/database": "workspace:*",
added apps/web/qa/smoke.mjs +239 −0
@@ -0,0 +1,239 @@
1 +#!/usr/bin/env node
2 +/**
3 + * CancerIndex web smoke suite — read-only.
4 + *
5 + * BASE_URL=http://127.0.0.1:8253 node qa/smoke.mjs (default)
6 + * BASE_URL=https://www.cancerindex.io node qa/smoke.mjs (production, read-only)
7 + *
8 + * Checks, for every route in ROUTES: HTTP 200, key text present, HTML weight under MAX_KB, no
9 + * "Data not yet available" on pages that must have data, and — when Playwright is available — no
10 + * horizontal overflow at 390 px plus a console-error scan. Playwright is resolved from a sibling
11 + * project's node_modules (never installed globally); without it the suite degrades to HTTP-only.
12 + *
13 + * Exit code 1 when any hard check fails. Prints a table.
14 + */
15 +import { createRequire } from 'node:module';
16 +import { existsSync } from 'node:fs';
17 +import os from 'node:os';
18 +import path from 'node:path';
19 +
20 +const BASE_URL = (process.env.BASE_URL ?? 'http://127.0.0.1:8253').replace(/\/$/, '');
21 +const MAX_KB = Number(process.env.MAX_KB ?? 600);
22 +const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 30000);
23 +const MOBILE_WIDTH = 390;
24 +const ONLY_HTTP = process.env.HTTP_ONLY === '1';
25 +
26 +/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */
27 +const ROUTES = [
28 + { path: '/', expect: ['CancerIndex'] },
29 + { path: '/cancers', expect: ['Cancers'] },
30 + { path: '/cancer/malignant-pancreatic-neoplasm', expect: ['Malignant Pancreatic Neoplasm', 'CI-CAN-'] },
31 + // "observations" only appears in the data branch (Freshness "N observations"); the empty branch is caught by mustHaveData.
32 + { path: '/cancer/malignant-pancreatic-neoplasm/statistics', expect: ['Population statistics', 'observations'], mustHaveData: true },
33 + { path: '/cancer/malignant-pancreatic-neoplasm/trials', expect: ['Registered studies', 'NCT'] },
34 + { path: '/cancer/malignant-pancreatic-neoplasm/evidence', expect: ['Curated clinical evidence', 'civic'] },
35 + { path: '/cancer/malignant-pancreatic-neoplasm/rankings', expect: ['Rankings'] },
36 + { path: '/gene/TP53', expect: ['TP53', 'Clinical evidence'] },
37 + { path: '/variant/braf-v600e', expect: ['V600E', 'Evidence by cancer'] },
38 + { path: '/drug/osimertinib', expect: ['Osimertinib', 'Approvals'], resolve: (ctx) => ctx.drug ?? '/drug/osimertinib' },
39 + { path: '/trial/<first NCT>', expect: ['NCT', 'Conditions'], resolve: (ctx) => ctx.trial ?? null },
40 + { path: '/rankings', expect: ['Rankings', 'metric'] },
41 + { path: '/rankings/mortality_count', expect: ['mortality', 'rank'] },
42 + { path: '/taxonomy', expect: ['Taxonomy'] },
43 + { path: '/sources', expect: ['Sources', 'license'] },
44 + { path: '/source/ncit-evs', expect: ['NCI', 'ncit-evs'] },
45 + { path: '/methodology', expect: ['Methodology'] },
46 + { path: '/search?q=glio', expect: ['Results for', 'glio'] },
47 + { path: '/sitemap.xml', expect: ['<sitemapindex', '<loc>'], kind: 'xml' },
48 + { path: '/api/v1/stats', expect: ['data'], kind: 'json', optionalLocal: true },
49 +];
50 +
51 +const c = { green: (s) => `\x1b[32m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
52 +const isLocal = /^(https?:\/\/)?(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|\/|$)/.test(BASE_URL);
53 +
54 +async function fetchText(url) {
55 + const ctl = new AbortController();
56 + const t = setTimeout(() => ctl.abort(), TIMEOUT_MS);
57 + const started = performance.now();
58 + try {
59 + const res = await fetch(url, { signal: ctl.signal, redirect: 'follow', headers: { 'user-agent': 'cancerindex-smoke/1.0', accept: 'text/html,application/xml,application/json;q=0.9,*/*;q=0.8' } });
60 + const buf = Buffer.from(await res.arrayBuffer());
61 + return { status: res.status, bytes: buf.byteLength, text: buf.toString('utf8'), ms: Math.round(performance.now() - started), headers: res.headers, finalUrl: res.url };
62 + } finally {
63 + clearTimeout(t);
64 + }
65 +}
66 +
67 +/** Discover a real drug slug and NCT id from the running site (no DB access from the QA suite). */
68 +async function discover() {
69 + const ctx = {};
70 + try {
71 + const d = await fetchText(`${BASE_URL}/drug/osimertinib`);
72 + if (d.status === 200) ctx.drug = '/drug/osimertinib';
73 + else {
74 + const list = await fetchText(`${BASE_URL}/drugs`);
75 + const m = /href="(\/drug\/[a-z0-9-]+)"/.exec(list.text);
76 + if (m) ctx.drug = m[1];
77 + }
78 + } catch {}
79 + try {
80 + const list = await fetchText(`${BASE_URL}/trials`);
81 + const m = /href="(\/trial\/NCT\d{8})"/.exec(list.text);
82 + if (m) ctx.trial = m[1];
83 + } catch {}
84 + return ctx;
85 +}
86 +
87 +async function loadPlaywright() {
88 + if (ONLY_HTTP) return null;
89 + const candidates = [
90 + process.env.PLAYWRIGHT_DIR,
91 + path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha/qa'),
92 + path.join(os.homedir(), 'Desktop/Projets/apps-web/rareindex'),
93 + path.join(os.homedir(), 'Desktop/Projets/apps-web/fetcha'),
94 + process.cwd(),
95 + ].filter(Boolean);
96 + for (const dir of candidates) {
97 + const pkg = path.join(dir, 'node_modules', 'playwright', 'package.json');
98 + if (!existsSync(pkg)) continue;
99 + try {
100 + const req = createRequire(path.join(dir, 'package.json'));
101 + const pw = req('playwright');
102 + const browser = await pw.chromium.launch({ headless: true });
103 + return { pw, browser, from: dir };
104 + } catch (err) {
105 + console.error(c.dim(`playwright at ${dir} unusable: ${err.message.split('\n')[0]}`));
106 + }
107 + }
108 + return null;
109 +}
110 +
111 +function pad(s, n, right = false) {
112 + s = String(s);
113 + if (s.length >= n) return s.slice(0, n);
114 + return right ? ' '.repeat(n - s.length) + s : s + ' '.repeat(n - s.length);
115 +}
116 +
117 +async function main() {
118 + console.log(`CancerIndex smoke · ${BASE_URL} · max ${MAX_KB} KB · viewport ${MOBILE_WIDTH}px`);
119 + const ctx = await discover();
120 + const pwr = await loadPlaywright();
121 + if (pwr) console.log(c.dim(`Playwright from ${pwr.from}`));
122 + else console.log(c.yellow('Playwright unavailable — HTTP-only mode (no overflow / console checks)'));
123 +
124 + const rows = [];
125 + let failures = 0;
126 + let warnings = 0;
127 + const page = pwr ? await pwr.browser.newPage({ viewport: { width: MOBILE_WIDTH, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) : null;
128 +
129 + for (const r of ROUTES) {
130 + const p = r.resolve ? r.resolve(ctx) : r.path;
131 + if (!p) {
132 + rows.push({ path: r.path, status: '—', kb: '—', ms: '—', overflow: '—', result: c.yellow('SKIP'), note: 'could not discover an instance' });
133 + warnings++;
134 + continue;
135 + }
136 + const url = `${BASE_URL}${p}`;
137 + const problems = [];
138 + const notes = [];
139 + let res;
140 + try {
141 + res = await fetchText(url);
142 + } catch (err) {
143 + rows.push({ path: p, status: 'ERR', kb: '—', ms: '—', overflow: '—', result: c.red('FAIL'), note: err.message });
144 + failures++;
145 + continue;
146 + }
147 + const kb = Math.round(res.bytes / 1024);
148 + const optional = r.optionalLocal && isLocal;
149 + if (res.status !== 200) {
150 + if (optional && (res.status === 500 || res.status === 502 || res.status === 503)) notes.push(`API proxy target down locally (${res.status}) — optional`);
151 + else problems.push(`status ${res.status}`);
152 + } else {
153 + const lower = res.text.toLowerCase();
154 + for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`);
155 + if (r.kind !== 'json' && r.kind !== 'xml') {
156 + if (kb > MAX_KB) problems.push(`weight ${kb} KB > ${MAX_KB} KB`);
157 + if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data');
158 + if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered');
159 + }
160 + if (r.kind === 'json') {
161 + try {
162 + const j = JSON.parse(res.text);
163 + if (!j || typeof j !== 'object' || !('data' in j)) problems.push('JSON envelope missing "data"');
164 + } catch {
165 + problems.push('invalid JSON');
166 + }
167 + }
168 + const cc = res.headers.get('cache-control') ?? '';
169 + if (/^\/(cancer|gene|drug|trial|source)\//.test(p) || /^\/rankings/.test(p)) {
170 + if (!/s-maxage/.test(cc)) notes.push(`no s-maxage (cache-control: ${cc || 'none'})`);
171 + }
172 + }
173 +
174 + let overflow = '—';
175 + if (page && res.status === 200 && r.kind !== 'json' && r.kind !== 'xml') {
176 + const consoleErrors = [];
177 + const onConsole = (m) => {
178 + if (m.type() === 'error') consoleErrors.push(m.text());
179 + };
180 + page.on('console', onConsole);
181 + try {
182 + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
183 + await page.waitForTimeout(250);
184 + const m = await page.evaluate(() => {
185 + const doc = document.documentElement;
186 + const bodyOverflow = Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth;
187 + // Elements wider than the viewport that are not inside a horizontally scrollable wrapper.
188 + const offenders = [];
189 + const vw = doc.clientWidth;
190 + for (const el of document.querySelectorAll('body *')) {
191 + const rect = el.getBoundingClientRect();
192 + if (rect.right > vw + 1 && rect.width > 0) {
193 + let scrollable = false;
194 + for (let a = el.parentElement; a; a = a.parentElement) {
195 + const ov = getComputedStyle(a).overflowX;
196 + if (ov === 'auto' || ov === 'scroll' || ov === 'hidden' || ov === 'clip') {
197 + scrollable = true;
198 + break;
199 + }
200 + }
201 + if (!scrollable && el.tagName !== 'HTML' && el.tagName !== 'BODY') offenders.push(`${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(' ').slice(0, 2).join('.') : ''} ${Math.round(rect.right - vw)}px`);
202 + if (offenders.length >= 3) break;
203 + }
204 + }
205 + return { bodyOverflow, offenders };
206 + });
207 + overflow = m.bodyOverflow > 1 ? `${m.bodyOverflow}px` : 'ok';
208 + if (m.bodyOverflow > 1) problems.push(`horizontal overflow ${m.bodyOverflow}px at ${MOBILE_WIDTH}px${m.offenders.length ? ` (${m.offenders.join('; ')})` : ''}`);
209 + const realErrors = consoleErrors.filter((t) => !/favicon|net::ERR_|Failed to load resource|hydrat/i.test(t));
210 + if (realErrors.length) notes.push(`${realErrors.length} console error(s): ${realErrors[0].slice(0, 90)}`);
211 + } catch (err) {
212 + notes.push(`browser: ${err.message.split('\n')[0].slice(0, 80)}`);
213 + } finally {
214 + page.off('console', onConsole);
215 + }
216 + }
217 +
218 + const ok = problems.length === 0;
219 + if (!ok) failures++;
220 + if (notes.length) warnings++;
221 + rows.push({ path: p, status: res.status, kb, ms: res.ms, overflow, result: ok ? c.green('PASS') : c.red('FAIL'), note: [...problems, ...notes].join(' · ') });
222 + }
223 +
224 + if (pwr) await pwr.browser.close();
225 +
226 + console.log('');
227 + console.log(`${pad('Route', 52)} ${pad('Status', 6, true)} ${pad('KB', 6, true)} ${pad('ms', 6, true)} ${pad('390px', 7)} Result Notes`);
228 + console.log('-'.repeat(120));
229 + for (const r of rows) console.log(`${pad(r.path, 52)} ${pad(r.status, 6, true)} ${pad(r.kb, 6, true)} ${pad(r.ms, 6, true)} ${pad(r.overflow, 7)} ${r.result} ${r.note ? c.dim(r.note) : ''}`);
230 + console.log('-'.repeat(120));
231 + const passed = rows.filter((r) => r.result.includes('PASS')).length;
232 + console.log(`${passed}/${rows.length} routes passed · ${failures} failure(s) · ${warnings} warning(s) · ${BASE_URL}`);
233 + process.exit(failures ? 1 : 0);
234 +}
235 +
236 +main().catch((err) => {
237 + console.error(c.red(`smoke crashed: ${err.stack ?? err}`));
238 + process.exit(2);
239 +});
added apps/web/src/app/cancer/[slug]/[[...tab]]/loading.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +/** Streams immediately while the cancer bundle + tab data load (also guarantees a 200 shell on DB errors → error.tsx). */
4 +export default function Loading() {
5 + return <PageSkeleton title="Loading cancer entity" tabs={10} />;
6 +}
modified apps/web/src/app/cancer/[slug]/[[...tab]]/page.tsx +9 −5
@@ -62,9 +62,11 @@ export default async function CancerPage({ params, searchParams }: { params: Pro
62 62 body = <GenomicsTab b={b} cohort={str(sp, 'cohort') || null} />;
63 63 break;
64 64 }
65 − case 'evidence':
66 − body = <EvidenceTab b={b} />;
65 + case 'evidence': {
66 + const sp = await searchParams;
67 + body = <EvidenceTab b={b} evPage={int(sp, 'evPage', 1, 1, 100_000)} />;
67 68 break;
69 + }
68 70 case 'drugs': {
69 71 const sp = await searchParams;
70 72 body = <DrugsTab b={b} jurisdiction={str(sp, 'jurisdiction') || null} />;
@@ -72,12 +74,14 @@ export default async function CancerPage({ params, searchParams }: { params: Pro
72 74 }
73 75 case 'trials': {
74 76 const sp = await searchParams;
75 − body = <TrialsTab b={b} status={str(sp, 'status')} phase={str(sp, 'phase')} page={int(sp, 'page', 1, 1, 1000)} />;
77 + body = <TrialsTab b={b} status={str(sp, 'status')} phase={str(sp, 'phase')} page={int(sp, 'page', 1, 1, 100_000)} />;
76 78 break;
77 79 }
78 − case 'research':
79 − body = <ResearchTab b={b} />;
80 + case 'research': {
81 + const sp = await searchParams;
82 + body = <ResearchTab b={b} pPage={int(sp, 'pPage', 1, 1, 100_000)} />;
80 83 break;
84 + }
81 85 case 'rankings':
82 86 body = <RankingsTab b={b} />;
83 87 break;
added apps/web/src/app/cancers/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading cancers" />;
5 +}
modified apps/web/src/app/cancers/page.tsx +2 −1
@@ -10,7 +10,8 @@ import { fmtInt, humanize } from '@/lib/format';
10 10 import { str, int, oneOf, bool, withParams, type SP } from '@/lib/search-params';
11 11
12 12 export const metadata: Metadata = { title: 'Cancers', description: 'Explore every indexed cancer entity with filters for type, anatomical site, hematologic and pediatric relevance.' };
13 −export const dynamic = 'force-dynamic';
13 +// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.
14 +export const revalidate = 600;
14 15
15 16 const PAGE_SIZE = 50;
16 17
added apps/web/src/app/compare/page.tsx +260 −0
@@ -0,0 +1,260 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import type { ReactNode } from 'react';
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, ConfidenceBadge } from '@/components/ui/badge';
8 +import { SourceBadge } from '@/components/ui/source-badge';
9 +import { CompareBars, type CompareBarDatum } from '@/components/charts/compare-bars';
10 +import { loadCompare, parseCompareIds, COMPARE_MIN, COMPARE_MAX, type CompareEntity } from '@/lib/queries/compare';
11 +import { loadProvenance, toInfo } from '@/lib/queries/provenance';
12 +import { bestRankFor } from '@/lib/queries/rankings';
13 +import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';
14 +import { str, type SP } from '@/lib/search-params';
15 +import { fmtInt, fmtValue, humanize, scopeLabel, toDate, unitLabel } from '@/lib/format';
16 +import { ComparePicker } from './picker';
17 +
18 +export const dynamic = 'force-dynamic';
19 +
20 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
21 + const ids = parseCompareIds(str(await searchParams, 'ids'));
22 + const ents = ids.length ? await loadCompare(ids) : [];
23 + const names = ents.map((e) => e.cancer.canonical_name);
24 + return {
25 + title: names.length >= 2 ? `Compare: ${names.join(' vs ')}` : 'Compare cancers',
26 + description: names.length >= 2 ? `Side-by-side facts for ${names.join(', ')}: taxonomy, US registry figures, trials, literature, curated evidence, cohorts and current ranks — every value with its source and period.` : 'Compare two to four cancers side by side: taxonomy, US registry figures, clinical research, literature, molecular evidence and current ranks, with a source on every value.',
27 + alternates: { canonical: ids.length ? `/compare?ids=${ids.join(',')}` : '/compare' },
28 + robots: ids.length ? { index: false, follow: true } : undefined,
29 + };
30 +}
31 +
32 +const EPI_ROWS: Array<[string, string]> = [
33 + ['mortality_count', 'US annual deaths'],
34 + ['incidence_count', 'US annual new cases'],
35 + ['as_mortality_rate', 'US age-standardized mortality'],
36 + ['as_incidence_rate', 'US age-standardized incidence'],
37 +];
38 +
39 +const RANK_METRICS = ['mortality_count', 'incidence_count', 'as_mortality_rate', 'as_incidence_rate', 'mortality_incidence_ratio', 'trial_gap', 'research_gap', 'active_trials', 'recruiting_trials', 'phase3_trials', 'publications_5y', 'publications_12m', 'publication_growth', 'curated_evidence_items', 'associated_genes', 'genomic_cohorts'];
40 +
41 +/**
42 + * /compare?ids=a,b,c (§100): side-by-side table of facts that exist in the database. Registry figures below the
43 + * top level fall back to the nearest top-level ancestor with an explicit label (methodology#compare).
44 + */
45 +export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
46 + const sp = await searchParams;
47 + const ids = parseCompareIds(str(sp, 'ids'));
48 + const ents = ids.length ? await loadCompare(ids) : [];
49 + const missing = ids.filter((s) => !ents.some((e) => e.cancer.slug === s));
50 + const enough = ents.length >= COMPARE_MIN;
51 + const provIds = ents.flatMap((e) => [...e.figures.values()].map((f) => f.provenance_id));
52 + const prov = await loadProvenance(provIds);
53 +
54 + const registryNote = (e: CompareEntity) => (e.registry && e.registry.depth > 0 ? `registry level: ${e.registry.canonical_name}` : null);
55 + const freshest = ents
56 + .flatMap((e) => [...[...e.figures.values()].map((f) => toDate(f.updated_at)), e.counters ? toDate(e.counters.updated_at) : null, ...e.ranks.map((r) => toDate(r.generated_at))])
57 + .filter((d): d is Date => !!d)
58 + .sort((a, b) => b.getTime() - a.getTime())[0];
59 +
60 + return (
61 + <div>
62 + <PageHeader kicker="Comparison engine" title="Compare cancers" lede="Two to four cancers side by side. Only facts present in the database are shown; every figure carries its source, period and unit. Entities at different hierarchy depths are labelled so counts are not read as equivalent." />
63 + <ComparePicker selected={ents.map((e) => ({ slug: e.cancer.slug, name: e.cancer.canonical_name }))} max={COMPARE_MAX} min={COMPARE_MIN} />
64 + {missing.length ? (
65 + <p className="mt-2 text-[12.5px] text-warn">
66 + Unknown or merged slug{missing.length > 1 ? 's' : ''}: <span className="ci-mono">{missing.join(', ')}</span> — ignored.
67 + </p>
68 + ) : null}
69 +
70 + {!enough ? (
71 + <div className="mt-6">
72 + <EmptyState title={ents.length === 1 ? 'Add at least one more cancer' : 'Pick cancers to compare'} knows={[{ label: 'Pancreatic vs lung vs glioblastoma', href: '/compare?ids=malignant-pancreatic-neoplasm,malignant-lung-neoplasm,glioblastoma' }, { label: 'Breast vs prostate', href: '/compare?ids=malignant-breast-neoplasm,malignant-prostate-neoplasm' }, { label: 'Cancer explorer', href: '/cancers' }]}>
73 + Use the picker above or open <span className="ci-mono">/compare?ids=slug-1,slug-2</span> with cancer slugs from their pages.
74 + </EmptyState>
75 + </div>
76 + ) : (
77 + <div className="mt-6 space-y-8">
78 + <CompareTable ents={ents} title="Taxonomy" id="taxonomy" rows={[
79 + { k: 'CancerIndex ID', cells: ents.map((e) => <span className="ci-mono text-[12px]">{e.cancer.id}</span>) },
80 + { k: 'Entity type', cells: ents.map((e) => humanize(e.cancer.entity_type)) },
81 + { k: 'Hierarchy depth', cells: ents.map((e) => <span className="ci-num">{e.cancer.depth}</span>) },
82 + { k: 'Top-level registry site', cells: ents.map((e) => (e.cancer.top_level ? 'Yes' : e.registry ? <span>No — nearest: <Link className="ci-link" href={`/cancer/${e.registry.slug}`}>{e.registry.canonical_name}</Link> <span className="text-ink-3">({e.registry.depth} level{e.registry.depth === 1 ? '' : 's'} up)</span></span> : <span className="text-ink-3">No top-level ancestor</span>)) },
83 + { k: 'Parents', cells: ents.map((e) => (e.parents.length ? <ul className="space-y-0.5">{e.parents.map((p) => <li key={`${p.hierarchy_type}-${p.slug}`}><Link className="ci-link" href={`/cancer/${p.slug}`}>{p.canonical_name}</Link> <span className="text-[11px] text-ink-3">{p.hierarchy_type}</span></li>)}</ul> : <span className="text-ink-3">root</span>)) },
84 + { k: 'NCIt / OncoTree', cells: ents.map((e) => <span className="ci-mono text-[12px]">{[e.cancer.primary_ncit_code, e.cancer.primary_oncotree_code].filter(Boolean).join(' · ') || '—'}</span>) },
85 + { k: 'Badges', cells: ents.map((e) => <span className="flex flex-wrap gap-1">{e.cancer.rare_cancer === true ? <Badge tone="accent">Rare</Badge> : null}{e.cancer.pediatric_relevant ? <Badge>Pediatric</Badge> : null}{e.cancer.hematologic ? <Badge>Hematologic</Badge> : null}{e.cancer.solid_tumor && !e.cancer.hematologic ? <Badge tone="outline">Solid tumor</Badge> : null}{!e.cancer.malignant ? <Badge tone="outline">Non-malignant</Badge> : null}{e.cancer.top_level ? <Badge tone="outline">Top-level</Badge> : null}{e.cancer.rare_cancer == null ? <span className="text-[11.5px] text-ink-3">rarity unknown</span> : null}</span>) },
86 + ]} caption={<>Taxonomy from NCIt / OncoTree (<ClaimBadge kind="curated" />). Badges are rule-derived from stored attributes.</>} />
87 +
88 + <CompareTable
89 + ents={ents}
90 + title="US registry figures (latest year available per metric)"
91 + id="registry"
92 + headNote={ents.map((e) => registryNote(e))}
93 + rows={EPI_ROWS.map(([metric, label]) => ({
94 + k: label,
95 + cells: ents.map((e) => {
96 + const f = e.figures.get(metric);
97 + if (!f) return e.registry ? <span className="text-[12.5px] text-ink-3">{e.registry.depth > 0 ? 'no registry observation at this level; none for the ancestor either' : 'no observation'}</span> : <span className="text-[12.5px] text-ink-3">no registry observation at this level (no top-level ancestor)</span>;
98 + const p = toInfo(prov.get(f.provenance_id), 'normalized') ?? { sourceSlug: f.source_slug, sourceName: f.source_name };
99 + return (
100 + <span className="flex flex-col gap-0.5">
101 + <span className="flex flex-wrap items-baseline gap-x-1.5">
102 + <span className="ci-num text-[16px] font-medium">{fmtValue(f.value, f.unit)}</span>
103 + <span className="text-[11px] text-ink-3">{unitLabel(f.unit)}</span>
104 + {f.estimate_type !== 'observed' ? <span className="text-[11px] italic text-warn">{f.estimate_type}</span> : null}
105 + </span>
106 + <span className="text-[11.5px] text-ink-3">
107 + {f.year_end && f.year_end !== f.year ? `${f.year}–${f.year_end}` : f.year} · both sexes · all ages{f.standard_population ? ` · ${f.standard_population.replace(/\s*\(.*\)$/, '')}` : ''}
108 + </span>
109 + <span className="flex flex-wrap items-center gap-1">
110 + <SourceBadge p={p} />
111 + <ClaimBadge kind="observed" />
112 + </span>
113 + {e.registry && e.registry.depth > 0 ? <span className="text-[11px] italic text-warn">figures for {e.registry.canonical_name}</span> : null}
114 + </span>
115 + );
116 + }),
117 + }))}
118 + caption={<>Observations as published (site definitions on each entity's Statistics tab). No global figures: IARC / GLOBOCAN is under license review and SEER awaits credentials. For entities below the top level the nearest top-level ancestor's registry figures are shown and labelled; they describe the whole site group, not the subtype.</>}
119 + />
120 +
121 + <CompareTable
122 + ents={ents}
123 + title="Counters (entity + descendants)"
124 + id="counters"
125 + rows={[
126 + ['Active trials', 'active_trial_count', 'clinicaltrials', '/trials'],
127 + ['Recruiting trials', 'recruiting_trial_count', 'clinicaltrials', '/trials?status=RECRUITING'],
128 + ['Active Phase III trials', 'phase3_trial_count', 'clinicaltrials', '/trials?phase=PHASE3'],
129 + ['Publications, last 5 years', 'publication_count_5y', 'pubmed', '/research'],
130 + ['Publications, last 12 months', 'publication_count_12m', 'pubmed', '/research'],
131 + ['Curated evidence items', 'evidence_count', 'civic', '/evidence'],
132 + ['Genes with evidence', 'gene_count', 'civic', '/genomics'],
133 + ['Genomic cohorts', 'cohort_count', 'gdc', '/genomics'],
134 + ['Drugs (any / approved)', 'drug_count', 'civic', '/drugs'],
135 + ].map(([label, field, src, path]) => ({
136 + k: label as string,
137 + cells: ents.map((e) => {
138 + const c = e.counters;
139 + if (!c) return <span className="text-[12.5px] text-ink-3">counters not computed</span>;
140 + const v = c[field as keyof typeof c] as number;
141 + return (
142 + <span className="flex flex-col gap-0.5">
143 + <Link href={`/cancer/${e.cancer.slug}${path}`} className="ci-num text-[16px] font-medium no-underline hover:text-accent">
144 + {fmtInt(v)}
145 + {field === 'drug_count' ? <span className="text-[13px] text-ink-3"> / {fmtInt(c.approved_drug_count)}</span> : null}
146 + </Link>
147 + <span className="flex flex-wrap items-center gap-1 text-[11px] text-ink-3">
148 + <SourceBadge p={{ sourceSlug: src as string, layer: 'derived', note: 'Counter computed by CancerIndex over the entity and its descendants.' }} compact />
149 + <ClaimBadge kind="computed" />
150 + </span>
151 + </span>
152 + );
153 + }),
154 + }))}
155 + caption={<>Counts aggregate over descendants across hierarchy types, so a broad family counts more than one of its subtypes; publication counts are query-based per entity. Counters refreshed {freshest ? <span title={freshest.toISOString()}>{freshest.toISOString().slice(0, 10)}</span> : 'unknown'}.</>}
156 + />
157 +
158 + <CompareTable
159 + ents={ents}
160 + title="Current ranks"
161 + id="ranks"
162 + rows={RANK_METRICS.filter((m) => ents.some((e) => e.ranks.some((r) => r.metric_slug === m))).map((m) => ({
163 + k: ents.flatMap((e) => e.ranks).find((r) => r.metric_slug === m)?.metric_name ?? humanize(m),
164 + cells: ents.map((e) => {
165 + const own = bestRankFor(
166 + e.ranks.filter((r) => r.cancer_id === e.cancer.id),
167 + m,
168 + { geo: 'USA', level: e.cancer.top_level ? 'top' : 'all' },
169 + );
170 + const viaAncestor = !own && e.registry && e.registry.depth > 0 ? bestRankFor(e.ranks.filter((r) => r.cancer_id === e.registry!.id), m, { geo: 'USA', level: 'top' }) : null;
171 + const r = own ?? viaAncestor;
172 + if (!r) return <span className="text-[12px] text-ink-3">not ranked</span>;
173 + return (
174 + <span className="flex flex-col gap-0.5 text-[12.5px]">
175 + <Link className="ci-link" href={`/rankings/${r.metric_slug}?scope=${encodeURIComponent(r.scope_key)}`}>
176 + <span className="ci-num font-medium">#{r.rank}</span> of {fmtInt(r.eligible_entities)}
177 + </Link>
178 + <span className="text-[11px] text-ink-3">
179 + {fmtValue(r.value, r.unit)} {unitLabel(r.unit)} · {scopeLabel(r.scope_key)}
180 + </span>
181 + <span className="flex flex-wrap gap-1">
182 + <ConfidenceBadge level={r.confidence} />
183 + {viaAncestor ? <span className="text-[11px] italic text-warn">rank of {e.registry!.canonical_name}</span> : null}
184 + </span>
185 + </span>
186 + );
187 + }),
188 + }))}
189 + caption={<>Ranks come from current snapshots (metric × scope × formula version); "of n" is the number of eligible entities in that scope. Top-level entities are ranked among the 36 mutually exclusive site groups, others among all malignant entities — the two are not comparable. <Link className="ci-link" href="/methodology#versioning">Versioning</Link>.</>}
190 + emptyText="No current ranking snapshot covers these entities."
191 + />
192 +
193 + <Section id="charts" kicker="Charts" title="Side by side" description="Bars are proportional within each chart; missing values are shown as a dash, never as zero.">
194 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
195 + <CompareBars title="US annual deaths" unit="count" data={barsFor(ents, (e) => e.figures.get('mortality_count'))} caption={<>Registry observation · latest year · <ClaimBadge kind="observed" /></>} />
196 + <CompareBars title="US age-standardized mortality" unit="per_100k" data={barsFor(ents, (e) => e.figures.get('as_mortality_rate'))} caption="Per 100,000 · standard population declared by the source" />
197 + <CompareBars title="Active trials" unit="count" data={ents.map((e) => ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.active_trial_count : null, note: 'ClinicalTrials.gov · entity + descendants', href: `/cancer/${e.cancer.slug}/trials` }))} caption={<>Counter · <ClaimBadge kind="computed" /></>} />
198 + <CompareBars title="Publications, last 5 years" unit="count" data={ents.map((e) => ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.publication_count_5y : null, note: 'PubMed · stored query per entity', href: `/cancer/${e.cancer.slug}/research` }))} caption={<>Counter · <ClaimBadge kind="computed" /></>} />
199 + </div>
200 + </Section>
201 +
202 + <Note>Comparisons juxtapose facts of different natures (registry counts, registrations, literature volume). They are not a judgment of severity or priority, and population figures never describe an individual.</Note>
203 + <Freshness dataUpdatedAt={freshest ?? null} extra="each cell states its own period and source" />
204 + </div>
205 + )}
206 + </div>
207 + );
208 +}
209 +
210 +function barsFor(ents: CompareEntity[], pick: (e: CompareEntity) => { value: number; year: number; estimate_type: string } | undefined): CompareBarDatum[] {
211 + return ents.map((e) => {
212 + const f = pick(e);
213 + const anc = e.registry && e.registry.depth > 0 ? e.registry.canonical_name : null;
214 + return { label: e.cancer.canonical_name, value: f ? Number(f.value) : null, note: f ? `${f.year} · ${f.estimate_type}${anc ? ` · registry level: ${anc}` : ''}` : 'no registry observation', href: `/cancer/${e.cancer.slug}/statistics`, muted: !!anc };
215 + });
216 +}
217 +
218 +function CompareTable({ ents, title, id, rows, caption, headNote, emptyText }: { ents: CompareEntity[]; title: string; id: string; rows: Array<{ k: string; cells: ReactNode[] }>; caption?: ReactNode; headNote?: Array<string | null>; emptyText?: string }) {
219 + const nonEmpty = rows;
220 + return (
221 + <Section id={id} kicker="Facts" title={title}>
222 + {nonEmpty.length === 0 ? (
223 + <EmptyState compact>{emptyText ?? 'Nothing to compare in this block.'}</EmptyState>
224 + ) : (
225 + <div className="ci-table-wrap">
226 + <table className="ci-table" style={{ minWidth: `${180 + ents.length * 200}px` }}>
227 + <thead>
228 + <tr>
229 + <th className="sticky-col">Fact</th>
230 + {ents.map((e, i) => (
231 + <th key={e.cancer.id}>
232 + <Link className="ci-link normal-case tracking-normal" href={`/cancer/${e.cancer.slug}`} style={{ fontSize: 13 }}>
233 + {e.cancer.canonical_name}
234 + </Link>
235 + {headNote?.[i] ? <span className="block text-[10.5px] font-normal normal-case tracking-normal text-warn">{headNote[i]}</span> : null}
236 + </th>
237 + ))}
238 + </tr>
239 + </thead>
240 + <tbody>
241 + {nonEmpty.map((r) => (
242 + <tr key={r.k}>
243 + <th scope="row" className="sticky-col text-left text-[12.5px] font-medium text-ink-2" style={{ verticalAlign: 'top', padding: '7px 10px' }}>
244 + {r.k}
245 + </th>
246 + {r.cells.map((c, i) => (
247 + <td key={ents[i]?.cancer.id ?? i} className="text-[13px]">
248 + {c}
249 + </td>
250 + ))}
251 + </tr>
252 + ))}
253 + </tbody>
254 + </table>
255 + </div>
256 + )}
257 + {caption ? <p className="mt-2 flex flex-wrap items-center gap-1 text-[12px] text-ink-3">{caption}</p> : null}
258 + </Section>
259 + );
260 +}
added apps/web/src/app/compare/picker.tsx +137 −0
@@ -0,0 +1,137 @@
1 +'use client';
2 +
3 +import { useEffect, useRef, useState } from 'react';
4 +import { useRouter } from 'next/navigation';
5 +import { Search, X, Plus } from 'lucide-react';
6 +
7 +interface Hit {
8 + type: string;
9 + id: string;
10 + title: string;
11 + subtitle?: string | null;
12 + href: string;
13 + match: string;
14 +}
15 +
16 +/**
17 + * Compare picker: adds/removes cancers by slug; the selection lives in the URL (?ids=a,b,c) so a comparison
18 + * is shareable. Uses the existing /api/search route (database-backed) and keeps only cancer hits.
19 + */
20 +export function ComparePicker({ selected, max, min }: { selected: Array<{ slug: string; name: string }>; max: number; min: number }) {
21 + const router = useRouter();
22 + const [q, setQ] = useState('');
23 + const [hits, setHits] = useState<Hit[]>([]);
24 + const [loading, setLoading] = useState(false);
25 + const [open, setOpen] = useState(false);
26 + const box = useRef<HTMLDivElement>(null);
27 +
28 + useEffect(() => {
29 + const term = q.trim();
30 + if (term.length < 2) {
31 + setHits([]);
32 + return;
33 + }
34 + const ctrl = new AbortController();
35 + const t = setTimeout(async () => {
36 + setLoading(true);
37 + try {
38 + const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=20`, { signal: ctrl.signal });
39 + const j = (await r.json()) as { data: Hit[] };
40 + const chosen = new Set(selected.map((s) => s.slug));
41 + setHits((j.data ?? []).filter((h) => h.type === 'cancer' && !chosen.has(h.href.replace(/^\/cancer\//, ''))).slice(0, 8));
42 + setOpen(true);
43 + } catch {
44 + /* aborted */
45 + } finally {
46 + setLoading(false);
47 + }
48 + }, 150);
49 + return () => {
50 + clearTimeout(t);
51 + ctrl.abort();
52 + };
53 + }, [q, selected]);
54 +
55 + useEffect(() => {
56 + const onDoc = (e: MouseEvent) => {
57 + if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
58 + };
59 + document.addEventListener('mousedown', onDoc);
60 + return () => document.removeEventListener('mousedown', onDoc);
61 + }, []);
62 +
63 + const navigate = (slugs: string[]) => {
64 + router.push(slugs.length ? `/compare?ids=${slugs.map(encodeURIComponent).join(',')}` : '/compare');
65 + };
66 + const add = (href: string) => {
67 + const slug = href.replace(/^\/cancer\//, '');
68 + if (!slug || selected.some((s) => s.slug === slug) || selected.length >= max) return;
69 + setQ('');
70 + setHits([]);
71 + setOpen(false);
72 + navigate([...selected.map((s) => s.slug), slug]);
73 + };
74 + const remove = (slug: string) => navigate(selected.filter((s) => s.slug !== slug).map((s) => s.slug));
75 + const full = selected.length >= max;
76 +
77 + return (
78 + <div className="border border-rule bg-paper-2 p-3">
79 + <div className="flex flex-wrap items-center gap-2">
80 + {selected.map((s) => (
81 + <span key={s.slug} className="inline-flex items-center gap-1 border border-rule-strong bg-paper px-2 py-1 text-[13px]">
82 + {s.name}
83 + <button type="button" onClick={() => remove(s.slug)} aria-label={`Remove ${s.name} from the comparison`} className="ml-0.5 text-ink-3 hover:text-danger">
84 + <X className="h-3.5 w-3.5" aria-hidden />
85 + </button>
86 + </span>
87 + ))}
88 + {selected.length === 0 ? <span className="text-[13px] text-ink-3">No cancer selected yet.</span> : null}
89 + </div>
90 + <div ref={box} className="relative mt-2">
91 + <label className="sr-only" htmlFor="compare-picker-input">
92 + Add a cancer to the comparison
93 + </label>
94 + <div className={`flex items-center gap-2 border bg-paper px-2.5 ${full ? 'border-rule text-ink-4' : 'border-rule-strong focus-within:border-accent'}`}>
95 + {full ? <Plus className="h-4 w-4 shrink-0" aria-hidden /> : <Search className="h-4 w-4 shrink-0 text-ink-3" aria-hidden />}
96 + <input
97 + id="compare-picker-input"
98 + value={q}
99 + onChange={(e) => setQ(e.target.value)}
100 + onFocus={() => hits.length && setOpen(true)}
101 + onKeyDown={(e) => {
102 + if (e.key === 'Enter' && hits[0]) {
103 + e.preventDefault();
104 + add(hits[0].href);
105 + } else if (e.key === 'Escape') setOpen(false);
106 + }}
107 + disabled={full}
108 + placeholder={full ? `Maximum ${max} cancers — remove one to add another` : `Add a cancer (${selected.length}/${max}) — e.g. glioblastoma, pancreatic, KRAS…`}
109 + className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-ink-4 disabled:cursor-not-allowed"
110 + autoComplete="off"
111 + spellCheck={false}
112 + role="combobox"
113 + aria-expanded={open && hits.length > 0}
114 + aria-controls="compare-picker-results"
115 + />
116 + </div>
117 + {open && q.trim().length >= 2 ? (
118 + <ul id="compare-picker-results" role="listbox" className="absolute left-0 right-0 z-20 mt-1 max-h-72 overflow-y-auto border border-rule-strong bg-paper shadow-lg">
119 + {hits.map((h) => (
120 + <li key={h.id} role="option" aria-selected={false}>
121 + <button type="button" onClick={() => add(h.href)} className="flex w-full items-baseline justify-between gap-2 px-3 py-2 text-left text-[13.5px] hover:bg-accent-soft">
122 + <span className="min-w-0 truncate">{h.title}</span>
123 + {h.subtitle ? <span className="shrink-0 text-[11.5px] text-ink-3">{h.subtitle}</span> : null}
124 + </button>
125 + </li>
126 + ))}
127 + {!loading && hits.length === 0 ? <li className="px-3 py-2 text-[13px] text-ink-3">No matching cancer entity.</li> : null}
128 + {loading ? <li className="px-3 py-2 text-[13px] text-ink-3">Searching…</li> : null}
129 + </ul>
130 + ) : null}
131 + </div>
132 + <p className="mt-1.5 text-[11.5px] text-ink-3">
133 + Compare {min} to {max} cancers. The selection is in the URL — copy it to share this comparison.
134 + </p>
135 + </div>
136 + );
137 +}
added apps/web/src/app/countries/page.tsx +163 −0
@@ -0,0 +1,163 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Section, Note } from '@/components/ui/section';
4 +import { EmptyState } from '@/components/ui/empty-state';
5 +import { Freshness } from '@/components/ui/freshness';
6 +import { Badge } from '@/components/ui/badge';
7 +import { SourceBadge } from '@/components/ui/source-badge';
8 +import { listGeographiesWithObservations, topCancersFor, allSitesObservations, WHO_REGION_LABEL } from '@/lib/queries/geography';
9 +import { listSources } from '@/lib/queries/sources';
10 +import { fmtInt, fmtValue, humanize, toDate, unitLabel } from '@/lib/format';
11 +
12 +export const metadata: Metadata = {
13 + title: 'Countries',
14 + description: 'Geographies with cancer epidemiology observations in CancerIndex: years covered, sources, and the latest-year figures per country.',
15 + alternates: { canonical: '/countries' },
16 +};
17 +export const revalidate = 3600;
18 +
19 +/**
20 + * /countries (§47): every geography that carries at least one epidemiology observation, with its year span,
21 + * the sources behind it and either the all-sites totals (when the source publishes one) or the top cancer by
22 + * deaths / new cases for the latest year. Geographies without data are not listed — nothing is invented.
23 + */
24 +export default async function CountriesPage() {
25 + const [geos, sources] = await Promise.all([listGeographiesWithObservations(), listSources()]);
26 + const epiSources = sources.filter((s) => ['cdc-uscs', 'cdc-wonder', 'seer', 'seer-explorer', 'iarc-globocan'].includes(s.slug));
27 +
28 + const details = await Promise.all(
29 + geos.map(async (g) => {
30 + const [allSites, deaths, cases] = await Promise.all([allSitesObservations(g.id, g.max_year, 'all'), topCancersFor(g, 'mortality_count', g.max_year, 'all', 1), topCancersFor(g, 'incidence_count', g.max_year, 'all', 1)]);
31 + return { g, allSites, deaths, cases };
32 + }),
33 + );
34 + const freshest = geos.map((g) => toDate(g.last_updated)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
35 +
36 + return (
37 + <div>
38 + <PageHeader kicker="Geographies" title="Countries with epidemiology data" lede="Population statistics are attached per geography, year and sex, exactly as published by a registry or statistical agency. This page lists only geographies for which at least one observation has been ingested.">
39 + <p className="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px] text-ink-3">
40 + Epidemiology sources:
41 + {epiSources.map((s) => (
42 + <span key={s.slug} className="inline-flex items-center gap-1">
43 + <SourceBadge p={{ sourceSlug: s.slug, sourceName: s.name, license: s.license, layer: 'raw' }} compact />
44 + <Badge tone={s.status === 'active' ? 'ok' : s.status === 'review' || s.status === 'awaiting_credentials' ? 'warn' : 'neutral'}>{humanize(s.status)}</Badge>
45 + </span>
46 + ))}
47 + </p>
48 + </PageHeader>
49 +
50 + <Section id="list" kicker="Coverage" title={`${fmtInt(geos.length)} ${geos.length === 1 ? 'geography' : 'geographies'} with observations`} description="Years covered, distinct cancer entities with at least one observation, and the latest-year figures. Totals across all cancer sites are shown only when the source itself publishes an all-sites row; sites are never summed by CancerIndex.">
51 + {geos.length === 0 ? (
52 + <EmptyState title="No geography has observations yet">
53 + Country pages appear as soon as a licensed registry connector ingests observations. IARC / GLOBOCAN stays under license review and SEER awaits credentials, so global coverage is not available yet.
54 + </EmptyState>
55 + ) : (
56 + <>
57 + <div className="ci-table-wrap">
58 + <table className="ci-table">
59 + <thead>
60 + <tr>
61 + <th>Geography</th>
62 + <th>ISO3</th>
63 + <th>WHO region</th>
64 + <th className="num">Years</th>
65 + <th className="num">Cancers</th>
66 + <th className="num">Observations</th>
67 + <th>Sources</th>
68 + <th>Latest year</th>
69 + <th>Highest annual deaths</th>
70 + <th>Highest annual new cases</th>
71 + </tr>
72 + </thead>
73 + <tbody>
74 + {details.map(({ g, allSites, deaths, cases }) => {
75 + const total = (metric: string) => allSites.find((a) => a.metric === metric);
76 + const td = total('mortality_count');
77 + const tc = total('incidence_count');
78 + const d0 = deaths.rows[0];
79 + const c0 = cases.rows[0];
80 + return (
81 + <tr key={g.id}>
82 + <td>
83 + <Link className="ci-link font-medium" href={`/country/${g.slug}`}>
84 + {g.name}
85 + </Link>
86 + <span className="ml-1.5 text-[11.5px] text-ink-3">{humanize(g.kind)}</span>
87 + </td>
88 + <td className="ci-mono">{g.iso3 ?? '—'}</td>
89 + <td className="text-[12.5px] text-ink-2">{g.who_region ? WHO_REGION_LABEL[g.who_region] ?? g.who_region : '—'}</td>
90 + <td className="num">
91 + {g.min_year}–{g.max_year}
92 + </td>
93 + <td className="num">{fmtInt(g.n_cancers)}</td>
94 + <td className="num">{fmtInt(g.n_obs)}</td>
95 + <td>
96 + <span className="inline-flex flex-wrap gap-1">
97 + {(g.sources ?? []).map((s) => (
98 + <SourceBadge key={s.slug} p={{ sourceSlug: s.slug, sourceName: s.name }} compact />
99 + ))}
100 + </span>
101 + </td>
102 + <td className="ci-num">{g.max_year}</td>
103 + <td className="text-[12.5px]">
104 + {td ? (
105 + <span>
106 + All sites: <span className="ci-num font-medium">{fmtValue(td.value, td.unit)}</span> {unitLabel(td.unit)} ({td.year})
107 + </span>
108 + ) : d0 ? (
109 + <span>
110 + <Link className="ci-link" href={`/cancer/${d0.slug}`}>
111 + {d0.canonical_name}
112 + </Link>{' '}
113 + <span className="ci-num font-medium">{fmtValue(d0.value, d0.unit)}</span> <span className="text-ink-3">({deaths.year})</span>
114 + </span>
115 + ) : (
116 + <span className="text-ink-4">—</span>
117 + )}
118 + </td>
119 + <td className="text-[12.5px]">
120 + {tc ? (
121 + <span>
122 + All sites: <span className="ci-num font-medium">{fmtValue(tc.value, tc.unit)}</span> {unitLabel(tc.unit)} ({tc.year})
123 + </span>
124 + ) : c0 ? (
125 + <span>
126 + <Link className="ci-link" href={`/cancer/${c0.slug}`}>
127 + {c0.canonical_name}
128 + </Link>{' '}
129 + <span className="ci-num font-medium">{fmtValue(c0.value, c0.unit)}</span> <span className="text-ink-3">({cases.year})</span>
130 + </span>
131 + ) : (
132 + <span className="text-ink-4">—</span>
133 + )}
134 + </td>
135 + </tr>
136 + );
137 + })}
138 + </tbody>
139 + </table>
140 + </div>
141 + <p className="mt-2 text-[12px] text-ink-3">"Highest annual deaths / new cases" is the top single site group for the latest year of that metric, both sexes, all ages — not a national total. Where a source publishes an all-sites aggregate it is shown instead and labelled "All sites".</p>
142 + <Freshness dataUpdatedAt={freshest} extra={`${fmtInt(geos.reduce((n, g) => n + g.n_obs, 0))} observations in total`} />
143 + </>
144 + )}
145 + </Section>
146 +
147 + <Section id="status" kicker="Coverage status" title="Why only these geographies?" level={3}>
148 + <Note>
149 + CancerIndex publishes population statistics only from sources whose license has been reviewed (CLAUDE.md §10.4). U.S. Cancer Statistics (CDC, NPCR/SEER incidence and NVSS mortality) is approved and ingested. The IARC Global Cancer Observatory (GLOBOCAN), which would provide estimates for 185 countries, is under license review; the SEER API awaits credentials. Until then no country outside the United States has observations, and no global ranking of countries is shown. Estimates are never substituted for registry counts.
150 + </Note>
151 + <p className="mt-2 text-[13px]">
152 + <Link className="ci-link" href="/sources">
153 + Source registry and license status →
154 + </Link>{' '}
155 + ·{' '}
156 + <Link className="ci-link" href="/methodology#country-scopes">
157 + Methodology: country scopes →
158 + </Link>
159 + </p>
160 + </Section>
161 + </div>
162 + );
163 +}
added apps/web/src/app/country/[slug]/page.tsx +405 −0
@@ -0,0 +1,405 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { PageHeader, Section, KV, Note } from '@/components/ui/section';
5 +import { EmptyState } from '@/components/ui/empty-state';
6 +import { Freshness } from '@/components/ui/freshness';
7 +import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';
8 +import { SourceBadge } from '@/components/ui/source-badge';
9 +import { Breadcrumbs } from '@/components/layout/breadcrumbs';
10 +import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart';
11 +import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography';
12 +import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';
13 +import { loadProvenance, toInfo } from '@/lib/queries/provenance';
14 +import { listSources } from '@/lib/queries/sources';
15 +import { jsonLd } from '@/lib/seo';
16 +import { SITE_URL, SITE_NAME } from '@/lib/site';
17 +import { str, int, oneOf, type SP } from '@/lib/search-params';
18 +import { fmtInt, fmtValue, humanize, toDate, unitLabel } from '@/lib/format';
19 +
20 +export const revalidate = 3600;
21 +
22 +type Params = { slug: string };
23 +
24 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
25 + const geo = await getGeographyBySlug((await params).slug);
26 + if (!geo) return { title: 'Not found' };
27 + return {
28 + title: `${geo.name} — cancer statistics`,
29 + description: `Cancer incidence and mortality observations for ${geo.name}: top cancers by annual deaths, new cases and age-standardized rates per year and sex, with trends and sources.`,
30 + alternates: { canonical: `/country/${geo.slug}` },
31 + };
32 +}
33 +
34 +/**
35 + * /country/[slug] (§47): header, latest-year summary, top-cancer tables per metric with sex/year selectors,
36 + * a multi-line trend of the top 8 cancers' age-standardized mortality, and a sources/freshness block.
37 + * Every value comes from epidemiology_observations; ranks from the matching ranking snapshot when it exists.
38 + */
39 +export default async function CountryPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {
40 + const { slug } = await params;
41 + const sp = await searchParams;
42 + const geo = await getGeographyBySlug(slug);
43 + if (!geo) notFound();
44 + const [coverage, years, sources] = await Promise.all([coverageFor(geo.id), yearsFor(geo.id), listSources()]);
45 + const latestYear = years[0];
46 + const sex = oneOf<Sex>(sp, 'sex', SEXES, 'all');
47 + const year = latestYear ? int(sp, 'year', latestYear, years[years.length - 1]!, latestYear) : null;
48 + const scopeCode = geographyScopeCode(geo);
49 +
50 + const crumbs = [{ label: 'Countries', href: '/countries' }, ...(geo.parent_slug && geo.parent_name && geo.kind === 'subdivision' ? [{ label: geo.parent_name, href: `/country/${geo.parent_slug}` }] : []), { label: geo.name }];
51 +
52 + if (!latestYear || year == null) {
53 + return (
54 + <div>
55 + <Breadcrumbs items={crumbs} className="pt-5" />
56 + <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)} />
57 + <EmptyState title="No epidemiology observation for this geography yet">
58 + Country statistics appear once a licensed registry connector has ingested observations for {geo.name}. IARC / GLOBOCAN (185 countries) is under license review and the SEER API awaits credentials.
59 + <div className="mt-1">
60 + <Link className="ci-link" href="/countries">
61 + Geographies with data
62 + </Link>
63 + </div>
64 + </EmptyState>
65 + </div>
66 + );
67 + }
68 +
69 + const [allSites, ...tops] = await Promise.all([allSitesObservations(geo.id, year, sex), ...BURDEN_METRICS.map((m) => topCancersFor(geo, m, year, sex, 40))]);
70 + const byMetric = new Map(tops.map((t) => [t.metric, t]));
71 + const asmr = byMetric.get('as_mortality_rate');
72 + const trendIds = (asmr?.rows.length ? asmr : byMetric.get('mortality_count'))?.rows.slice(0, 8).map((r) => r.cancer_id) ?? [];
73 + const trendMetric = asmr?.rows.length ? 'as_mortality_rate' : 'mortality_count';
74 + const trend = await trendFor(geo.id, trendMetric, sex, trendIds);
75 + const provIds = tops.flatMap((t) => t.rows.slice(0, 1).map((r) => r.provenance_id));
76 + const prov = await loadProvenance([...provIds, ...allSites.map((a) => a.provenance_id)]);
77 +
78 + const usedSources = [...new Set(coverage.map((c) => c.source_slug))].map((s) => sources.find((x) => x.slug === s)).filter((s): s is NonNullable<typeof s> => !!s);
79 + const freshest = coverage.map((c) => toDate(c.last_updated)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
80 + const nCancers = Math.max(...coverage.map((c) => c.n_cancers), 0);
81 + const estimateTypes = [...new Set(coverage.flatMap((c) => c.estimate_types))];
82 + const standardPop = coverage.find((c) => c.standard_population)?.standard_population ?? null;
83 +
84 + const ld = {
85 + '@context': 'https://schema.org',
86 + '@type': 'Dataset',
87 + name: `Cancer incidence and mortality observations — ${geo.name}`,
88 + description: `Per-site cancer observations for ${geo.name} (${years[years.length - 1]}–${latestYear}): annual deaths, new cases and age-standardized rates by sex, as published by ${usedSources.map((s) => s.name).join('; ') || 'the source registry'}. Normalized by CancerIndex without changing values.`,
89 + url: `${SITE_URL}/country/${geo.slug}`,
90 + creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },
91 + isBasedOn: usedSources.map((s) => s.homepage ?? `${SITE_URL}/source/${s.slug}`),
92 + spatialCoverage: { '@type': 'Place', name: geo.name, ...(geo.iso3 ? { identifier: geo.iso3 } : {}) },
93 + temporalCoverage: `${years[years.length - 1]}/${latestYear}`,
94 + variableMeasured: [...new Set(coverage.map((c) => EPI_METRIC_LABEL[c.metric] ?? c.metric))],
95 + license: usedSources.map((s) => s.license).filter(Boolean).join('; ') || undefined,
96 + ...(freshest ? { dateModified: freshest.toISOString() } : {}),
97 + };
98 +
99 + const q = (over: Partial<{ sex: string; year: number }>) => {
100 + const p = new URLSearchParams();
101 + const s = over.sex ?? sex;
102 + const y = over.year ?? year;
103 + if (s !== 'all') p.set('sex', s);
104 + if (y !== latestYear) p.set('year', String(y));
105 + const qs = p.toString();
106 + return `/country/${geo.slug}${qs ? `?${qs}` : ''}`;
107 + };
108 +
109 + return (
110 + <article>
111 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(ld) }} />
112 + <Breadcrumbs items={crumbs} className="pt-5" />
113 + <PageHeader kicker={humanize(geo.kind)} title={geo.name} lede={headerLede(geo)}>
114 + <KV
115 + className="mt-3 max-w-xl"
116 + items={[
117 + { k: 'ISO 3166-1', v: geo.iso3 ? <span className="ci-mono">{geo.iso3}{geo.iso2 ? ` · ${geo.iso2}` : ''}</span> : null },
118 + { k: 'WHO region', v: geo.who_region ? WHO_REGION_LABEL[geo.who_region] ?? geo.who_region : null },
119 + { k: 'Population', v: geo.population ? <span className="ci-num">{fmtInt(geo.population)}{geo.population_year ? ` (${geo.population_year})` : ''}</span> : <span className="text-ink-3">not seeded — CancerIndex shows only rates and counts published by the source</span> },
120 + { k: 'Years covered', v: <span className="ci-num">{years[years.length - 1]}–{latestYear}</span> },
121 + { k: 'Cancer entities', v: <span className="ci-num">{fmtInt(nCancers)}</span> },
122 + { k: 'Estimate types', v: estimateTypes.map((t) => <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">{t}</Badge>) },
123 + ]}
124 + />
125 + </PageHeader>
126 +
127 + {/* Selectors: sex + year (server-side URL state) */}
128 + <nav aria-label="Scope" className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-rule py-2 text-[13px]">
129 + <div className="flex items-center gap-1.5">
130 + <span className="ci-kicker mr-1">Sex</span>
131 + {SEXES.map((s) => (
132 + <Link key={s} href={q({ sex: s })} aria-current={s === sex ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${s === sex ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>
133 + {s === 'all' ? 'Both sexes' : humanize(s)}
134 + </Link>
135 + ))}
136 + </div>
137 + <div className="flex flex-wrap items-center gap-1.5">
138 + <span className="ci-kicker mr-1">Year</span>
139 + {years.slice(0, 8).map((y) => (
140 + <Link key={y} href={q({ year: y })} aria-current={y === year ? 'page' : undefined} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>
141 + {y}
142 + </Link>
143 + ))}
144 + {years.length > 8 ? (
145 + <details className="relative">
146 + <summary className="cursor-pointer border border-rule px-2 py-0.5 text-ink-2 hover:border-accent">earlier…</summary>
147 + <div className="absolute left-0 z-10 mt-1 flex max-w-[320px] flex-wrap gap-1 border border-rule-strong bg-paper p-2 shadow-lg">
148 + {years.slice(8).map((y) => (
149 + <Link key={y} href={q({ year: y })} className={`ci-num border px-2 py-0.5 no-underline ${y === year ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>
150 + {y}
151 + </Link>
152 + ))}
153 + </div>
154 + </details>
155 + ) : null}
156 + </div>
157 + </nav>
158 +
159 + {/* Summary cards */}
160 + <Section id="summary" kicker="Summary" title={`${year} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages`} description="Totals across all cancer sites are shown only when the source publishes an all-sites row. Otherwise the per-site figures below stand on their own; CancerIndex never sums site groups (definitions overlap between sources).">
161 + {allSites.length ? (
162 + <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">
163 + {allSites.map((a) => (
164 + <li key={a.metric} className="bg-paper px-3 py-2.5">
165 + <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[a.metric] ?? a.metric} · all sites</span>
166 + <span className="mt-0.5 flex items-baseline gap-1.5">
167 + <span className="ci-num font-display text-2xl">{fmtValue(a.value, a.unit)}</span>
168 + <span className="text-[11.5px] text-ink-3">{unitLabel(a.unit)}</span>
169 + </span>
170 + <span className="block text-[11.5px] text-ink-3">
171 + {a.year} · {a.site_definition}
172 + </span>
173 + <span className="mt-1 flex items-center gap-1.5">
174 + <SourceBadge p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} />
175 + <ClaimBadge kind="observed" />
176 + {a.estimate_type !== 'observed' ? <Badge tone="warn">{a.estimate_type}</Badge> : null}
177 + </span>
178 + </li>
179 + ))}
180 + </ul>
181 + ) : (
182 + <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">
183 + {BURDEN_METRICS.map((m) => {
184 + const t = byMetric.get(m);
185 + const top = t?.rows[0];
186 + return (
187 + <li key={m} className="bg-paper px-3 py-2.5">
188 + <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{EPI_METRIC_LABEL[m] ?? m}</span>
189 + {top && t ? (
190 + <>
191 + <span className="mt-0.5 block text-[12px] text-ink-3">
192 + highest site group{t.year !== year ? ` (${t.year}, latest available)` : ''}:
193 + </span>
194 + <Link href={`/cancer/${top.slug}`} className="ci-link block truncate text-[14px]">
195 + {top.canonical_name}
196 + </Link>
197 + <span className="flex items-baseline gap-1.5">
198 + <span className="ci-num font-display text-2xl">{fmtValue(top.value, top.unit)}</span>
199 + <span className="text-[11.5px] text-ink-3">{unitLabel(top.unit)}</span>
200 + </span>
201 + <span className="mt-1 flex flex-wrap items-center gap-1.5">
202 + <SourceBadge p={toInfo(prov.get(top.provenance_id)) ?? { sourceSlug: top.source_slug, sourceName: top.source_name }} />
203 + <ClaimBadge kind="observed" />
204 + <span className="text-[11px] text-ink-3">{fmtInt(t.rows.length)} site groups</span>
205 + </span>
206 + </>
207 + ) : (
208 + <span className="mt-1 block text-[12.5px] text-ink-3">no observation for {year}</span>
209 + )}
210 + </li>
211 + );
212 + })}
213 + </ul>
214 + )}
215 + {!allSites.length ? <p className="mt-2 text-[12px] text-ink-3">No all-sites total in source for this scope — per-site figures below; no national total is computed.</p> : null}
216 + </Section>
217 +
218 + {/* Top tables */}
219 + {tops.map((t) => (
220 + <TopTable key={t.metric} t={t} year={year} sex={sex} scopeCode={scopeCode} prov={prov} geoSlug={geo.slug} />
221 + ))}
222 +
223 + {/* Trend */}
224 + <Section id="trend" kicker="Trend" title={`${EPI_METRIC_LABEL[trendMetric]} — top ${Math.min(8, trendIds.length)} cancers, all years`} description={`Top cancers of ${year} (${sex === 'all' ? 'both sexes' : humanize(sex)}) traced over every year published for ${geo.name}. Dashed lines mark estimated or projected values; the y axis starts at zero.`}>
225 + {trend.length ? (
226 + <>
227 + <TrendChart series={toSeries(trend)} unit={trend[0]!.unit} ariaLabel={`${EPI_METRIC_LABEL[trendMetric]} by year for the top ${trendIds.length} cancers in ${geo.name}`} yLabel={unitLabel(trend[0]!.unit)} />
228 + <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
229 + <SourceBadge p={{ sourceSlug: trend[0]!.source_slug }} compact />
230 + <ClaimBadge kind="observed" />
231 + {trend[0]!.standard_population ? <span>standard: {trend[0]!.standard_population}</span> : null}
232 + <span>{fmtInt(trend.length)} points</span>
233 + </p>
234 + </>
235 + ) : (
236 + <EmptyState compact>No multi-year series for this scope.</EmptyState>
237 + )}
238 + </Section>
239 +
240 + {/* Sources & freshness */}
241 + <Section id="sources" kicker="Provenance" title="Sources and freshness" description="What each source covers for this geography, exactly as ingested. Values are normalized (units, labels) but never changed.">
242 + <div className="ci-table-wrap">
243 + <table className="ci-table">
244 + <thead>
245 + <tr>
246 + <th>Source</th>
247 + <th>Metric</th>
248 + <th>Sex</th>
249 + <th className="num">Years</th>
250 + <th className="num">Cancers</th>
251 + <th>Estimate types</th>
252 + <th>Standard population</th>
253 + <th>Ingested</th>
254 + </tr>
255 + </thead>
256 + <tbody>
257 + {coverage.map((c) => (
258 + <tr key={`${c.source_slug}-${c.metric}-${c.sex}`}>
259 + <td>
260 + <SourceBadge p={{ sourceSlug: c.source_slug, sourceName: c.source_name }} compact />
261 + </td>
262 + <td>{EPI_METRIC_LABEL[c.metric] ?? c.metric}</td>
263 + <td>{c.sex === 'all' ? 'Both' : humanize(c.sex)}</td>
264 + <td className="num">
265 + {c.min_year}–{c.max_year} <span className="text-ink-3">({c.n_years})</span>
266 + </td>
267 + <td className="num">{fmtInt(c.n_cancers)}</td>
268 + <td>
269 + {c.estimate_types.map((t) => (
270 + <Badge key={t} tone={t === 'observed' ? 'ok' : 'warn'} className="mr-1">
271 + {t}
272 + </Badge>
273 + ))}
274 + </td>
275 + <td className="text-[12px] text-ink-3">{c.standard_population ?? '—'}</td>
276 + <td className="text-[12px] text-ink-3">{toDate(c.last_updated)?.toISOString().slice(0, 10) ?? '—'}</td>
277 + </tr>
278 + ))}
279 + </tbody>
280 + </table>
281 + </div>
282 + <ul className="mt-3 space-y-1.5 text-[13px]">
283 + {usedSources.map((s) => (
284 + <li key={s.slug} className="flex flex-wrap items-center gap-2">
285 + <Link className="ci-link" href={`/source/${s.slug}`}>
286 + {s.name}
287 + </Link>
288 + <Badge tone={s.license_status === 'approved' ? 'ok' : 'warn'}>license: {s.license_status}</Badge>
289 + {s.license ? <span className="text-[12px] text-ink-3">{s.license}</span> : null}
290 + </li>
291 + ))}
292 + </ul>
293 + <Note>
294 + {standardPop ? `Age-standardized rates use the ${standardPop}; they are not comparable with rates standardized to the World (Segi) population used by IARC. ` : ''}
295 + Global comparisons are not available: the IARC Global Cancer Observatory remains under license review and the SEER API awaits credentials (CLAUDE.md §10.3-10.4). Only geographies with licensed observations get a page; see <Link className="ci-link" href="/countries">/countries</Link>.
296 + </Note>
297 + <Freshness dataUpdatedAt={freshest} sourceVersion={coverage.length ? `${years[years.length - 1]}–${latestYear}` : null} extra={`${fmtInt(coverage.reduce((n, c) => n + c.n_years * c.n_cancers, 0))} observations approx. · ranks from snapshots geo=${scopeCode}`} />
298 + </Section>
299 + </article>
300 + );
301 +}
302 +
303 +function headerLede(geo: { name: string; kind: string }) {
304 + return `Cancer incidence and mortality for ${geo.name} as published by the source registry: per site group, per year and sex, with age-standardized rates where the source provides them. Population statistics describe groups, never individuals.`;
305 +}
306 +
307 +function toSeries(points: Awaited<ReturnType<typeof trendFor>>): TrendSeries[] {
308 + const map = new Map<string, TrendSeries>();
309 + for (const p of points) {
310 + if (!map.has(p.cancer_id)) map.set(p.cancer_id, { key: p.cancer_id, name: p.canonical_name, href: `/cancer/${p.slug}/statistics`, points: [], dashed: false });
311 + const s = map.get(p.cancer_id)!;
312 + s.points.push({ x: Number(p.year), y: Number(p.value) });
313 + if (p.estimate_type !== 'observed') s.dashed = true;
314 + }
315 + // Order series by latest value descending so the legend matches the table.
316 + return [...map.values()].sort((a, b) => (b.points.at(-1)?.y ?? 0) - (a.points.at(-1)?.y ?? 0));
317 +}
318 +
319 +function TopTable({ t, year, sex, scopeCode, prov, geoSlug }: { t: TopCancersResult; year: number; sex: Sex; scopeCode: string; prov: Awaited<ReturnType<typeof loadProvenance>>; geoSlug: string }) {
320 + const label = EPI_METRIC_LABEL[t.metric] ?? t.metric;
321 + const first = t.rows[0];
322 + const ranked = t.rows.some((r) => r.rank != null);
323 + const scopeKey = t.year != null ? `geo=${scopeCode}|sex=${sex}|age=all|year=${t.year}|level=top` : null;
324 + return (
325 + <Section id={t.metric} kicker="Top cancers" title={`By ${label.toLowerCase()}`} description={t.year == null ? undefined : `${t.year}${t.year !== year ? ` — latest year available for this metric (${year} not yet published by the source)` : ''} · ${sex === 'all' ? 'both sexes' : humanize(sex)} · all ages · ${fmtInt(t.rows.length)} site groups${first?.standard_population ? ` · ${first.standard_population}` : ''}`}>
326 + {!first ? (
327 + <EmptyState compact>No {label.toLowerCase()} observation for this geography and sex.</EmptyState>
328 + ) : (
329 + <>
330 + <div className="ci-table-wrap">
331 + <table className="ci-table">
332 + <thead>
333 + <tr>
334 + <th className="num">#</th>
335 + <th>Cancer</th>
336 + <th className="num">
337 + {label} ({unitLabel(first.unit)})
338 + </th>
339 + <th className="num">95% CI</th>
340 + <th>Type</th>
341 + <th>Rank in scope</th>
342 + <th>Site definition</th>
343 + <th>Source</th>
344 + </tr>
345 + </thead>
346 + <tbody>
347 + {t.rows.map((r, i) => (
348 + <tr key={r.cancer_id}>
349 + <td className="num">{i + 1}</td>
350 + <td>
351 + <Link className="ci-link" href={`/cancer/${r.slug}`}>
352 + {r.canonical_name}
353 + </Link>
354 + <span className="ml-1.5 text-[11px] text-ink-3">{humanize(r.entity_type)}</span>
355 + </td>
356 + <td className="num font-medium">{fmtValue(r.value, r.unit)}</td>
357 + <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtValue(r.lower_ci, r.unit)}–${fmtValue(r.upper_ci, r.unit)}` : '—'}</td>
358 + <td>
359 + <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge>
360 + </td>
361 + <td className="text-[12.5px]">
362 + {r.rank != null && r.rank_scope_key ? (
363 + <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(r.rank_scope_key)}`} title={r.rank_scope_key}>
364 + #{r.rank} of {fmtInt(r.eligible_entities)}
365 + </Link>
366 + ) : (
367 + <span className="text-ink-4" title="No current ranking snapshot for this metric and scope">no snapshot</span>
368 + )}
369 + </td>
370 + <td className="max-w-[260px] text-[11.5px] text-ink-3">{r.site_definition ?? '—'}</td>
371 + <td>
372 + <span className="inline-flex gap-1">
373 + <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} compact />
374 + <ClaimBadge kind="observed" />
375 + </span>
376 + </td>
377 + </tr>
378 + ))}
379 + </tbody>
380 + </table>
381 + </div>
382 + {/* div, not p: the SourceBadge popover holds a <dl>, which would close a <p> in the HTML parser (hydration mismatch). */}
383 + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">
384 + <SourceBadge p={toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }} />
385 + {ranked && scopeKey ? (
386 + <span>
387 + Rank from snapshot <span className="ci-mono">{scopeKey}</span> ·{' '}
388 + <Link className="ci-link" href={`/rankings/${t.metric}?scope=${encodeURIComponent(scopeKey)}`}>
389 + full ranking and "Why this rank?"
390 + </Link>
391 + </span>
392 + ) : (
393 + <span>"#" is the position in this table; no ranking snapshot covers this scope yet.</span>
394 + )}
395 + {t.rows.every((r) => r.estimate_type === 'observed') ? <ConfidenceBadge level="HIGH" /> : <ConfidenceBadge level="MEDIUM" />}
396 + <Link className="ci-link" href={`/country/${geoSlug}?sex=${sex}&year=${t.year}#trend`}>
397 + trend ↓
398 + </Link>
399 + </div>
400 + <Freshness dataUpdatedAt={t.rows.map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null} sourceVersion={prov.get(first.provenance_id)?.dataset_version ?? null} />
401 + </>
402 + )}
403 + </Section>
404 + );
405 +}
added apps/web/src/app/drug/[slug]/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading drug" />;
5 +}
modified apps/web/src/app/drug/[slug]/page.tsx +52 −14
@@ -6,35 +6,43 @@ import { PageHeader, Section, KV, Note } from '@/components/ui/section';
6 6 import { Badge } from '@/components/ui/badge';
7 7 import { EmptyState } from '@/components/ui/empty-state';
8 8 import { Freshness } from '@/components/ui/freshness';
9 +import { Pager } from '@/components/ui/pager';
9 10 import { ApprovalsTable } from '@/components/data/approvals-table';
10 11 import { EvidenceTable } from '@/components/data/evidence-table';
11 12 import { TrialTable } from '@/components/data/trial-list';
12 13 import { getDrugBySlug, approvalsForDrug } from '@/lib/queries/drugs';
13 −import { evidenceForDrug } from '@/lib/queries/evidence';
14 −import { trialsForDrug } from '@/lib/queries/trials';
14 +import { evidenceForDrug, evidenceForDrugCount, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';
15 +import { trialsForDrug, trialsForDrugCount, TRIAL_PAGE_SIZE } from '@/lib/queries/trials';
15 16 import { loadProvenance } from '@/lib/queries/provenance';
16 17 import { jsonLd, drugLd } from '@/lib/seo';
17 18 import { fmtInt, humanize } from '@/lib/format';
18 −import { str, type SP } from '@/lib/search-params';
19 +import { pageInfo } from '@/lib/pagination';
20 +import { str, int, withParams, type SP } from '@/lib/search-params';
19 21
20 22 export const revalidate = 3600;
21 23
22 24 export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
23 25 const d = await getDrugBySlug((await params).slug);
24 − return d ? { title: `${d.name} — drug`, description: d.description ?? `${d.name}: regulatory approvals by jurisdiction, curated evidence and clinical trials.` } : { title: 'Drug' };
26 + return d ? { title: `${d.name} — drug`, description: d.description ?? `${d.name}: regulatory approvals by jurisdiction, curated evidence and clinical trials.`, alternates: { canonical: `/drug/${d.slug}` } } : { title: 'Drug' };
25 27 }
26 28
27 29 export default async function DrugPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {
28 30 const { slug } = await params;
29 31 const d = await getDrugBySlug(slug);
30 32 if (!d) notFound();
31 − const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evidenceForDrug(d.id), trialsForDrug(d.id)]);
33 + const sp = await searchParams;
34 + const [evTotal, tTotal] = await Promise.all([evidenceForDrugCount(d.id), trialsForDrugCount(d.id)]);
35 + const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal);
36 + const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, tTotal);
37 + const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([])]);
32 38 const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]);
33 39 const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort();
34 − const wanted = approvals.length ? str(await searchParams, 'jurisdiction') : '';
40 + const wanted = approvals.length ? str(sp, 'jurisdiction') : '';
35 41 const selected = jurisdictions.includes(wanted) ? wanted : null;
36 42 const shown = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals;
37 43 const aliases = d.aliases ?? [];
44 + const current = { jurisdiction: selected ?? '', evPage: ev.page > 1 ? ev.page : '', tPage: tp.page > 1 ? tp.page : '' };
45 + const href = (o: Record<string, string | number | null | undefined>, hash?: string) => `/drug/${d.slug}${withParams(current, o)}${hash ? `#${hash}` : ''}`;
38 46
39 47 return (
40 48 <article>
@@ -72,31 +80,61 @@ export default async function DrugPage({ params, searchParams }: { params: Promi
72 80 {approvals.length ? (
73 81 <>
74 82 <nav aria-label="Jurisdiction" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]">
75 − <Link href={`/drug/${d.slug}`} className={`border px-2 py-0.5 no-underline ${!selected ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>
83 + <Link href={href({ jurisdiction: '' }, 'approvals')} aria-current={!selected ? 'page' : undefined} className="ci-chip">
76 84 All
77 85 </Link>
78 86 {jurisdictions.map((j) => (
79 − <Link key={j} href={`/drug/${d.slug}?jurisdiction=${j}`} className={`ci-mono border px-2 py-0.5 no-underline ${selected === j ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>
87 + <Link key={j} href={href({ jurisdiction: j }, 'approvals')} aria-current={selected === j ? 'page' : undefined} className="ci-chip ci-mono">
80 88 {j}
81 89 </Link>
82 90 ))}
83 91 </nav>
84 92 <ApprovalsTable rows={shown} prov={prov} showDrug={false} />
85 − <Freshness dataUpdatedAt={approvals.reduce((m, a) => (a.updated_at > m ? a.updated_at : m), approvals[0]!.updated_at)} />
93 + <Freshness dataUpdatedAt={approvals.reduce<Date | string | null>((m, a) => (m == null || String(a.updated_at) > String(m) ? a.updated_at : m), null)} />
86 94 </>
87 95 ) : (
88 − <EmptyState compact knows={[{ label: 'Curated evidence below' }, { label: 'Sources', href: '/sources' }]}>
96 + <EmptyState compact knows={[{ label: 'Curated evidence below', href: '#evidence' }, { label: 'Sources', href: '/sources' }]}>
89 97 No regulatory approval recorded. Absence here is not evidence of absence: only ingested jurisdictions are covered.
90 98 </EmptyState>
91 99 )}
92 100 </Section>
93 101
94 − <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evidence.length)})`} description="CIViC items in which this therapy appears, grouped by molecular profile. Cancer context per row.">
95 − {evidence.length ? <EvidenceTable items={evidence} prov={prov} showCancer /> : <EmptyState compact>No curated evidence item mentions this therapy yet.</EmptyState>}
102 + <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items in which this therapy appears, grouped by cancer context, then molecular profile. ${EVIDENCE_PAGE_SIZE} items per page.`}>
103 + {evidence.length ? (
104 + <>
105 + <EvidenceTable
106 + items={evidence}
107 + prov={prov}
108 + showCancer
109 + summary={
110 + <>
111 + Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items
112 + </>
113 + }
114 + />
115 + <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(p) => href({ evPage: p > 1 ? p : '' }, 'evidence')} label="Evidence pages" noun="evidence items" />
116 + </>
117 + ) : (
118 + <EmptyState compact>No curated evidence item mentions this therapy yet.</EmptyState>
119 + )}
96 120 </Section>
97 121
98 − <Section id="trials" kicker="Clinical trials" title={`Trials with this intervention (${fmtInt(d.trial_count ?? trials.length)})`}>
99 − {trials.length ? <TrialTable rows={trials} /> : <EmptyState compact>No registered study lists this drug as an intervention yet.</EmptyState>}
122 + <Section id="trials" kicker="Clinical trials" title={`Trials with this intervention (${fmtInt(tTotal)})`} description={tTotal ? `Most recently updated first, ${TRIAL_PAGE_SIZE} per page.` : undefined}>
123 + {trials.length ? (
124 + <>
125 + <TrialTable
126 + rows={trials}
127 + summary={
128 + <>
129 + Showing {fmtInt(tp.from)}–{fmtInt(tp.to)} of {fmtInt(tTotal)} studies
130 + </>
131 + }
132 + />
133 + <Pager total={tTotal} pageSize={tp.pageSize} page={tp.page} hrefFor={(p) => href({ tPage: p > 1 ? p : '' }, 'trials')} label="Trial pages" noun="studies" />
134 + </>
135 + ) : (
136 + <EmptyState compact>No registered study lists this drug as an intervention yet.</EmptyState>
137 + )}
100 138 </Section>
101 139 </div>
102 140
added apps/web/src/app/drugs/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading drugs" />;
5 +}
modified apps/web/src/app/drugs/page.tsx +2 −1
@@ -9,7 +9,8 @@ import { fmtInt, humanize } from '@/lib/format';
9 9 import { str, int, withParams, type SP } from '@/lib/search-params';
10 10
11 11 export const metadata: Metadata = { title: 'Drugs', description: 'Oncology drugs with jurisdiction-aware approvals, curated evidence and trials.' };
12 −export const dynamic = 'force-dynamic';
12 +// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.
13 +export const revalidate = 600;
13 14 const PAGE_SIZE = 50;
14 15
15 16 export default async function DrugsPage({ searchParams }: { searchParams: Promise<SP> }) {
added apps/web/src/app/gene/[symbol]/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading gene" />;
5 +}
modified apps/web/src/app/gene/[symbol]/page.tsx +87 −37
@@ -6,31 +6,52 @@ import { PageHeader, Section, KV, Note } from '@/components/ui/section';
6 6 import { Badge } from '@/components/ui/badge';
7 7 import { EmptyState } from '@/components/ui/empty-state';
8 8 import { Freshness } from '@/components/ui/freshness';
9 +import { Pager } from '@/components/ui/pager';
9 10 import { EvidenceTable } from '@/components/data/evidence-table';
10 11 import { GeneFrequencyTable } from '@/components/data/frequency-table';
11 −import { getGeneBySymbol, variantsForGene, frequenciesForGene } from '@/lib/queries/genomics';
12 −import { evidenceForGene } from '@/lib/queries/evidence';
12 +import { getGeneBySymbol, variantsForGene, variantsForGeneCount, frequenciesForGene, VARIANT_PAGE_SIZE } from '@/lib/queries/genomics';
13 +import { evidenceForGene, evidenceForGeneCount, evidenceCancersForGene, EVIDENCE_PAGE_SIZE } from '@/lib/queries/evidence';
13 14 import { loadProvenance } from '@/lib/queries/provenance';
14 −import { recentPublicationsFor } from '@/lib/queries/publications';
15 +import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';
15 16 import { PublicationList } from '@/components/data/publication-list';
16 17 import { fmtInt, humanize } from '@/lib/format';
18 +import { pageInfo } from '@/lib/pagination';
19 +import { int, withParams, type SP } from '@/lib/search-params';
17 20
18 21 export const revalidate = 3600;
22 +/** Cancer chips above the evidence table: the most-cited contexts only (every row still names its cancer). */
23 +const CANCER_CHIPS = 20;
19 24
20 25 export async function generateMetadata({ params }: { params: Promise<{ symbol: string }> }): Promise<Metadata> {
21 26 const g = await getGeneBySymbol((await params).symbol);
22 − return g ? { title: `${g.symbol} — gene`, description: `${g.symbol}${g.name ? ` (${g.name})` : ''}: curated cancer evidence, variants and cohort alteration frequencies.` } : { title: 'Gene' };
27 + return g ? { title: `${g.symbol} — gene`, description: `${g.symbol}${g.name ? ` (${g.name})` : ''}: curated cancer evidence, variants and cohort alteration frequencies.`, alternates: { canonical: `/gene/${g.symbol}` } } : { title: 'Gene' };
23 28 }
24 29
25 −export default async function GenePage({ params }: { params: Promise<{ symbol: string }> }) {
30 +export default async function GenePage({ params, searchParams }: { params: Promise<{ symbol: string }>; searchParams: Promise<SP> }) {
26 31 const { symbol } = await params;
27 32 const g = await getGeneBySymbol(symbol);
28 33 if (!g) notFound();
29 34 if (g.symbol !== symbol) permanentRedirect(`/gene/${g.symbol}`);
30 − const [variants, evidence, freqs, pubs] = await Promise.all([variantsForGene(g.id), evidenceForGene(g.id, g.symbol), frequenciesForGene(g.id, g.symbol), recentPublicationsFor('gene', [g.id], 20)]);
35 + const sp = await searchParams;
36 + const evPageReq = int(sp, 'evPage', 1, 1, 100_000);
37 + const vPageReq = int(sp, 'vPage', 1, 1, 100_000);
38 + const pPageReq = int(sp, 'pPage', 1, 1, 100_000);
39 +
40 + const [evTotal, vTotal, pTotal] = await Promise.all([evidenceForGeneCount(g.id, g.symbol), variantsForGeneCount(g.id), recentPublicationsForCount('gene', [g.id])]);
41 + const ev = pageInfo(evPageReq, EVIDENCE_PAGE_SIZE, evTotal);
42 + const vp = pageInfo(vPageReq, VARIANT_PAGE_SIZE, vTotal);
43 + const pp = pageInfo(pPageReq, PUBLICATION_PAGE_SIZE, pTotal);
44 + const [variants, evidence, freqs, pubs, cancersInEvidence] = await Promise.all([
45 + vTotal ? variantsForGene(g.id, { page: vp.page, pageSize: vp.pageSize }) : Promise.resolve([]),
46 + evTotal ? evidenceForGene(g.id, g.symbol, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]),
47 + frequenciesForGene(g.id, g.symbol),
48 + pTotal ? recentPublicationsFor('gene', [g.id], { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([]),
49 + evTotal ? evidenceCancersForGene(g.id, g.symbol, CANCER_CHIPS) : Promise.resolve([]),
50 + ]);
31 51 const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...freqs.map((f) => f.provenance_id)]);
32 − const cancersInEvidence = new Map<string, { slug: string; name: string; n: number }>();
33 − for (const e of evidence) if (e.cancer_slug) cancersInEvidence.set(e.cancer_slug, { slug: e.cancer_slug, name: e.cancer_name ?? e.cancer_slug, n: (cancersInEvidence.get(e.cancer_slug)?.n ?? 0) + 1 });
52 +
53 + const current = { evPage: ev.page > 1 ? ev.page : '', vPage: vp.page > 1 ? vp.page : '', pPage: pp.page > 1 ? pp.page : '' };
54 + const href = (o: Record<string, string | number | null | undefined>, hash: string) => `/gene/${g.symbol}${withParams(current, o)}#${hash}`;
34 55
35 56 return (
36 57 <article>
@@ -70,22 +91,34 @@ export default async function GenePage({ params }: { params: Promise<{ symbol: s
70 91 </Section>
71 92 ) : null}
72 93
73 − <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evidence.length)})`} description="CIViC items involving this gene, grouped by molecular profile and therapy, with native levels and directions.">
74 − {evidence.length ? (
94 + <Section id="evidence" kicker="Curated evidence" title={`Clinical evidence (${fmtInt(evTotal)})`} description={`CIViC items involving this gene, grouped by molecular profile and therapy, with native levels and directions. ${EVIDENCE_PAGE_SIZE} items per page.`}>
95 + {evTotal ? (
75 96 <>
76 − {cancersInEvidence.size ? (
77 − <p className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]">
78 − <span className="ci-kicker mr-1">Cancers</span>
79 − {[...cancersInEvidence.values()]
80 − .sort((a, b) => b.n - a.n)
81 − .map((c) => (
82 − <Link key={c.slug} href={`/cancer/${c.slug}/evidence`} className="border border-rule px-2 py-0.5 no-underline hover:border-accent">
83 − {c.name} <span className="ci-num text-ink-3">{c.n}</span>
84 − </Link>
97 + {cancersInEvidence.length ? (
98 + <nav aria-label="Cancers in evidence" className="mb-3">
99 + <ul className="m-0 flex list-none flex-wrap gap-1.5 p-0 text-[12.5px]">
100 + <li className="ci-kicker mr-1 self-center">{cancersInEvidence.length >= CANCER_CHIPS ? `Top ${CANCER_CHIPS} cancers` : 'Cancers'}</li>
101 + {cancersInEvidence.map((c) => (
102 + <li key={c.slug}>
103 + <Link href={`/cancer/${c.slug}/evidence`} className="ci-chip">
104 + {c.name} <span className="ci-num text-ink-3">{c.n}</span>
105 + </Link>
106 + </li>
85 107 ))}
86 − </p>
108 + </ul>
109 + </nav>
87 110 ) : null}
88 − <EvidenceTable items={evidence} prov={prov} showCancer />
111 + <EvidenceTable
112 + items={evidence}
113 + prov={prov}
114 + showCancer
115 + summary={
116 + <>
117 + Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items
118 + </>
119 + }
120 + />
121 + <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(p) => href({ evPage: p > 1 ? p : '' }, 'evidence')} label="Evidence pages" noun="evidence items" />
89 122 </>
90 123 ) : (
91 124 <EmptyState compact>No curated evidence item involves this gene yet.</EmptyState>
@@ -96,8 +129,22 @@ export default async function GenePage({ params }: { params: Promise<{ symbol: s
96 129 {freqs.length ? <GeneFrequencyTable rows={freqs} prov={prov} /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>}
97 130 </Section>
98 131
99 − <Section id="publications" kicker="Literature" title="Linked publications">
100 − {pubs.length ? <PublicationList rows={pubs} /> : <EmptyState compact>No publication linked to this gene yet.</EmptyState>}
132 + <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}>
133 + {pubs.length ? (
134 + <>
135 + <PublicationList
136 + rows={pubs}
137 + summary={
138 + <>
139 + Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pTotal)} publications
140 + </>
141 + }
142 + />
143 + <Pager total={pTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => href({ pPage: p > 1 ? p : '' }, 'publications')} label="Publication pages" noun="publications" />
144 + </>
145 + ) : (
146 + <EmptyState compact>No publication linked to this gene yet.</EmptyState>
147 + )}
101 148 </Section>
102 149 </div>
103 150
@@ -119,21 +166,24 @@ export default async function GenePage({ params }: { params: Promise<{ symbol: s
119 166 />
120 167 <Freshness dataUpdatedAt={g.updated_at} extra="source: hgnc" />
121 168 </Section>
122 − <Section id="variants" kicker="Variants" title={variants.length >= 100 ? `Variants (top ${fmtInt(variants.length)} by evidence — full list via the API)` : `Variants (${fmtInt(variants.length)})`} level={3}>
169 + <Section id="variants" kicker="Variants" title={`Variants (${fmtInt(vTotal)})`} description={vTotal > VARIANT_PAGE_SIZE ? `Sorted by evidence count, ${VARIANT_PAGE_SIZE} per page.` : undefined} level={3}>
123 170 {variants.length ? (
124 − <ul className="max-h-[480px] overflow-y-auto text-[13.5px]">
125 − {variants.map((v) => (
126 − <li key={v.id} className="flex items-baseline justify-between gap-2 border-b border-rule py-1">
127 − <Link className="ci-link" href={`/variant/${v.slug}`}>
128 − {v.name}
129 − </Link>
130 − <span className="text-[11.5px] text-ink-3">
131 − {v.variant_type ? humanize(v.variant_type) : ''}
132 − {v.evidence_count ? ` · ${v.evidence_count} ev.` : ''}
133 − </span>
134 − </li>
135 − ))}
136 − </ul>
171 + <>
172 + <ul className="ci-rows">
173 + {variants.map((v) => (
174 + <li key={v.id}>
175 + <Link className="ci-link" href={`/variant/${v.slug}`}>
176 + {v.name}
177 + </Link>
178 + <span>
179 + {v.variant_type ? humanize(v.variant_type) : ''}
180 + {v.evidence_count ? ` · ${v.evidence_count} ev.` : ''}
181 + </span>
182 + </li>
183 + ))}
184 + </ul>
185 + <Pager total={vTotal} pageSize={vp.pageSize} page={vp.page} hrefFor={(p) => href({ vPage: p > 1 ? p : '' }, 'variants')} label="Variant pages" noun="variants" className="text-[12px]" />
186 + </>
137 187 ) : (
138 188 <p className="text-[13px] text-ink-3">No variant entity recorded for this gene.</p>
139 189 )}
added apps/web/src/app/genes/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/genes/page.tsx +2 −1
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/globals.css +278 −0
Large diff (281 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/methodology/page.tsx +60 −4
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/page.tsx +14 −2
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/publication/[pmid]/page.tsx +30 −8
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/sitemap.xml/route.ts +4 −2
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap/countries/route.ts +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/trial/[nct]/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/trial/[nct]/page.tsx +43 −19
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/trials/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/trials/page.tsx +23 −13
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/variant/[slug]/loading.tsx +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/variant/[slug]/page.tsx +74 −69
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/cancer/key-figures.tsx +201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/drugs.tsx +16 −22
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/evidence.tsx +23 −8
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/genomics.tsx +51 −17
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/overview.tsx +11 −2
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/rankings.tsx +20 −20
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/research.tsx +38 −17
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/statistics.tsx +33 −19
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/survival.tsx +26 −15
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/cancer/tabs/trials.tsx +27 −18
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/compare-bars.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/trend-chart.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/data/approvals-table.tsx +74 −62
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/data/evidence-table.tsx +166 −123
Large diff (320 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/data/frequency-table.tsx +95 −65
Large diff (208 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/data/publication-list.tsx +51 −37
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/data/trial-list.tsx +71 −45
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/burden-module.tsx +117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/gaps-module.tsx +95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/rising-module.tsx +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/badge.tsx +16 −7
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/pager.tsx +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/pagination.tsx +6 −32
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/skeleton.tsx +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/source-badge.tsx +38 −9
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/ui/tabs.tsx +17 −7
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/db-errors.ts +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/db.ts +13 −2
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/pagination.ts +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/compare.ts +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/epidemiology.ts +75 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/evidence.ts +127 −31
Large diff (203 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/genomics.ts +60 −5
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/geography.ts +310 −0
Large diff (310 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/publications.ts +38 −9
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/rankings.ts +68 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/search.ts +4 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/queries/trials.ts +23 −2
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/rankings-util.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/search-util.ts +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/site.ts +2 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/db-errors.test.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/format.test.ts +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/pagination.test.ts +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/rankings-util.test.ts +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/search-util.test.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/vitest.config.ts +1 −1
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified deploy/README.md +23 −1
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added deploy/backup.sh +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
modified deploy/mld-manifest.cancerindex.json +19 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added deploy/restore.sh +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified docs/ARCHITECTURE.md +44 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified docs/SECURITY.md +19 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/README.md +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/cbioportal.md +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/chembl.md +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/mesh.md +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added docs/connectors/openfda.md +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added docs/schema-changes-ops.md +95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/cbioportal.test.ts +109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/cancer-types.json +93 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/molecular-profiles-acc_tcga.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/molecular-profiles-no-mutations.json +12 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/mutated-genes-acc_tcga.json +229 −0
Large diff (230 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/mutated-genes-msk_impact_2017.json +206 −0
Large diff (207 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/sample-list-acc_tcga_sequenced.json +13 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/fixtures/studies-detailed.json +255 −0
Large diff (256 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/index.ts +320 −0
Large diff (320 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/manifest.ts +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/cbioportal/normalize.ts +141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/cdc-uscs/index.ts +7 −1
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/cdc-uscs/sites.ts +14 −8
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/api.ts +350 −0
Large diff (350 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/chembl.test.ts +213 −0
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/indication-imatinib.json +895 −0
Large diff (896 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/indication-osimertinib.json +215 −0
Large diff (216 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/mechanism-imatinib.json +126 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/mechanism-osimertinib.json +46 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/mechanism-trastuzumab.json +36 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/search-fluorouracil.json +230 −0
Large diff (231 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/search-imatinib.json +366 −0
Large diff (367 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/search-none.json +10 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/search-osimertinib.json +226 −0
Large diff (227 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/search-trastuzumab.json +707 −0
Large diff (708 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL1824.json +49 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL1862.json +41 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL1913.json +41 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL1936.json +37 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL203.json +45 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL2096618.json +69 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/fixtures/target-CHEMBL2111414.json +73 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/index.ts +385 −0
Large diff (385 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/chembl/manifest.ts +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/clinicaltrials/index.ts +3 −2
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/clinvar/index.ts +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/fixtures/desc-records.xml +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/fixtures/descriptors-page.json +365 −0
Large diff (366 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/index.ts +199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/manifest.ts +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/mesh.test.ts +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/sparql.ts +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/mesh/xml.ts +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/ncit-evs/index.ts +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/connectors/oncotree/index.ts +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/drugsfda-imatinib.json +758 −0
Large diff (759 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/drugsfda-osimertinib.json +225 −0
Large diff (226 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/drugsfda-trastuzumab.json +308 −0
Large diff (309 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/label-gleevec.json +50 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/label-osimertinib.json +53 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/fixtures/not-found.json +6 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/index.ts +499 −0
Large diff (499 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/manifest.ts +116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/normalize.ts +442 −0
Large diff (442 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/connectors/openfda/openfda.test.ts +229 −0
Large diff (229 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/index.ts +1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/ops/doctor.ts +334 −0
Large diff (334 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/ops/index.ts +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/ops/schedule.test.ts +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/ops/schedule.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/registry.ts +5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/sdk/lake.test.ts +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/sdk/lake.ts +86 −18
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/sdk/manifest.ts +6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added packages/connectors/src/sdk/run.test.ts +278 −0
Large diff (278 lines) is not loaded by default to keep the page fast.Load diff
modified packages/connectors/src/sdk/run.ts +0 −0

Binary file not shown.

added packages/database/migrations/0001_jazzy_arclight.sql +16 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/migrations/meta/0001_snapshot.json +2149 −0
Large diff (2,149 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme