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
159 changed files +18,136 −784
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.