Phase 1: NCIt/OncoTree/HGNC/CIViC/ClinVar/GDC/ClinicalTrials/PubMed/CDC WONDER connectors, ranking engine, Fastify API, pg-boss worker, Next.js web app, docs, deploy
153 changed files +19,998 −0
modified
.gitignore
+4 −0
@@ -13,3 +13,7 @@ tmp/ | ||
| 13 | 13 | coverage/ |
| 14 | 14 | .DS_Store |
| 15 | 15 | .claude/ |
| 16 | +**/data/raw/ | |
| 17 | +**/data/cache/ | |
| 18 | +**/data/tmp/ | |
| 19 | +.admin-token.local | |
added
README.md
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +# CancerIndex.io — the global index of cancer | |
| 2 | + | |
| 3 | +CancerIndex is a provenance-first, continuously updated, transparently sourced oncology knowledge | |
| 4 | +platform. It connects a canonical cancer ontology (NCIt-anchored, OncoTree and registry dimensions) | |
| 5 | +to epidemiology, genomics, biomarkers, therapies, clinical trials, regulatory evidence and literature | |
| 6 | +— for every recognised malignant disease entity — and publishes reproducible rankings with their | |
| 7 | +scope, formula version and lineage down to the raw record. | |
| 8 | + | |
| 9 | +It is not a physician, not a diagnostic tool, and gives no treatment recommendations. | |
| 10 | + | |
| 11 | +## Status | |
| 12 | + | |
| 13 | +Phase 1 (2026-09). Foundation, connectors, public API, worker and web app are being built in | |
| 14 | +parallel. Data currently ingested locally: OncoTree (865 cancer entities), with NCIt EVS, HGNC, | |
| 15 | +CIViC, GDC, ClinVar, ClinicalTrials.gov, PubMed and CDC WONDER connectors in progress. GLOBOCAN | |
| 16 | +remains under license review and SEER awaits credentials — the platform says so rather than showing | |
| 17 | +numbers it cannot source. | |
| 18 | + | |
| 19 | +## Repository | |
| 20 | + | |
| 21 | +``` | |
| 22 | +apps/web Next.js 16 public site (port 8250), proxies /api/v1/* to the API | |
| 23 | +apps/api Fastify 5 public API /v1 (port 8251), OpenAPI at /v1/docs | |
| 24 | +workers/ pg-boss scheduler: connector cron, counters, rankings, health probes | |
| 25 | +packages/ shared · database (Drizzle) · ontology · connectors (SDK + connectors) · ranking | |
| 26 | +scripts/ci.ts operator CLI (pnpm cix …) | |
| 27 | +deploy/ MacLustr mld manifest, first-run bootstrap | |
| 28 | +docs/ architecture, data model, methodology, API, security, AI policy, ADRs, connector docs | |
| 29 | +``` | |
| 30 | + | |
| 31 | +Start with `CLAUDE.md` (operational rules), then `docs/ARCHITECTURE.md`, `docs/DATA-MODEL.md` and | |
| 32 | +`docs/METHODOLOGY.md`. | |
| 33 | + | |
| 34 | +## Quickstart | |
| 35 | + | |
| 36 | +Requirements: Node ≥ 22, pnpm 11, PostgreSQL 17 with `pg_trgm`, `unaccent` and `vector`. | |
| 37 | + | |
| 38 | +```bash | |
| 39 | +createdb cancerindex && cp .env.example .env # set NCBI_EMAIL, ADMIN_TOKEN | |
| 40 | +pnpm install | |
| 41 | +pnpm db:migrate && pnpm db:seed && pnpm cix sources:sync | |
| 42 | +pnpm cix run oncotree --mode dry_run # smoke test against the live API (no writes) | |
| 43 | +pnpm cix run oncotree # first real ingest | |
| 44 | +pnpm cix counters && pnpm cix rank | |
| 45 | +pnpm dev:api & # http://127.0.0.1:8251/v1/docs | |
| 46 | +pnpm worker & # schedules from connector manifests | |
| 47 | +pnpm dev:web # http://localhost:8250 | |
| 48 | +``` | |
| 49 | + | |
| 50 | +Verify: `curl -s localhost:8251/healthz`, `curl -s "localhost:8251/v1/search?q=glio"`. | |
| 51 | + | |
| 52 | +## CLI | |
| 53 | + | |
| 54 | +``` | |
| 55 | +pnpm cix connectors list connectors, license status, health | |
| 56 | +pnpm cix run <id> [--mode full|incremental|dry_run] [--max-records N] [--max-minutes M] [--reset-cursor] | |
| 57 | +pnpm cix run-all [--max-minutes M] every active connector in registry order | |
| 58 | +pnpm cix health <id> source liveness probe | |
| 59 | +pnpm cix sources:sync manifests → sources table | |
| 60 | +pnpm cix counters rebuild entity_counters | |
| 61 | +pnpm cix rank recompute ranking snapshots | |
| 62 | +pnpm cix stats table counts | |
| 63 | +pnpm cix trace <table> <id> lineage of a value | |
| 64 | + | |
| 65 | +tsx workers/cli.ts run <id> | counters [--then-rank] | rank | health | schedules | queues | |
| 66 | +pnpm --filter @cancerindex/api create-key -- --label "Lab" --tier research --rpm 600 | |
| 67 | +``` | |
| 68 | + | |
| 69 | +## Quality gates | |
| 70 | + | |
| 71 | +```bash | |
| 72 | +pnpm typecheck # every workspace | |
| 73 | +pnpm -r test # vitest; connector tests use fixtures, API smoke tests skip without a DB | |
| 74 | +``` | |
| 75 | + | |
| 76 | +## Deployment | |
| 77 | + | |
| 78 | +Production runs on the MacLustr cluster through `mld` (gateway M1M32): manifest | |
| 79 | +`deploy/mld-manifest.cancerindex.json`, `mld stage <dir> cancerindex`, `mld deploy cancerindex`, | |
| 80 | +then `deploy/first-run.sh` for the ordered first ingestion. PM2 processes `cancerindex-web`, | |
| 81 | +`cancerindex-api`, `cancerindex-worker`; ngrok `www.cancerindex.io` → 8250. See `deploy/README.md`. | |
| 82 | + | |
| 83 | +## Principles (short form) | |
| 84 | + | |
| 85 | +1. No scientific number without provenance; derived values carry a formula version and inputs. | |
| 86 | +2. Layers stay separable: RAW → NORMALIZED → CANONICAL → DERIVED → RANKED → AI. | |
| 87 | +3. Never fake data: missing is missing ("Data not yet available"), never zero. | |
| 88 | +4. Identifiers are first-class: every upstream id is kept; public ids are `CI-<NS>-00000001`. | |
| 89 | +5. Reconciliation before ingestion; unknown labels go to a curation queue, never dropped. | |
| 90 | +6. Time-aware observations: a new year is a new row. | |
| 91 | +7. Every edge has context: cancer, direction, evidence level, provenance. | |
| 92 | +8. Licensing gate on every source; scientific safety labels are never merged. | |
| 93 | + | |
| 94 | +## License and attribution | |
| 95 | + | |
| 96 | +Code: private (Groupe/SPB). Data: each source keeps its own license and attribution, surfaced in | |
| 97 | +every API response (`sources`) and on `/sources`. See `docs/source-policy.md`. | |
added
apps/api/package.json
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@cancerindex/api", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "description": "CancerIndex public API — Fastify 5, /v1, JSON envelope { data, sources, dataRelease }", | |
| 7 | + "scripts": { | |
| 8 | + "dev": "tsx watch src/server.ts", | |
| 9 | + "start": "tsx src/server.ts", | |
| 10 | + "build": "tsc -p tsconfig.json --noEmit", | |
| 11 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 12 | + "test": "vitest run --passWithNoTests", | |
| 13 | + "create-key": "tsx scripts/create-key.ts" | |
| 14 | + }, | |
| 15 | + "dependencies": { | |
| 16 | + "@cancerindex/database": "workspace:*", | |
| 17 | + "@cancerindex/ontology": "workspace:*", | |
| 18 | + "@cancerindex/ranking": "workspace:*", | |
| 19 | + "@cancerindex/shared": "workspace:*", | |
| 20 | + "@fastify/compress": "^8.0.1", | |
| 21 | + "@fastify/cors": "^11.0.0", | |
| 22 | + "@fastify/etag": "^6.0.3", | |
| 23 | + "@fastify/rate-limit": "^10.3.0", | |
| 24 | + "@fastify/swagger": "^9.5.1", | |
| 25 | + "@fastify/swagger-ui": "^5.2.3", | |
| 26 | + "drizzle-orm": "^0.45.0", | |
| 27 | + "fastify": "^5.5.0", | |
| 28 | + "fastify-type-provider-zod": "^7.0.0", | |
| 29 | + "pg-boss": "^12.0.0", | |
| 30 | + "postgres": "^3.4.7", | |
| 31 | + "zod": "^4.1.5" | |
| 32 | + }, | |
| 33 | + "devDependencies": { | |
| 34 | + "@types/node": "^24.0.0", | |
| 35 | + "tsx": "^4.20.0", | |
| 36 | + "typescript": "^5.9.3", | |
| 37 | + "vitest": "^3.2.0" | |
| 38 | + } | |
| 39 | +} | |
added
apps/api/scripts/create-key.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +#!/usr/bin/env tsx | |
| 2 | +/** | |
| 3 | + * Mint a public API key (CLAUDE.md §190-191). | |
| 4 | + * | |
| 5 | + * pnpm --filter @cancerindex/api create-key -- --label "Lab X" --email someone@example.org --tier research --rpm 600 | |
| 6 | + * | |
| 7 | + * The plaintext key is printed ONCE; only its sha256 hash and a display prefix are stored. | |
| 8 | + */ | |
| 9 | +import { randomBytes } from 'node:crypto'; | |
| 10 | +import { sql } from 'drizzle-orm'; | |
| 11 | +import { loadEnv } from '../src/lib/env.js'; | |
| 12 | +import { hashApiKey } from '../src/plugins/auth.js'; | |
| 13 | + | |
| 14 | +loadEnv(); | |
| 15 | + | |
| 16 | +const args = process.argv.slice(2); | |
| 17 | +const flag = (name: string, fallback?: string) => { | |
| 18 | + const i = args.indexOf(`--${name}`); | |
| 19 | + return i >= 0 && args[i + 1] && !args[i + 1]!.startsWith('--') ? args[i + 1] : fallback; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +const label = flag('label', 'unnamed'); | |
| 23 | +const email = flag('email'); | |
| 24 | +const tier = flag('tier', 'free')!; | |
| 25 | +const rpm = Number(flag('rpm', tier === 'institutional' ? '3000' : tier === 'pro' ? '1200' : tier === 'research' ? '600' : '120')); | |
| 26 | + | |
| 27 | +const { getDb, closeDb } = await import('@cancerindex/database'); | |
| 28 | +const db = getDb(); | |
| 29 | +try { | |
| 30 | + const key = `cix_${randomBytes(24).toString('base64url')}`; | |
| 31 | + const prefix = key.slice(0, 12); | |
| 32 | + const rows = await db.execute<{ id: number }>(sql` | |
| 33 | + INSERT INTO api_keys (key_hash, prefix, label, owner_email, tier, rate_limit_per_minute, active) | |
| 34 | + VALUES (${hashApiKey(key)}, ${prefix}, ${label}, ${email ?? null}, ${tier}, ${rpm}, true) RETURNING id`); | |
| 35 | + console.log(`API key created (id=${rows[0]!.id}, prefix=${prefix}, tier=${tier}, ${rpm} req/min)`); | |
| 36 | + console.log('Store it now — it will not be shown again:'); | |
| 37 | + console.log(key); | |
| 38 | + console.log(`Usage: curl -H "Authorization: Bearer ${key}" http://127.0.0.1:${process.env.API_PORT ?? 8251}/v1/stats`); | |
| 39 | +} finally { | |
| 40 | + await closeDb(); | |
| 41 | +} | |
added
apps/api/src/app.ts
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +import { randomUUID } from 'node:crypto'; | |
| 2 | +import Fastify, { type FastifyInstance } from 'fastify'; | |
| 3 | +import cors from '@fastify/cors'; | |
| 4 | +import compress from '@fastify/compress'; | |
| 5 | +import etag from '@fastify/etag'; | |
| 6 | +import rateLimit from '@fastify/rate-limit'; | |
| 7 | +import swagger from '@fastify/swagger'; | |
| 8 | +import swaggerUi from '@fastify/swagger-ui'; | |
| 9 | +import { sql } from 'drizzle-orm'; | |
| 10 | +import { hasZodFastifySchemaValidationErrors, isResponseSerializationError, jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; | |
| 11 | +import { getDb, type Database } from '@cancerindex/database'; | |
| 12 | +import { HttpError } from './lib/errors.js'; | |
| 13 | +import { rateLimitKey, rateLimitMax, registerApiKeyAuth } from './plugins/auth.js'; | |
| 14 | +import { closeBoss } from './lib/queue.js'; | |
| 15 | +import './types.js'; | |
| 16 | +import { healthRoutes } from './routes/health.js'; | |
| 17 | +import { cancerRoutes } from './routes/cancers.js'; | |
| 18 | +import { geneRoutes } from './routes/genes.js'; | |
| 19 | +import { variantRoutes } from './routes/variants.js'; | |
| 20 | +import { drugRoutes } from './routes/drugs.js'; | |
| 21 | +import { trialRoutes } from './routes/trials.js'; | |
| 22 | +import { publicationRoutes } from './routes/publications.js'; | |
| 23 | +import { rankingRoutes } from './routes/rankings.js'; | |
| 24 | +import { searchRoutes } from './routes/search.js'; | |
| 25 | +import { sourceRoutes } from './routes/sources.js'; | |
| 26 | +import { statsRoutes } from './routes/stats.js'; | |
| 27 | +import { changeRoutes } from './routes/changes.js'; | |
| 28 | +import { adminRoutes } from './routes/admin.js'; | |
| 29 | + | |
| 30 | +export interface BuildOptions { | |
| 31 | + db?: Database; | |
| 32 | + logger?: boolean; | |
| 33 | + logLevel?: string; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Build the Fastify app (used by server.ts and by tests via app.inject). */ | |
| 37 | +export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> { | |
| 38 | + const app = Fastify({ | |
| 39 | + logger: opts.logger === false ? false : { level: opts.logLevel ?? process.env.LOG_LEVEL ?? 'info', base: { service: process.env.CI_SERVICE ?? 'api' } }, | |
| 40 | + // Correlation id on every request (CLAUDE.md §175): honour an incoming x-request-id, else mint one. | |
| 41 | + requestIdHeader: 'x-request-id', | |
| 42 | + genReqId: () => randomUUID(), | |
| 43 | + trustProxy: true, | |
| 44 | + ajv: { customOptions: { coerceTypes: true } }, | |
| 45 | + }); | |
| 46 | + | |
| 47 | + app.setValidatorCompiler(validatorCompiler); | |
| 48 | + app.setSerializerCompiler(serializerCompiler); | |
| 49 | + | |
| 50 | + const db = opts.db ?? getDb(); | |
| 51 | + app.decorate('db', db); | |
| 52 | + app.decorate('dbHealthy', async () => { | |
| 53 | + try { | |
| 54 | + await db.execute(sql`SELECT 1`); | |
| 55 | + return true; | |
| 56 | + } catch { | |
| 57 | + return false; | |
| 58 | + } | |
| 59 | + }); | |
| 60 | + | |
| 61 | + app.addHook('onRequest', async (req, reply) => { | |
| 62 | + reply.header('x-request-id', req.id); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + await app.register(cors, { origin: true, methods: ['GET', 'POST', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after'] }); | |
| 66 | + await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'], threshold: 1024 }); | |
| 67 | + await app.register(etag, { weak: true }); | |
| 68 | + | |
| 69 | + registerApiKeyAuth(app); | |
| 70 | + await app.register(rateLimit, { | |
| 71 | + global: true, | |
| 72 | + hook: 'preValidation', // runs after the onRequest auth hook so per-key limits apply | |
| 73 | + timeWindow: '1 minute', | |
| 74 | + max: (req) => rateLimitMax(req), | |
| 75 | + keyGenerator: (req) => rateLimitKey(req), | |
| 76 | + allowList: (req) => req.url === '/healthz' || req.url.startsWith('/v1/docs') || req.url === '/v1/openapi.json', | |
| 77 | + addHeaders: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true, 'retry-after': true }, | |
| 78 | + addHeadersOnExceeding: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true }, | |
| 79 | + errorResponseBuilder: (req, ctx) => ({ error: { code: 'rate_limited', message: `Rate limit exceeded (${ctx.max} requests per ${ctx.after}). Use an API key for higher limits.` }, requestId: req.id }), | |
| 80 | + }); | |
| 81 | + | |
| 82 | + await app.register(swagger, { | |
| 83 | + openapi: { | |
| 84 | + openapi: '3.1.0', | |
| 85 | + info: { | |
| 86 | + title: 'CancerIndex API', | |
| 87 | + version: '1.0.0', | |
| 88 | + description: | |
| 89 | + 'Public read-only API of CancerIndex.io — the global index of cancer. Every response is wrapped in `{ data, sources, dataRelease, generatedAt }`; `sources` lists the upstream sources (with license and attribution) behind the returned data. Numbers are never invented: absent data is absent, not zero. Rankings carry scope, formula version and inputs hash. Not medical advice.', | |
| 90 | + contact: { name: 'CancerIndex', url: 'https://www.cancerindex.io' }, | |
| 91 | + license: { name: 'Data licensed per source; see /v1/sources' }, | |
| 92 | + }, | |
| 93 | + servers: [{ url: '/', description: 'This host' }], | |
| 94 | + tags: [ | |
| 95 | + { name: 'cancers', description: 'Canonical cancer entities, hierarchy, statistics, evidence' }, | |
| 96 | + { name: 'genes' }, | |
| 97 | + { name: 'variants' }, | |
| 98 | + { name: 'drugs' }, | |
| 99 | + { name: 'trials' }, | |
| 100 | + { name: 'publications' }, | |
| 101 | + { name: 'rankings', description: 'Ranking snapshots with scope, formula version and lineage' }, | |
| 102 | + { name: 'search' }, | |
| 103 | + { name: 'sources', description: 'Source registry, licenses, connector health' }, | |
| 104 | + { name: 'stats' }, | |
| 105 | + { name: 'changes' }, | |
| 106 | + { name: 'admin', description: 'Operator endpoints (x-admin-token)' }, | |
| 107 | + { name: 'system' }, | |
| 108 | + ], | |
| 109 | + components: { | |
| 110 | + securitySchemes: { | |
| 111 | + apiKey: { type: 'http', scheme: 'bearer', description: 'Optional. Raises the per-minute rate limit.' }, | |
| 112 | + adminToken: { type: 'apiKey', in: 'header', name: 'x-admin-token' }, | |
| 113 | + }, | |
| 114 | + }, | |
| 115 | + }, | |
| 116 | + transform: jsonSchemaTransform, | |
| 117 | + }); | |
| 118 | + await app.register(swaggerUi, { routePrefix: '/v1/docs', uiConfig: { docExpansion: 'list', deepLinking: true } }); | |
| 119 | + | |
| 120 | + app.setErrorHandler((err, req, reply) => { | |
| 121 | + if (err instanceof HttpError) { | |
| 122 | + return reply.code(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details }, requestId: req.id }); | |
| 123 | + } | |
| 124 | + if (hasZodFastifySchemaValidationErrors(err)) { | |
| 125 | + return reply.code(400).send({ error: { code: 'bad_request', message: 'request does not match the schema', details: err.validation }, requestId: req.id }); | |
| 126 | + } | |
| 127 | + if (isResponseSerializationError(err)) { | |
| 128 | + req.log.error({ err, issues: err.cause.issues }, 'response serialization error'); | |
| 129 | + return reply.code(500).send({ error: { code: 'internal', message: 'response does not match the schema' }, requestId: req.id }); | |
| 130 | + } | |
| 131 | + const e = err as { statusCode?: number; message?: string }; | |
| 132 | + const status = e.statusCode ?? 500; | |
| 133 | + if (status >= 500) req.log.error({ err }, 'unhandled error'); | |
| 134 | + return reply.code(status).send({ error: { code: status === 429 ? 'rate_limited' : status >= 500 ? 'internal' : 'error', message: status >= 500 ? 'internal server error' : (e.message ?? 'error') }, requestId: req.id }); | |
| 135 | + }); | |
| 136 | + app.setNotFoundHandler((req, reply) => { | |
| 137 | + reply.code(404).send({ error: { code: 'not_found', message: `route ${req.method} ${req.url} not found` }, requestId: req.id }); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + await app.register(healthRoutes); | |
| 141 | + await app.register( | |
| 142 | + async (v1) => { | |
| 143 | + v1.get('/openapi.json', { schema: { hide: true } }, async () => app.swagger()); | |
| 144 | + await v1.register(cancerRoutes); | |
| 145 | + await v1.register(geneRoutes); | |
| 146 | + await v1.register(variantRoutes); | |
| 147 | + await v1.register(drugRoutes); | |
| 148 | + await v1.register(trialRoutes); | |
| 149 | + await v1.register(publicationRoutes); | |
| 150 | + await v1.register(rankingRoutes); | |
| 151 | + await v1.register(searchRoutes); | |
| 152 | + await v1.register(sourceRoutes); | |
| 153 | + await v1.register(statsRoutes); | |
| 154 | + await v1.register(changeRoutes); | |
| 155 | + await v1.register(adminRoutes, { prefix: '/admin' }); | |
| 156 | + }, | |
| 157 | + { prefix: '/v1' }, | |
| 158 | + ); | |
| 159 | + | |
| 160 | + app.addHook('onClose', async () => { | |
| 161 | + await closeBoss().catch(() => {}); | |
| 162 | + }); | |
| 163 | + | |
| 164 | + return app; | |
| 165 | +} | |
added
apps/api/src/lib/cache.ts
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +/** Minimal in-process TTL cache (single node, no eviction pressure expected). */ | |
| 2 | +export class TtlCache<V> { | |
| 3 | + private store = new Map<string, { value: V; expires: number }>(); | |
| 4 | + constructor(private readonly ttlMs: number) {} | |
| 5 | + | |
| 6 | + get(key: string): V | undefined { | |
| 7 | + const hit = this.store.get(key); | |
| 8 | + if (!hit) return undefined; | |
| 9 | + if (hit.expires < Date.now()) { | |
| 10 | + this.store.delete(key); | |
| 11 | + return undefined; | |
| 12 | + } | |
| 13 | + return hit.value; | |
| 14 | + } | |
| 15 | + | |
| 16 | + set(key: string, value: V): V { | |
| 17 | + this.store.set(key, { value, expires: Date.now() + this.ttlMs }); | |
| 18 | + return value; | |
| 19 | + } | |
| 20 | + | |
| 21 | + async getOrLoad(key: string, load: () => Promise<V>): Promise<V> { | |
| 22 | + const hit = this.get(key); | |
| 23 | + if (hit !== undefined) return hit; | |
| 24 | + return this.set(key, await load()); | |
| 25 | + } | |
| 26 | + | |
| 27 | + clear(): void { | |
| 28 | + this.store.clear(); | |
| 29 | + } | |
| 30 | +} | |
added
apps/api/src/lib/descendants.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { Database } from '@cancerindex/database'; | |
| 3 | + | |
| 4 | +export const MAX_HIERARCHY_DEPTH = 12; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Cancer + all descendants across every hierarchy type (ncit, oncotree, …), depth ≤ 12 — the same | |
| 8 | + * traversal the ranking counters use (packages/ranking/src/counters.ts) so API numbers match rankings. | |
| 9 | + */ | |
| 10 | +export async function descendantIds(db: Database, cancerId: string, hierarchyType?: string): Promise<string[]> { | |
| 11 | + const rows = await db.execute<{ id: string }>(sql` | |
| 12 | + WITH RECURSIVE d AS ( | |
| 13 | + SELECT ${cancerId}::varchar AS id, 0 AS depth | |
| 14 | + UNION | |
| 15 | + SELECT h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.id | |
| 16 | + WHERE d.depth < ${MAX_HIERARCHY_DEPTH} ${hierarchyType ? sql`AND h.hierarchy_type = ${hierarchyType}` : sql``} | |
| 17 | + ) | |
| 18 | + SELECT DISTINCT id FROM d`); | |
| 19 | + return rows.map((r) => r.id); | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Ancestors (for breadcrumbs), nearest first, depth ≤ 12. */ | |
| 23 | +export async function ancestorChain(db: Database, cancerId: string, hierarchyType = 'ncit'): Promise<Array<{ id: string; slug: string; name: string; depth: number }>> { | |
| 24 | + const rows = await db.execute<{ id: string; slug: string; name: string; depth: number }>(sql` | |
| 25 | + WITH RECURSIVE a AS ( | |
| 26 | + SELECT h.parent_id AS id, 1 AS depth FROM cancer_hierarchy h WHERE h.child_id = ${cancerId} AND h.hierarchy_type = ${hierarchyType} | |
| 27 | + UNION | |
| 28 | + SELECT h.parent_id, a.depth + 1 FROM a JOIN cancer_hierarchy h ON h.child_id = a.id AND h.hierarchy_type = ${hierarchyType} | |
| 29 | + WHERE a.depth < ${MAX_HIERARCHY_DEPTH} | |
| 30 | + ) | |
| 31 | + SELECT DISTINCT ON (a.id) a.id, c.slug, c.canonical_name AS name, a.depth FROM a JOIN cancers c ON c.id = a.id ORDER BY a.id, a.depth`); | |
| 32 | + return rows.map((r) => ({ ...r, depth: Number(r.depth) })).sort((x, y) => x.depth - y.depth); | |
| 33 | +} | |
added
apps/api/src/lib/env.ts
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { existsSync } from 'node:fs'; | |
| 2 | +import path from 'node:path'; | |
| 3 | +import { fileURLToPath } from 'node:url'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Load the repository `.env` (two levels above apps/api) when present, then the cwd `.env`. | |
| 7 | + * Values already set in the environment win (process.loadEnvFile never overrides). | |
| 8 | + */ | |
| 9 | +export function loadEnv(): void { | |
| 10 | + const here = path.dirname(fileURLToPath(import.meta.url)); | |
| 11 | + const candidates = [path.resolve(here, '../../../../.env'), path.resolve(process.cwd(), '.env')]; | |
| 12 | + for (const candidate of candidates) { | |
| 13 | + if (!existsSync(candidate)) continue; | |
| 14 | + try { | |
| 15 | + process.loadEnvFile(candidate); | |
| 16 | + } catch { | |
| 17 | + /* ignore malformed files */ | |
| 18 | + } | |
| 19 | + } | |
| 20 | + process.env.CI_SERVICE ??= 'api'; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export const API_HOST = () => process.env.API_HOST ?? '127.0.0.1'; | |
| 24 | +export const API_PORT = () => Number(process.env.API_PORT ?? 8251); | |
added
apps/api/src/lib/envelope.ts
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Response envelope for every /v1 endpoint (CLAUDE.md §321): | |
| 5 | + * { data, sources: [...], dataRelease: "CancerIndex <YYYY-MM>", generatedAt, ...pagination } | |
| 6 | + * `sources` lists the distinct upstream sources behind the returned data with their license and | |
| 7 | + * attribution so consumers can comply with redistribution terms (§142). | |
| 8 | + */ | |
| 9 | +export const SourceRef = z.object({ | |
| 10 | + id: z.string().describe('CI-SOURCE-… identifier'), | |
| 11 | + slug: z.string().describe('Connector id (e.g. "oncotree")'), | |
| 12 | + name: z.string(), | |
| 13 | + license: z.string().nullable(), | |
| 14 | + attribution: z.string().nullable(), | |
| 15 | + url: z.string().nullable().describe('Source homepage'), | |
| 16 | +}); | |
| 17 | +export type SourceRef = z.infer<typeof SourceRef>; | |
| 18 | + | |
| 19 | +export const Pagination = z.object({ | |
| 20 | + total: z.number().int().nonnegative().describe('Total rows matching the query (before limit/offset)'), | |
| 21 | + limit: z.number().int().positive(), | |
| 22 | + offset: z.number().int().nonnegative(), | |
| 23 | + hasMore: z.boolean(), | |
| 24 | +}); | |
| 25 | +export type Pagination = z.infer<typeof Pagination>; | |
| 26 | + | |
| 27 | +export interface Envelope<T> { | |
| 28 | + data: T; | |
| 29 | + sources: SourceRef[]; | |
| 30 | + dataRelease: string; | |
| 31 | + generatedAt: string; | |
| 32 | + total?: number; | |
| 33 | + limit?: number; | |
| 34 | + offset?: number; | |
| 35 | + hasMore?: boolean; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Zod schema factory for OpenAPI generation: envelope around a data schema. */ | |
| 39 | +export function envelopeSchema<T extends z.ZodTypeAny>(data: T, paginated = false) { | |
| 40 | + const base = z.object({ | |
| 41 | + data, | |
| 42 | + sources: z.array(SourceRef), | |
| 43 | + dataRelease: z.string().describe('Data release label, "CancerIndex YYYY-MM"'), | |
| 44 | + generatedAt: z.string().describe('ISO-8601 timestamp of the response'), | |
| 45 | + }); | |
| 46 | + return paginated ? base.extend(Pagination.shape) : base; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** "CancerIndex 2026-09" — month of the most recent successful ingest run, or of the request when unknown. */ | |
| 50 | +export function dataRelease(asOf: Date = new Date()): string { | |
| 51 | + return `CancerIndex ${asOf.toISOString().slice(0, 7)}`; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function envelope<T>(data: T, sources: SourceRef[], pagination?: Pagination, asOf?: Date): Envelope<T> { | |
| 55 | + const out: Envelope<T> = { data, sources: dedupeSources(sources), dataRelease: dataRelease(asOf), generatedAt: new Date().toISOString() }; | |
| 56 | + if (pagination) Object.assign(out, pagination); | |
| 57 | + return out; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function paginate(total: number, limit: number, offset: number): Pagination { | |
| 61 | + return { total, limit, offset, hasMore: offset + limit < total }; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Distinct by id, stable order by slug. */ | |
| 65 | +export function dedupeSources(list: SourceRef[]): SourceRef[] { | |
| 66 | + const seen = new Map<string, SourceRef>(); | |
| 67 | + for (const s of list) if (!seen.has(s.id)) seen.set(s.id, s); | |
| 68 | + return [...seen.values()].sort((a, b) => a.slug.localeCompare(b.slug)); | |
| 69 | +} | |
added
apps/api/src/lib/errors.ts
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +/** Typed HTTP errors mapped by the global error handler in app.ts. */ | |
| 2 | +export class HttpError extends Error { | |
| 3 | + constructor( | |
| 4 | + public readonly statusCode: number, | |
| 5 | + public readonly code: string, | |
| 6 | + message: string, | |
| 7 | + public readonly details?: unknown, | |
| 8 | + ) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +export class NotFound extends HttpError { | |
| 14 | + constructor(entity: string, ref: string) { | |
| 15 | + super(404, 'not_found', `${entity} "${ref}" not found`); | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +export class BadRequest extends HttpError { | |
| 20 | + constructor(message: string, details?: unknown) { | |
| 21 | + super(400, 'bad_request', message, details); | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +export class Unauthorized extends HttpError { | |
| 26 | + constructor(message = 'unauthorized') { | |
| 27 | + super(401, 'unauthorized', message); | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +export class ServiceUnavailable extends HttpError { | |
| 32 | + constructor(message: string) { | |
| 33 | + super(503, 'service_unavailable', message); | |
| 34 | + } | |
| 35 | +} | |
added
apps/api/src/lib/pagination.ts
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import { z } from 'zod'; | |
| 2 | + | |
| 3 | +export const MAX_LIMIT = 200; | |
| 4 | + | |
| 5 | +/** Shared offset pagination query parameters (limit ≤ 200). */ | |
| 6 | +export const pageQuery = { | |
| 7 | + limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(50).describe('Page size (max 200)'), | |
| 8 | + offset: z.coerce.number().int().min(0).default(0).describe('Rows to skip'), | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export const boolQuery = z | |
| 12 | + .enum(['true', 'false', '1', '0']) | |
| 13 | + .transform((v) => v === 'true' || v === '1') | |
| 14 | + .optional(); | |
added
apps/api/src/lib/queue.ts
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +import { PgBoss } from 'pg-boss'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Job names shared with workers/main.ts. Queues use the `singleton` policy so a connector (or the | |
| 5 | + * counters/rank maintenance) never runs twice concurrently — the `singletonKey` is the connector id. | |
| 6 | + */ | |
| 7 | +export const JOBS = { | |
| 8 | + connectorRun: 'connector.run', | |
| 9 | + counters: 'maintenance.counters', | |
| 10 | + rank: 'maintenance.rank', | |
| 11 | + healthProbe: 'health.probe', | |
| 12 | +} as const; | |
| 13 | +export type JobName = (typeof JOBS)[keyof typeof JOBS]; | |
| 14 | + | |
| 15 | +export const PGBOSS_SCHEMA = 'pgboss'; | |
| 16 | + | |
| 17 | +let boss: PgBoss | null = null; | |
| 18 | +let starting: Promise<PgBoss> | null = null; | |
| 19 | + | |
| 20 | +/** Lazy pg-boss client for enqueueing only (no supervision, no cron — the worker owns those). */ | |
| 21 | +export async function getBoss(): Promise<PgBoss> { | |
| 22 | + if (boss) return boss; | |
| 23 | + if (!starting) { | |
| 24 | + starting = (async () => { | |
| 25 | + const b = new PgBoss({ | |
| 26 | + connectionString: process.env.DATABASE_URL ?? 'postgres://localhost:5432/cancerindex', | |
| 27 | + schema: PGBOSS_SCHEMA, | |
| 28 | + application_name: 'cancerindex-api', | |
| 29 | + supervise: false, | |
| 30 | + schedule: false, | |
| 31 | + max: 2, | |
| 32 | + }); | |
| 33 | + b.on('error', () => { | |
| 34 | + /* logged by caller on send failure */ | |
| 35 | + }); | |
| 36 | + await b.start(); | |
| 37 | + boss = b; | |
| 38 | + return b; | |
| 39 | + })(); | |
| 40 | + } | |
| 41 | + return starting; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * Queue policy `stately`: at most one job per singletonKey in created/retry/active state, i.e. a | |
| 46 | + * connector is never queued twice nor run concurrently (`send` returns null when deduplicated). | |
| 47 | + * The worker owns queue creation; this only covers the case where the API enqueues first. | |
| 48 | + */ | |
| 49 | +export const QUEUE_POLICY = 'stately' as const; | |
| 50 | + | |
| 51 | +export async function ensureQueue(b: PgBoss, name: JobName): Promise<void> { | |
| 52 | + const existing = await b.getQueue(name); | |
| 53 | + if (existing) return; | |
| 54 | + // pg-boss refuses `undefined` option values — pass only defined keys. | |
| 55 | + await b.createQueue(name, { policy: QUEUE_POLICY, retryLimit: 0, expireInSeconds: 3 * 3600 }); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export async function enqueue(name: JobName, data: Record<string, unknown>, singletonKey: string): Promise<string | null> { | |
| 59 | + const b = await getBoss(); | |
| 60 | + await ensureQueue(b, name); | |
| 61 | + return b.send(name, data, { singletonKey }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export async function closeBoss(): Promise<void> { | |
| 65 | + if (boss) { | |
| 66 | + await boss.stop({ graceful: false, close: true, timeout: 5000 }); | |
| 67 | + boss = null; | |
| 68 | + starting = null; | |
| 69 | + } | |
| 70 | +} | |
added
apps/api/src/lib/resolve.ts
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { Database } from '@cancerindex/database'; | |
| 3 | +import { isCiId, type IdNamespace } from '@cancerindex/shared'; | |
| 4 | +import { NotFound } from './errors.js'; | |
| 5 | + | |
| 6 | +export type RefKind = 'id' | 'slug' | 'symbol' | 'nct' | 'pmid'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Classify a public reference (pure, unit-tested): CI-<NS>-00000001 → id; NCT… → nct; digits → pmid; | |
| 10 | + * otherwise a slug/symbol. Never accepts database integers as entity references (CLAUDE.md §6). | |
| 11 | + */ | |
| 12 | +export function classifyRef(ref: string, ns: IdNamespace): RefKind { | |
| 13 | + const r = ref.trim(); | |
| 14 | + if (isCiId(r, ns)) return 'id'; | |
| 15 | + if (ns === 'TRIAL' && /^NCT\d{8}$/i.test(r)) return 'nct'; | |
| 16 | + if (ns === 'PUB' && /^\d{1,9}$/.test(r)) return 'pmid'; | |
| 17 | + if (ns === 'GENE') return 'symbol'; | |
| 18 | + return 'slug'; | |
| 19 | +} | |
| 20 | + | |
| 21 | +async function one<T extends Record<string, unknown>>(db: Database, query: ReturnType<typeof sql>): Promise<T | null> { | |
| 22 | + const rows = await db.execute<T>(query); | |
| 23 | + return (rows[0] as T | undefined) ?? null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** Cancer by CI-CAN id or slug. Follows `merged_into` so old ids keep resolving (§70). */ | |
| 27 | +export async function resolveCancer(db: Database, ref: string): Promise<{ id: string; slug: string; status: string }> { | |
| 28 | + const kind = classifyRef(ref, 'CAN'); | |
| 29 | + const row = | |
| 30 | + kind === 'id' | |
| 31 | + ? await one<{ id: string; slug: string; status: string; merged_into: string | null }>(db, sql`SELECT id, slug, status, merged_into FROM cancers WHERE id = ${ref}`) | |
| 32 | + : await one<{ id: string; slug: string; status: string; merged_into: string | null }>(db, sql`SELECT id, slug, status, merged_into FROM cancers WHERE slug = ${ref.toLowerCase()}`); | |
| 33 | + if (!row) throw new NotFound('cancer', ref); | |
| 34 | + if (row.status === 'merged' && row.merged_into) return resolveCancer(db, row.merged_into); | |
| 35 | + return { id: row.id, slug: row.slug, status: row.status }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Gene by CI-GENE id, HGNC symbol (case-insensitive), previous/alias symbol or HGNC:nnnn. */ | |
| 39 | +export async function resolveGene(db: Database, ref: string): Promise<{ id: string; symbol: string }> { | |
| 40 | + const kind = classifyRef(ref, 'GENE'); | |
| 41 | + if (kind === 'id') { | |
| 42 | + const row = await one<{ id: string; symbol: string }>(db, sql`SELECT id, symbol FROM genes WHERE id = ${ref}`); | |
| 43 | + if (row) return row; | |
| 44 | + throw new NotFound('gene', ref); | |
| 45 | + } | |
| 46 | + const sym = ref.trim(); | |
| 47 | + const row = await one<{ id: string; symbol: string }>( | |
| 48 | + db, | |
| 49 | + sql`SELECT g.id, g.symbol FROM genes g WHERE upper(g.symbol) = upper(${sym}) OR g.hgnc_id = ${sym} | |
| 50 | + UNION ALL | |
| 51 | + SELECT g.id, g.symbol FROM genes g JOIN gene_aliases a ON a.gene_id = g.id WHERE upper(a.alias) = upper(${sym}) | |
| 52 | + LIMIT 1`, | |
| 53 | + ); | |
| 54 | + if (!row) throw new NotFound('gene', ref); | |
| 55 | + return row; | |
| 56 | +} | |
| 57 | + | |
| 58 | +export async function resolveVariant(db: Database, ref: string): Promise<{ id: string; slug: string }> { | |
| 59 | + const kind = classifyRef(ref, 'VAR'); | |
| 60 | + const row = | |
| 61 | + kind === 'id' | |
| 62 | + ? await one<{ id: string; slug: string }>(db, sql`SELECT id, slug FROM variants WHERE id = ${ref}`) | |
| 63 | + : await one<{ id: string; slug: string }>(db, sql`SELECT id, slug FROM variants WHERE slug = ${ref.toLowerCase()}`); | |
| 64 | + if (!row) throw new NotFound('variant', ref); | |
| 65 | + return row; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export async function resolveDrug(db: Database, ref: string): Promise<{ id: string; slug: string }> { | |
| 69 | + const kind = classifyRef(ref, 'DRUG'); | |
| 70 | + const row = | |
| 71 | + kind === 'id' | |
| 72 | + ? await one<{ id: string; slug: string }>(db, sql`SELECT id, slug FROM drugs WHERE id = ${ref}`) | |
| 73 | + : await one<{ id: string; slug: string }>(db, sql`SELECT id, slug FROM drugs WHERE slug = ${ref.toLowerCase()}`); | |
| 74 | + if (!row) throw new NotFound('drug', ref); | |
| 75 | + return row; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export async function resolveTrial(db: Database, ref: string): Promise<{ id: string; nctId: string }> { | |
| 79 | + const kind = classifyRef(ref, 'TRIAL'); | |
| 80 | + const row = | |
| 81 | + kind === 'id' | |
| 82 | + ? await one<{ id: string; nct_id: string }>(db, sql`SELECT id, nct_id FROM clinical_trials WHERE id = ${ref}`) | |
| 83 | + : await one<{ id: string; nct_id: string }>(db, sql`SELECT id, nct_id FROM clinical_trials WHERE nct_id = ${ref.toUpperCase()}`); | |
| 84 | + if (!row) throw new NotFound('trial', ref); | |
| 85 | + return { id: row.id, nctId: row.nct_id }; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export async function resolvePublication(db: Database, ref: string): Promise<{ id: string; pmid: string | null }> { | |
| 89 | + const kind = classifyRef(ref, 'PUB'); | |
| 90 | + const row = | |
| 91 | + kind === 'id' | |
| 92 | + ? await one<{ id: string; pmid: string | null }>(db, sql`SELECT id, pmid FROM publications WHERE id = ${ref}`) | |
| 93 | + : await one<{ id: string; pmid: string | null }>(db, sql`SELECT id, pmid FROM publications WHERE pmid = ${ref}`); | |
| 94 | + if (!row) throw new NotFound('publication', ref); | |
| 95 | + return row; | |
| 96 | +} | |
added
apps/api/src/lib/respond.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import type { FastifyInstance } from 'fastify'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { TtlCache } from './cache.js'; | |
| 4 | +import { envelope, envelopeSchema, type Envelope, type Pagination } from './envelope.js'; | |
| 5 | +import { latestDataAsOf, loadSources } from './sources.js'; | |
| 6 | + | |
| 7 | +const asOfCache = new TtlCache<Date | 'none'>(60_000); | |
| 8 | + | |
| 9 | +/** Build the envelope: resolves source references and the data-release month (cached 60 s). */ | |
| 10 | +export async function respond<T>(app: FastifyInstance, data: T, sourceRefs: Iterable<string | null | undefined>, pagination?: Pagination): Promise<Envelope<T>> { | |
| 11 | + const [sources, asOf] = await Promise.all([loadSources(app.db, sourceRefs), asOfCache.getOrLoad('asOf', async () => (await latestDataAsOf(app.db)) ?? 'none')]); | |
| 12 | + return envelope(data, sources, pagination, asOf === 'none' ? undefined : asOf); | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** Loose schemas for OpenAPI: payload shapes are documented in docs/API.md and DATA-MODEL.md. */ | |
| 16 | +export const AnyRecord = z.record(z.string(), z.unknown()); | |
| 17 | +export const AnyList = z.array(AnyRecord); | |
| 18 | +export const ok = (data: z.ZodTypeAny, paginated = false) => ({ 200: envelopeSchema(data, paginated) }); | |
| 19 | + | |
| 20 | +export const ErrorResponse = z.object({ error: z.object({ code: z.string(), message: z.string(), details: z.unknown().optional() }), requestId: z.string() }); | |
| 21 | + | |
| 22 | +/** snake_case → camelCase for raw SQL rows (shallow; jsonb values are left untouched). */ | |
| 23 | +export function camel<T = Record<string, unknown>>(row: Record<string, unknown>): T { | |
| 24 | + const out: Record<string, unknown> = {}; | |
| 25 | + for (const [k, v] of Object.entries(row)) out[k.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())] = v; | |
| 26 | + return out as T; | |
| 27 | +} | |
| 28 | +export function camelRows<T = Record<string, unknown>>(rows: Iterable<Record<string, unknown>>): T[] { | |
| 29 | + return [...rows].map((r) => camel<T>(r)); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function num(v: unknown): number { | |
| 33 | + return v === null || v === undefined ? 0 : Number(v); | |
| 34 | +} | |
added
apps/api/src/lib/search.ts
+171 −0
@@ -0,0 +1,171 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { Database } from '@cancerindex/database'; | |
| 3 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 4 | + | |
| 5 | +export type SearchType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication'; | |
| 6 | + | |
| 7 | +/** Match tier — lower is better (CLAUDE.md §68, §312: exact > alias > prefix > fuzzy). */ | |
| 8 | +export const TIER = { exact: 0, alias: 1, prefix: 2, fuzzy: 3 } as const; | |
| 9 | +export type Tier = (typeof TIER)[keyof typeof TIER]; | |
| 10 | + | |
| 11 | +export interface SearchHit { | |
| 12 | + type: SearchType; | |
| 13 | + id: string; | |
| 14 | + slug: string; | |
| 15 | + name: string; | |
| 16 | + subtitle: string | null; | |
| 17 | + tier: Tier; | |
| 18 | + /** Similarity or secondary signal used inside a tier (higher is better). */ | |
| 19 | + score: number; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface SearchResult { | |
| 23 | + type: SearchType; | |
| 24 | + id: string; | |
| 25 | + slug: string; | |
| 26 | + name: string; | |
| 27 | + subtitle: string | null; | |
| 28 | + score: number; | |
| 29 | + match: 'exact' | 'alias' | 'prefix' | 'fuzzy'; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const TIER_NAME: Record<Tier, SearchResult['match']> = { 0: 'exact', 1: 'alias', 2: 'prefix', 3: 'fuzzy' }; | |
| 33 | +/** Entity ordering inside a tier: cancers first (the index is cancer-centric), then genes, drugs, variants, trials, publications. */ | |
| 34 | +const TYPE_ORDER: Record<SearchType, number> = { cancer: 0, gene: 1, drug: 2, variant: 3, trial: 4, publication: 5 }; | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * Deterministic ordering (pure, unit-tested): tier asc, score desc, type order, shorter name first, | |
| 38 | + * then name, then id. One result per (type, id). Returns the top `limit` results. | |
| 39 | + */ | |
| 40 | +export function rankSearchHits(hits: SearchHit[], limit = 20): SearchResult[] { | |
| 41 | + const best = new Map<string, SearchHit>(); | |
| 42 | + for (const h of hits) { | |
| 43 | + const key = `${h.type}:${h.id}`; | |
| 44 | + const prev = best.get(key); | |
| 45 | + if (!prev || h.tier < prev.tier || (h.tier === prev.tier && h.score > prev.score)) best.set(key, h); | |
| 46 | + } | |
| 47 | + return [...best.values()] | |
| 48 | + .sort((a, b) => a.tier - b.tier || b.score - a.score || TYPE_ORDER[a.type] - TYPE_ORDER[b.type] || a.name.length - b.name.length || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) | |
| 49 | + .slice(0, limit) | |
| 50 | + .map((h) => ({ type: h.type, id: h.id, slug: h.slug, name: h.name, subtitle: h.subtitle, score: Math.round((4 - h.tier + h.score) * 1000) / 1000, match: TIER_NAME[h.tier] })); | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Cross-entity search over aliases, symbols, slugs and registry ids. */ | |
| 54 | +export async function searchAll(db: Database, q: string, types?: SearchType[], limit = 20): Promise<SearchResult[]> { | |
| 55 | + const raw = q.trim(); | |
| 56 | + const norm = normalizeLabel(raw); | |
| 57 | + if (!norm) return []; | |
| 58 | + const want = new Set<SearchType>(types && types.length ? types : ['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']); | |
| 59 | + const hits: SearchHit[] = []; | |
| 60 | + const per = Math.max(limit, 20); | |
| 61 | + const fuzzyOk = norm.length >= 4; | |
| 62 | + | |
| 63 | + const tasks: Array<Promise<void>> = []; | |
| 64 | + | |
| 65 | + if (want.has('cancer')) { | |
| 66 | + tasks.push( | |
| 67 | + db | |
| 68 | + .execute<{ id: string; slug: string; name: string; subtitle: string | null; tier: number; score: number }>(sql` | |
| 69 | + SELECT c.id, c.slug, c.canonical_name AS name, c.primary_ncit_code AS subtitle, | |
| 70 | + min(CASE WHEN a.normalized = ${norm} AND a.alias_type = 'preferred' THEN 0 | |
| 71 | + WHEN a.normalized = ${norm} THEN 1 | |
| 72 | + WHEN a.normalized LIKE ${norm + '%'} THEN 2 | |
| 73 | + ELSE 3 END) AS tier, | |
| 74 | + max(similarity(a.normalized, ${norm})) AS score | |
| 75 | + FROM cancers c JOIN cancer_aliases a ON a.cancer_id = c.id | |
| 76 | + WHERE c.status = 'active' AND (a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'} ${fuzzyOk ? sql`OR a.normalized % ${norm}` : sql``}) | |
| 77 | + GROUP BY c.id, c.slug, c.canonical_name, c.primary_ncit_code | |
| 78 | + ORDER BY tier, score DESC, c.canonical_name LIMIT ${per}`) | |
| 79 | + .then((rows) => { | |
| 80 | + for (const r of rows) hits.push({ type: 'cancer', id: r.id, slug: r.slug, name: r.name, subtitle: r.subtitle ? `NCIt ${r.subtitle}` : null, tier: Number(r.tier) as Tier, score: Number(r.score) }); | |
| 81 | + }), | |
| 82 | + ); | |
| 83 | + } | |
| 84 | + if (want.has('gene')) { | |
| 85 | + const up = raw.toUpperCase(); | |
| 86 | + tasks.push( | |
| 87 | + db | |
| 88 | + .execute<{ id: string; symbol: string; name: string | null; tier: number; score: number }>(sql` | |
| 89 | + SELECT g.id, g.symbol, g.name, | |
| 90 | + min(CASE WHEN upper(g.symbol) = ${up} THEN 0 | |
| 91 | + WHEN upper(a.alias) = ${up} THEN 1 | |
| 92 | + WHEN upper(g.symbol) LIKE ${up + '%'} OR upper(a.alias) LIKE ${up + '%'} THEN 2 | |
| 93 | + ELSE 3 END) AS tier, | |
| 94 | + greatest(max(similarity(upper(g.symbol), ${up})), max(similarity(upper(coalesce(a.alias, '')), ${up})), max(similarity(lower(coalesce(g.name,'')), ${norm}))) AS score | |
| 95 | + FROM genes g LEFT JOIN gene_aliases a ON a.gene_id = g.id | |
| 96 | + WHERE upper(g.symbol) LIKE ${up + '%'} OR upper(a.alias) LIKE ${up + '%'} ${fuzzyOk ? sql`OR lower(g.name) % ${norm}` : sql``} | |
| 97 | + GROUP BY g.id, g.symbol, g.name | |
| 98 | + ORDER BY tier, score DESC, g.symbol LIMIT ${per}`) | |
| 99 | + .then((rows) => { | |
| 100 | + for (const r of rows) hits.push({ type: 'gene', id: r.id, slug: r.symbol, name: r.symbol, subtitle: r.name, tier: Number(r.tier) as Tier, score: Number(r.score) }); | |
| 101 | + }), | |
| 102 | + ); | |
| 103 | + } | |
| 104 | + if (want.has('drug')) { | |
| 105 | + tasks.push( | |
| 106 | + db | |
| 107 | + .execute<{ id: string; slug: string; name: string; kind: string | null; tier: number; score: number }>(sql` | |
| 108 | + SELECT d.id, d.slug, d.name, d.kind, | |
| 109 | + min(CASE WHEN a.normalized = ${norm} AND a.alias_type = 'generic' THEN 0 | |
| 110 | + WHEN a.normalized = ${norm} THEN 1 | |
| 111 | + WHEN a.normalized LIKE ${norm + '%'} THEN 2 ELSE 3 END) AS tier, | |
| 112 | + max(similarity(a.normalized, ${norm})) AS score | |
| 113 | + FROM drugs d JOIN drug_aliases a ON a.drug_id = d.id | |
| 114 | + WHERE a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'} ${fuzzyOk ? sql`OR a.normalized % ${norm}` : sql``} | |
| 115 | + GROUP BY d.id, d.slug, d.name, d.kind | |
| 116 | + ORDER BY tier, score DESC, d.name LIMIT ${per}`) | |
| 117 | + .then((rows) => { | |
| 118 | + for (const r of rows) hits.push({ type: 'drug', id: r.id, slug: r.slug, name: r.name, subtitle: r.kind, tier: Number(r.tier) as Tier, score: Number(r.score) }); | |
| 119 | + }), | |
| 120 | + ); | |
| 121 | + } | |
| 122 | + if (want.has('variant')) { | |
| 123 | + const slugLike = norm.replace(/ /g, '-'); | |
| 124 | + tasks.push( | |
| 125 | + db | |
| 126 | + .execute<{ id: string; slug: string; name: string; gene_symbol: string | null; tier: number; score: number }>(sql` | |
| 127 | + SELECT v.id, v.slug, v.name, v.gene_symbol, | |
| 128 | + CASE WHEN v.slug = ${slugLike} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) = ${norm} THEN 0 | |
| 129 | + WHEN lower(v.name) = ${norm} THEN 1 | |
| 130 | + WHEN v.slug LIKE ${slugLike + '%'} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) LIKE ${norm + '%'} THEN 2 ELSE 3 END AS tier, | |
| 131 | + similarity(lower(coalesce(v.gene_symbol,'') || ' ' || v.name), ${norm}) AS score | |
| 132 | + FROM variants v | |
| 133 | + WHERE v.slug LIKE ${slugLike + '%'} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) LIKE ${norm + '%'} OR lower(v.name) = ${norm} | |
| 134 | + ${fuzzyOk ? sql`OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) % ${norm}` : sql``} | |
| 135 | + ORDER BY tier, score DESC, v.name LIMIT ${per}`) | |
| 136 | + .then((rows) => { | |
| 137 | + for (const r of rows) hits.push({ type: 'variant', id: r.id, slug: r.slug, name: r.gene_symbol ? `${r.gene_symbol} ${r.name}` : r.name, subtitle: 'variant', tier: Number(r.tier) as Tier, score: Number(r.score) }); | |
| 138 | + }), | |
| 139 | + ); | |
| 140 | + } | |
| 141 | + if (want.has('trial')) { | |
| 142 | + const up = raw.toUpperCase(); | |
| 143 | + tasks.push( | |
| 144 | + db | |
| 145 | + .execute<{ id: string; nct_id: string; brief_title: string; overall_status: string | null; tier: number; score: number }>(sql` | |
| 146 | + SELECT t.id, t.nct_id, t.brief_title, t.overall_status, | |
| 147 | + CASE WHEN t.nct_id = ${up} THEN 0 | |
| 148 | + WHEN upper(coalesce(t.acronym,'')) = ${up} THEN 1 | |
| 149 | + WHEN t.nct_id LIKE ${up + '%'} THEN 2 ELSE 3 END AS tier, | |
| 150 | + similarity(lower(t.brief_title), ${norm}) AS score | |
| 151 | + FROM clinical_trials t | |
| 152 | + WHERE t.nct_id LIKE ${up + '%'} OR upper(coalesce(t.acronym,'')) = ${up} ${fuzzyOk ? sql`OR lower(t.brief_title) % ${norm}` : sql``} | |
| 153 | + ORDER BY tier, score DESC, t.nct_id LIMIT ${per}`) | |
| 154 | + .then((rows) => { | |
| 155 | + for (const r of rows) hits.push({ type: 'trial', id: r.id, slug: r.nct_id, name: r.nct_id, subtitle: r.brief_title, tier: Number(r.tier) as Tier, score: Number(r.score) }); | |
| 156 | + }), | |
| 157 | + ); | |
| 158 | + } | |
| 159 | + if (want.has('publication') && /^\d{1,9}$/.test(raw)) { | |
| 160 | + tasks.push( | |
| 161 | + db | |
| 162 | + .execute<{ id: string; pmid: string; title: string; journal: string | null; pub_year: number | null }>(sql` | |
| 163 | + SELECT id, pmid, title, journal, pub_year FROM publications WHERE pmid = ${raw} LIMIT 1`) | |
| 164 | + .then((rows) => { | |
| 165 | + for (const r of rows) hits.push({ type: 'publication', id: r.id, slug: r.pmid, name: r.title, subtitle: [r.journal, r.pub_year].filter(Boolean).join(' · ') || `PMID ${r.pmid}`, tier: TIER.exact, score: 1 }); | |
| 166 | + }), | |
| 167 | + ); | |
| 168 | + } | |
| 169 | + await Promise.all(tasks); | |
| 170 | + return rankSearchHits(hits, limit); | |
| 171 | +} | |
added
apps/api/src/lib/sources.ts
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { Database } from '@cancerindex/database'; | |
| 3 | +import type { SourceRef } from './envelope.js'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Load source references for the envelope. Accepts CI-SOURCE ids and/or connector slugs (ranking | |
| 7 | + * snapshots store slugs for count metrics and ids for epidemiology scopes). | |
| 8 | + */ | |
| 9 | +export async function loadSources(db: Database, refs: Iterable<string | null | undefined>): Promise<SourceRef[]> { | |
| 10 | + const ids = new Set<string>(); | |
| 11 | + const slugs = new Set<string>(); | |
| 12 | + for (const r of refs) { | |
| 13 | + if (!r) continue; | |
| 14 | + if (r.startsWith('CI-SOURCE-')) ids.add(r); | |
| 15 | + else slugs.add(r); | |
| 16 | + } | |
| 17 | + if (ids.size === 0 && slugs.size === 0) return []; | |
| 18 | + const rows = await db.execute<{ id: string; slug: string; name: string; license: string | null; attribution: string | null; homepage: string | null }>(sql` | |
| 19 | + SELECT id, slug, name, license, attribution, homepage FROM sources | |
| 20 | + WHERE id = ANY(${sql.param([...ids])}::text[]) OR slug = ANY(${sql.param([...slugs])}::text[]) | |
| 21 | + ORDER BY slug`); | |
| 22 | + return rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, license: r.license, attribution: r.attribution, url: r.homepage })); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Collect a column from rows (null-safe). */ | |
| 26 | +export function pluck<T extends Record<string, unknown>>(rows: readonly T[], ...keys: Array<keyof T>): string[] { | |
| 27 | + const out: string[] = []; | |
| 28 | + for (const r of rows) for (const k of keys) if (typeof r[k] === 'string') out.push(r[k] as string); | |
| 29 | + return out; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Sources that contributed codes, aliases or hierarchy edges to a set of cancers. */ | |
| 33 | +export async function cancerStructuralSourceIds(db: Database, cancerIds: string[]): Promise<string[]> { | |
| 34 | + if (cancerIds.length === 0) return []; | |
| 35 | + const rows = await db.execute<{ source_id: string }>(sql` | |
| 36 | + SELECT DISTINCT source_id FROM ( | |
| 37 | + SELECT source_id FROM cancer_codes WHERE cancer_id = ANY(${sql.param(cancerIds)}::text[]) AND source_id IS NOT NULL | |
| 38 | + UNION SELECT source_id FROM cancer_aliases WHERE cancer_id = ANY(${sql.param(cancerIds)}::text[]) AND source_id IS NOT NULL | |
| 39 | + UNION SELECT source_id FROM cancer_hierarchy WHERE (child_id = ANY(${sql.param(cancerIds)}::text[]) OR parent_id = ANY(${sql.param(cancerIds)}::text[])) AND source_id IS NOT NULL | |
| 40 | + ) s`); | |
| 41 | + return rows.map((r) => r.source_id); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** Most recent successful ingest finish — used for the dataRelease label. Cached by the caller. */ | |
| 45 | +export async function latestDataAsOf(db: Database): Promise<Date | undefined> { | |
| 46 | + const rows = await db.execute<{ t: string | null }>(sql`SELECT max(finished_at)::text AS t FROM ingest_runs WHERE status IN ('succeeded','partial')`); | |
| 47 | + const t = rows[0]?.t; | |
| 48 | + return t ? new Date(t) : undefined; | |
| 49 | +} | |
added
apps/api/src/plugins/auth.ts
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +import { createHash, timingSafeEqual } from 'node:crypto'; | |
| 2 | +import { sql } from 'drizzle-orm'; | |
| 3 | +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; | |
| 4 | +import { TtlCache } from '../lib/cache.js'; | |
| 5 | +import { ServiceUnavailable, Unauthorized } from '../lib/errors.js'; | |
| 6 | +import type { ApiKeyInfo } from '../types.js'; | |
| 7 | + | |
| 8 | +export const ANONYMOUS_LIMIT_PER_MINUTE = 60; | |
| 9 | + | |
| 10 | +export function hashApiKey(key: string): string { | |
| 11 | + return createHash('sha256').update(key).digest('hex'); | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * API key resolution (CLAUDE.md §190-191). `Authorization: Bearer <key>` → sha256 → api_keys. | |
| 16 | + * Anonymous requests are allowed (60/min by IP); an invalid key is rejected with 401 rather than | |
| 17 | + * silently downgraded so integrators notice misconfiguration. | |
| 18 | + */ | |
| 19 | +export function registerApiKeyAuth(app: FastifyInstance): void { | |
| 20 | + const cache = new TtlCache<ApiKeyInfo | 'invalid'>(60_000); | |
| 21 | + const lastTouched = new Map<number, number>(); | |
| 22 | + app.decorateRequest('apiKey', null); | |
| 23 | + | |
| 24 | + app.addHook('onRequest', async (req: FastifyRequest) => { | |
| 25 | + const header = req.headers.authorization; | |
| 26 | + if (!header) return; | |
| 27 | + const m = /^Bearer\s+(\S+)$/i.exec(header); | |
| 28 | + if (!m) throw new Unauthorized('malformed Authorization header (expected "Bearer <key>")'); | |
| 29 | + const hash = hashApiKey(m[1]!); | |
| 30 | + const info = await cache.getOrLoad(hash, async () => { | |
| 31 | + const rows = await app.db.execute<{ id: number; prefix: string; tier: string; rate_limit_per_minute: number; label: string | null }>(sql` | |
| 32 | + SELECT id, prefix, tier, rate_limit_per_minute, label FROM api_keys WHERE key_hash = ${hash} AND active LIMIT 1`); | |
| 33 | + const r = rows[0]; | |
| 34 | + return r ? { id: Number(r.id), prefix: r.prefix, tier: r.tier, rateLimitPerMinute: Number(r.rate_limit_per_minute), label: r.label } : 'invalid'; | |
| 35 | + }); | |
| 36 | + if (info === 'invalid') throw new Unauthorized('invalid or inactive API key'); | |
| 37 | + req.apiKey = info; | |
| 38 | + // Touch last_used_at at most every 5 minutes per key (fire-and-forget). | |
| 39 | + const now = Date.now(); | |
| 40 | + if ((lastTouched.get(info.id) ?? 0) < now - 300_000) { | |
| 41 | + lastTouched.set(info.id, now); | |
| 42 | + app.db.execute(sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${info.id}`).catch((e: unknown) => app.log.warn({ err: e }, 'api_keys touch failed')); | |
| 43 | + } | |
| 44 | + }); | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Rate-limit key: per API key when present, otherwise per client IP. */ | |
| 48 | +export function rateLimitKey(req: FastifyRequest): string { | |
| 49 | + return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export function rateLimitMax(req: FastifyRequest): number { | |
| 53 | + return req.apiKey?.rateLimitPerMinute ?? ANONYMOUS_LIMIT_PER_MINUTE; | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** Admin guard: constant-time comparison of `x-admin-token` with ADMIN_TOKEN. */ | |
| 57 | +export async function requireAdmin(req: FastifyRequest, _reply: FastifyReply): Promise<void> { | |
| 58 | + const expected = process.env.ADMIN_TOKEN; | |
| 59 | + if (!expected || expected === 'change-me') throw new ServiceUnavailable('admin endpoints disabled: set ADMIN_TOKEN'); | |
| 60 | + const given = req.headers['x-admin-token']; | |
| 61 | + const token = Array.isArray(given) ? given[0] : given; | |
| 62 | + if (!token) throw new Unauthorized('missing x-admin-token'); | |
| 63 | + const a = Buffer.from(token); | |
| 64 | + const b = Buffer.from(expected); | |
| 65 | + if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Unauthorized('invalid admin token'); | |
| 66 | +} | |
added
apps/api/src/routes/admin.ts
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { traceValue } from '@cancerindex/ranking'; | |
| 5 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 6 | +import { BadRequest, NotFound } from '../lib/errors.js'; | |
| 7 | +import { JOBS, enqueue } from '../lib/queue.js'; | |
| 8 | +import { requireAdmin } from '../plugins/auth.js'; | |
| 9 | +import { AnyList, AnyRecord, camel, camelRows, ok, respond } from '../lib/respond.js'; | |
| 10 | + | |
| 11 | +const ACTOR = 'admin-api'; | |
| 12 | + | |
| 13 | +// Optional JSON bodies: Fastify hands the validator `null` when a POST carries no body, hence nullish(). | |
| 14 | +const ReasonBody = z.object({ reason: z.string().max(500).optional() }).nullish(); | |
| 15 | +const RunBody = z.object({ mode: z.enum(['full', 'incremental', 'backfill', 'dry_run']).optional(), maxMinutes: z.number().int().min(1).max(600).optional(), maxRecords: z.number().int().min(1).optional(), resetCursor: z.boolean().optional(), reason: z.string().max(500).optional() }).nullish(); | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Operator endpoints (CLAUDE.md §348: every mutation is audited). Guarded by `x-admin-token`. | |
| 19 | + * Runs are never executed in the API process: they are enqueued to pg-boss and executed by the worker. | |
| 20 | + */ | |
| 21 | +export const adminRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 22 | + app.addHook('onRequest', requireAdmin); | |
| 23 | + | |
| 24 | + const audit = async (action: string, entityType: string | null, entityId: string | null, before: unknown, after: unknown, reason?: string) => { | |
| 25 | + await app.db.execute(sql`INSERT INTO audit_log (actor, action, entity_type, entity_id, before, after, reason) VALUES (${ACTOR}, ${action}, ${entityType}, ${entityId}, ${before === undefined ? null : JSON.stringify(before)}::jsonb, ${after === undefined ? null : JSON.stringify(after)}::jsonb, ${reason ?? null})`); | |
| 26 | + }; | |
| 27 | + | |
| 28 | + const connectorExists = async (id: string) => { | |
| 29 | + const rows = await app.db.execute<{ slug: string; status: string }>(sql`SELECT slug, status FROM sources WHERE slug = ${id}`); | |
| 30 | + if (!rows[0]) throw new NotFound('connector', id); | |
| 31 | + return rows[0]; | |
| 32 | + }; | |
| 33 | + | |
| 34 | + app.get('/connectors', { schema: { tags: ['admin'], summary: 'Connector health, cursors and the last 5 runs each', security: [{ adminToken: [] }], response: ok(AnyList) } }, async () => { | |
| 35 | + const [sources, runs] = await Promise.all([ | |
| 36 | + app.db.execute<Record<string, unknown>>(sql` | |
| 37 | + SELECT s.id AS source_id, s.slug AS connector_id, s.name, s.category, s.tier, s.status, s.license_status, s.manifest->>'schedule' AS schedule, s.manifest->>'documentationVerifiedAt' AS documentation_verified_at, | |
| 38 | + cc.health, cc.health_detail, cc.paused, cc.cursor, cc.last_success_at, cc.last_attempt_at, | |
| 39 | + (SELECT count(*) FROM source_records sr WHERE sr.source_id = s.id) AS record_count, | |
| 40 | + (SELECT count(*) FROM unresolved_labels u WHERE u.source_id = s.id AND u.status = 'open') AS unresolved_open | |
| 41 | + FROM sources s LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug ORDER BY s.tier, s.slug`), | |
| 42 | + app.db.execute<Record<string, unknown>>(sql` | |
| 43 | + SELECT * FROM ( | |
| 44 | + SELECT id, connector_id, mode, status, started_at, finished_at, duration_ms, records_fetched, records_created, records_updated, records_unchanged, records_rejected, http_requests, http_failures, rate_limit_events, validation_failures, | |
| 45 | + jsonb_array_length(schema_drift) AS drift_signals, error, anomaly, dataset_version, row_number() OVER (PARTITION BY connector_id ORDER BY started_at DESC) AS rn | |
| 46 | + FROM ingest_runs) x WHERE rn <= 5 ORDER BY connector_id, started_at DESC`), | |
| 47 | + ]); | |
| 48 | + const byConnector = new Map<string, Array<Record<string, unknown>>>(); | |
| 49 | + for (const r of runs) { | |
| 50 | + const { rn: _rn, ...rest } = r; | |
| 51 | + const list = byConnector.get(r.connector_id as string) ?? []; | |
| 52 | + list.push(camel(rest)); | |
| 53 | + byConnector.set(r.connector_id as string, list); | |
| 54 | + } | |
| 55 | + const data = sources.map((s) => ({ ...camel(s), health: s.health ?? 'never_run', recentRuns: byConnector.get(s.connector_id as string) ?? [] })); | |
| 56 | + return respond(app, data, []); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + app.post('/connectors/:id/run', { schema: { tags: ['admin'], summary: 'Enqueue a connector run (pg-boss connector.run, singleton per connector)', security: [{ adminToken: [] }], params: z.object({ id: z.string() }), body: RunBody, response: ok(AnyRecord) } }, async (req) => { | |
| 60 | + const src = await connectorExists(req.params.id); | |
| 61 | + const body = req.body ?? {}; | |
| 62 | + // pg-boss refuses undefined option values — build the payload with defined keys only. | |
| 63 | + const payload: Record<string, unknown> = { id: req.params.id, requestedBy: ACTOR }; | |
| 64 | + if (body.mode) payload.mode = body.mode; | |
| 65 | + if (body.maxMinutes) payload.maxMinutes = body.maxMinutes; | |
| 66 | + if (body.maxRecords) payload.maxRecords = body.maxRecords; | |
| 67 | + if (body.resetCursor) payload.resetCursor = true; | |
| 68 | + const jobId = await enqueue(JOBS.connectorRun, payload, req.params.id); | |
| 69 | + await audit('connector.run.enqueue', 'connector', req.params.id, null, { jobId, payload, sourceStatus: src.status }, body.reason); | |
| 70 | + return respond(app, { connectorId: req.params.id, jobId, queued: jobId !== null, note: jobId === null ? 'a run for this connector is already queued or active (singleton)' : 'queued; the worker will pick it up' }, []); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + for (const action of ['pause', 'resume'] as const) { | |
| 74 | + app.post(`/connectors/:id/${action}`, { schema: { tags: ['admin'], summary: `${action === 'pause' ? 'Pause' : 'Resume'} a connector (worker skips paused connectors)`, security: [{ adminToken: [] }], params: z.object({ id: z.string() }), body: ReasonBody, response: ok(AnyRecord) } }, async (req) => { | |
| 75 | + await connectorExists(req.params.id); | |
| 76 | + const before = await app.db.execute<Record<string, unknown>>(sql`SELECT paused, health FROM connector_cursors WHERE connector_id = ${req.params.id}`); | |
| 77 | + const paused = action === 'pause'; | |
| 78 | + await app.db.execute(sql`INSERT INTO connector_cursors (connector_id, paused, updated_at) VALUES (${req.params.id}, ${paused}, now()) ON CONFLICT (connector_id) DO UPDATE SET paused = ${paused}, updated_at = now()`); | |
| 79 | + await app.db.execute(sql`UPDATE sources SET status = ${paused ? 'paused' : 'active'}, updated_at = now() WHERE slug = ${req.params.id} AND status IN ('active','paused','degraded')`); | |
| 80 | + await audit(`connector.${action}`, 'connector', req.params.id, before[0] ?? null, { paused }, req.body?.reason); | |
| 81 | + return respond(app, { connectorId: req.params.id, paused }, []); | |
| 82 | + }); | |
| 83 | + } | |
| 84 | + | |
| 85 | + app.get('/runs/:runId', { schema: { tags: ['admin'], summary: 'Full ingest run record: counters, log, schema drift, cursors', security: [{ adminToken: [] }], params: z.object({ runId: z.string() }), response: ok(AnyRecord) } }, async (req) => { | |
| 86 | + const rows = await app.db.execute<Record<string, unknown>>(sql`SELECT * FROM ingest_runs WHERE id = ${req.params.runId}`); | |
| 87 | + if (!rows[0]) throw new NotFound('run', req.params.runId); | |
| 88 | + return respond(app, camel(rows[0]), [rows[0].source_id as string]); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + app.get('/unresolved', { schema: { tags: ['admin'], summary: 'Curation queue: labels no connector could reconcile (never dropped, §222)', security: [{ adminToken: [] }], querystring: z.object({ entityKind: z.string().optional(), status: z.enum(['open', 'mapped', 'rejected', 'ignored']).default('open'), sourceId: z.string().optional(), limit: z.coerce.number().int().min(1).max(500).default(100), offset: z.coerce.number().int().min(0).default(0) }), response: ok(AnyList, true) } }, async (req) => { | |
| 92 | + const q = req.query; | |
| 93 | + const conds = [sql`u.status = ${q.status}`]; | |
| 94 | + if (q.entityKind) conds.push(sql`u.entity_kind = ${q.entityKind}`); | |
| 95 | + if (q.sourceId) conds.push(sql`(u.source_id = ${q.sourceId} OR s.slug = ${q.sourceId})`); | |
| 96 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 97 | + SELECT u.*, s.slug AS source_slug, c.canonical_name AS suggested_name, count(*) OVER() AS total | |
| 98 | + FROM unresolved_labels u JOIN sources s ON s.id = u.source_id LEFT JOIN cancers c ON c.id = u.suggested_id | |
| 99 | + WHERE ${sql.join(conds, sql` AND `)} ORDER BY u.count DESC, u.id LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 100 | + const total = rows.length ? Number(rows[0]!.total) : 0; | |
| 101 | + const data = rows.map((r) => { | |
| 102 | + const { total: _t, ...rest } = r; | |
| 103 | + return camel(rest); | |
| 104 | + }); | |
| 105 | + return respond(app, data, rows.map((r) => r.source_id as string), { total, limit: q.limit, offset: q.offset, hasMore: q.offset + q.limit < total }); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + app.post('/unresolved/:id/resolve', { schema: { tags: ['admin'], summary: 'Resolve an unresolved label: map to a cancer (adds a curated alias; connectors pick it up on the next run) or reject', security: [{ adminToken: [] }], params: z.object({ id: z.coerce.number().int() }), body: z.object({ cancerId: z.string().optional(), rejected: z.boolean().optional(), reason: z.string().max(500).optional() }), response: ok(AnyRecord) } }, async (req) => { | |
| 109 | + const rows = await app.db.execute<Record<string, unknown>>(sql`SELECT * FROM unresolved_labels WHERE id = ${req.params.id}`); | |
| 110 | + const u = rows[0]; | |
| 111 | + if (!u) throw new NotFound('unresolved label', String(req.params.id)); | |
| 112 | + const { cancerId, rejected, reason } = req.body; | |
| 113 | + if (!cancerId && !rejected) throw new BadRequest('provide cancerId or rejected=true'); | |
| 114 | + if (cancerId && rejected) throw new BadRequest('cancerId and rejected are mutually exclusive'); | |
| 115 | + let after: Record<string, unknown>; | |
| 116 | + if (rejected) { | |
| 117 | + await app.db.execute(sql`UPDATE unresolved_labels SET status = 'rejected', resolved_by = ${ACTOR}, updated_at = now() WHERE id = ${req.params.id}`); | |
| 118 | + after = { status: 'rejected' }; | |
| 119 | + } else { | |
| 120 | + const cancer = await app.db.execute<{ id: string; canonical_name: string }>(sql`SELECT id, canonical_name FROM cancers WHERE id = ${cancerId!} AND status = 'active'`); | |
| 121 | + if (!cancer[0]) throw new NotFound('cancer', cancerId!); | |
| 122 | + if (u.entity_kind !== 'cancer') throw new BadRequest(`only cancer labels can be mapped here (entity_kind=${u.entity_kind as string})`); | |
| 123 | + const normalized = (u.normalized as string) || normalizeLabel(u.source_text as string); | |
| 124 | + await app.db.transaction(async (tx) => { | |
| 125 | + await tx.execute(sql`INSERT INTO cancer_aliases (cancer_id, alias, normalized, alias_type, source_id, source_terminology, language) | |
| 126 | + VALUES (${cancer[0]!.id}, ${u.source_text as string}, ${normalized}, 'synonym', ${u.source_id as string}, 'curation', 'en') ON CONFLICT DO NOTHING`); | |
| 127 | + await tx.execute(sql`UPDATE unresolved_labels SET status = 'mapped', resolved_id = ${cancer[0]!.id}, resolved_by = ${ACTOR}, updated_at = now() WHERE id = ${req.params.id}`); | |
| 128 | + // Immediate effect on trial conditions that carry the same normalized label; other tables are re-reconciled by their connector's next run. | |
| 129 | + await tx.execute(sql`UPDATE trial_conditions SET cancer_id = ${cancer[0]!.id}, match_type = 'CURATED_EXACT', confidence = 1 WHERE cancer_id IS NULL AND normalized = ${normalized}`); | |
| 130 | + await tx.execute(sql`INSERT INTO change_events (entity_type, entity_id, kind, summary, after) VALUES ('cancer', ${cancer[0]!.id}, 'alias_added', ${`Curated alias "${u.source_text as string}" added from unresolved label #${req.params.id}`}, ${JSON.stringify({ alias: u.source_text, normalized, sourceId: u.source_id })}::jsonb)`); | |
| 131 | + }); | |
| 132 | + after = { status: 'mapped', resolvedId: cancer[0].id, cancerName: cancer[0].canonical_name, aliasAdded: u.source_text, normalized }; | |
| 133 | + } | |
| 134 | + await audit('unresolved.resolve', 'unresolved_label', String(req.params.id), camel(u), after, reason); | |
| 135 | + return respond(app, { id: req.params.id, ...after }, [u.source_id as string]); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + app.get('/trace/:table/:id', { schema: { tags: ['admin'], summary: 'Lineage trace: ranked value → inputs → observation → provenance → raw record (§252-253)', security: [{ adminToken: [] }], params: z.object({ table: z.enum(['rankings', 'epidemiology_observations', 'cancer_gene_frequencies', 'literature_counts', 'survival_observations']), id: z.string() }), response: ok(AnyRecord) } }, async (req) => { | |
| 139 | + const trace = await traceValue(app.db, req.params.table, req.params.id); | |
| 140 | + return respond(app, trace, []); | |
| 141 | + }); | |
| 142 | + | |
| 143 | + for (const [path, job, key] of [ | |
| 144 | + ['/jobs/counters', JOBS.counters, 'counters'], | |
| 145 | + ['/jobs/rank', JOBS.rank, 'rank'], | |
| 146 | + ] as const) { | |
| 147 | + app.post(path, { schema: { tags: ['admin'], summary: `Enqueue ${job}`, security: [{ adminToken: [] }], body: ReasonBody, response: ok(AnyRecord) } }, async (req) => { | |
| 148 | + const jobId = await enqueue(job, { requestedBy: ACTOR, thenRank: job === JOBS.counters ? false : undefined } as Record<string, unknown>, key); | |
| 149 | + await audit(`${job}.enqueue`, 'job', job, null, { jobId }, req.body?.reason); | |
| 150 | + return respond(app, { job, jobId, queued: jobId !== null }, []); | |
| 151 | + }); | |
| 152 | + } | |
| 153 | + | |
| 154 | + 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 | + 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 | + return respond(app, camelRows(rows), []); | |
| 157 | + }); | |
| 158 | +}; | |
added
apps/api/src/routes/cancers.ts
+421 −0
@@ -0,0 +1,421 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 5 | +import { paginate } from '../lib/envelope.js'; | |
| 6 | +import { ancestorChain, descendantIds } from '../lib/descendants.js'; | |
| 7 | +import { boolQuery, pageQuery } from '../lib/pagination.js'; | |
| 8 | +import { resolveCancer } from '../lib/resolve.js'; | |
| 9 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 10 | +import { cancerStructuralSourceIds, pluck } from '../lib/sources.js'; | |
| 11 | + | |
| 12 | +const idParam = z.object({ id: z.string().min(1).describe('CI-CAN-… identifier or slug') }); | |
| 13 | + | |
| 14 | +const listQuery = z.object({ | |
| 15 | + q: z.string().trim().min(1).max(200).optional().describe('Name/alias prefix filter'), | |
| 16 | + level: z.enum(['top', 'all']).default('all').describe('top = the mutually exclusive global ranking set (§247)'), | |
| 17 | + type: z.string().optional().describe('entity_type filter (cancer, subtype, hematologic_malignancy, …)'), | |
| 18 | + malignant: boolQuery, | |
| 19 | + hematologic: boolQuery, | |
| 20 | + pediatric: boolQuery, | |
| 21 | + rare: boolQuery, | |
| 22 | + sort: z.enum(['name', 'active_trials', 'publications_5y']).default('name'), | |
| 23 | + ...pageQuery, | |
| 24 | +}); | |
| 25 | + | |
| 26 | +/** Column list shared by list + detail: entity + entity_counters. */ | |
| 27 | +const CANCER_COLS = sql` | |
| 28 | + c.id, c.slug, c.canonical_name, c.short_name, c.entity_type, c.malignant, c.solid_tumor, c.hematologic, c.pediatric_relevant, | |
| 29 | + c.rare_cancer, c.top_level, c.depth, c.primary_ncit_code, c.primary_oncotree_code, c.status, c.classification_version, c.semantic_types, c.updated_at, | |
| 30 | + ec.trial_count, ec.active_trial_count, ec.recruiting_trial_count, ec.phase3_trial_count, ec.publication_count, ec.publication_count_5y, | |
| 31 | + ec.publication_count_12m, ec.gene_count, ec.variant_count, ec.drug_count, ec.approved_drug_count, ec.evidence_count, ec.cohort_count, | |
| 32 | + ec.subtype_count, ec.descendant_count, ec.epidemiology_obs_count, ec.survival_obs_count, ec.completeness, ec.updated_at AS counters_computed_at`; | |
| 33 | + | |
| 34 | +function shapeCancer(row: Record<string, unknown>) { | |
| 35 | + const r = camel<Record<string, unknown>>(row); | |
| 36 | + const counters = r.countersComputedAt | |
| 37 | + ? { | |
| 38 | + trials: num(r.trialCount), | |
| 39 | + activeTrials: num(r.activeTrialCount), | |
| 40 | + recruitingTrials: num(r.recruitingTrialCount), | |
| 41 | + phase3Trials: num(r.phase3TrialCount), | |
| 42 | + publications: num(r.publicationCount), | |
| 43 | + publications5y: num(r.publicationCount5y), | |
| 44 | + publications12m: num(r.publicationCount12m), | |
| 45 | + genes: num(r.geneCount), | |
| 46 | + variants: num(r.variantCount), | |
| 47 | + drugs: num(r.drugCount), | |
| 48 | + approvedDrugs: num(r.approvedDrugCount), | |
| 49 | + evidenceItems: num(r.evidenceCount), | |
| 50 | + cohorts: num(r.cohortCount), | |
| 51 | + subtypes: num(r.subtypeCount), | |
| 52 | + descendants: num(r.descendantCount), | |
| 53 | + epidemiologyObservations: num(r.epidemiologyObsCount), | |
| 54 | + survivalObservations: num(r.survivalObsCount), | |
| 55 | + computedAt: r.countersComputedAt, | |
| 56 | + } | |
| 57 | + : null; // counters not yet computed → "Data not yet available", never zeros (CLAUDE.md §281) | |
| 58 | + return { | |
| 59 | + id: r.id, | |
| 60 | + slug: r.slug, | |
| 61 | + name: r.canonicalName, | |
| 62 | + shortName: r.shortName, | |
| 63 | + entityType: r.entityType, | |
| 64 | + malignant: r.malignant, | |
| 65 | + solidTumor: r.solidTumor, | |
| 66 | + hematologic: r.hematologic, | |
| 67 | + pediatricRelevant: r.pediatricRelevant, | |
| 68 | + rareCancer: r.rareCancer, | |
| 69 | + topLevel: r.topLevel, | |
| 70 | + depth: r.depth, | |
| 71 | + primaryNcitCode: r.primaryNcitCode, | |
| 72 | + primaryOncotreeCode: r.primaryOncotreeCode, | |
| 73 | + status: r.status, | |
| 74 | + classificationVersion: r.classificationVersion, | |
| 75 | + semanticTypes: r.semanticTypes, | |
| 76 | + updatedAt: r.updatedAt, | |
| 77 | + counters, | |
| 78 | + completeness: (r.completeness as Record<string, number> | null) ?? null, | |
| 79 | + }; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export const cancerRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 83 | + app.get('/cancers', { schema: { tags: ['cancers'], summary: 'List cancer entities with counters', querystring: listQuery, response: ok(AnyList, true) } }, async (req) => { | |
| 84 | + const q = req.query; | |
| 85 | + const conds = [sql`c.status = 'active'`]; | |
| 86 | + if (q.level === 'top') conds.push(sql`c.top_level`); | |
| 87 | + if (q.type) conds.push(sql`c.entity_type = ${q.type}`); | |
| 88 | + if (q.malignant !== undefined) conds.push(sql`c.malignant = ${q.malignant}`); | |
| 89 | + if (q.hematologic !== undefined) conds.push(sql`c.hematologic = ${q.hematologic}`); | |
| 90 | + if (q.pediatric !== undefined) conds.push(sql`c.pediatric_relevant = ${q.pediatric}`); | |
| 91 | + if (q.rare !== undefined) conds.push(sql`c.rare_cancer = ${q.rare}`); | |
| 92 | + if (q.q) { | |
| 93 | + const norm = normalizeLabel(q.q); | |
| 94 | + conds.push(sql`EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND (a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'} OR a.normalized LIKE ${'% ' + norm + '%'}))`); | |
| 95 | + } | |
| 96 | + const where = sql.join(conds, sql` AND `); | |
| 97 | + const order = | |
| 98 | + q.sort === 'active_trials' ? sql`ec.active_trial_count DESC NULLS LAST, c.canonical_name` : q.sort === 'publications_5y' ? sql`ec.publication_count_5y DESC NULLS LAST, c.canonical_name` : sql`c.canonical_name`; | |
| 99 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 100 | + SELECT ${CANCER_COLS}, count(*) OVER() AS total | |
| 101 | + FROM cancers c LEFT JOIN entity_counters ec ON ec.entity_type = 'cancer' AND ec.entity_id = c.id | |
| 102 | + WHERE ${where} ORDER BY ${order} LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 103 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 104 | + const data = rows.map(shapeCancer); | |
| 105 | + const sources = await cancerStructuralSourceIds( | |
| 106 | + app.db, | |
| 107 | + data.map((d) => d.id as string), | |
| 108 | + ); | |
| 109 | + return respond(app, data, sources, paginate(total, q.limit, q.offset)); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + app.get('/cancers/:id', { schema: { tags: ['cancers'], summary: 'Cancer entity: aliases, codes, hierarchy, anatomy, counters, current rankings, change history', params: idParam, response: ok(AnyRecord) } }, async (req) => { | |
| 113 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 114 | + const db = app.db; | |
| 115 | + const [entityRows, aliases, codes, parents, children, anatomy, rankings, changes, breadcrumbs] = await Promise.all([ | |
| 116 | + db.execute<Record<string, unknown>>(sql`SELECT ${CANCER_COLS}, c.description, c.description_provenance_id, c.merged_into, c.deprecated_reason | |
| 117 | + FROM cancers c LEFT JOIN entity_counters ec ON ec.entity_type = 'cancer' AND ec.entity_id = c.id WHERE c.id = ${id}`), | |
| 118 | + db.execute<Record<string, unknown>>(sql`SELECT alias, alias_type, source_id, source_terminology, language FROM cancer_aliases WHERE cancer_id = ${id} ORDER BY CASE alias_type WHEN 'preferred' THEN 0 WHEN 'display' THEN 1 WHEN 'abbreviation' THEN 2 ELSE 3 END, alias`), | |
| 119 | + db.execute<Record<string, unknown>>(sql`SELECT system, code, match_type, source_id, valid_from, valid_to FROM cancer_codes WHERE cancer_id = ${id} ORDER BY system, code`), | |
| 120 | + db.execute<Record<string, unknown>>(sql`SELECT h.parent_id AS id, c.slug, c.canonical_name AS name, c.entity_type, h.hierarchy_type, h.source_id FROM cancer_hierarchy h JOIN cancers c ON c.id = h.parent_id WHERE h.child_id = ${id} ORDER BY h.hierarchy_type, c.canonical_name`), | |
| 121 | + db.execute<Record<string, unknown>>(sql`SELECT h.child_id AS id, c.slug, c.canonical_name AS name, c.entity_type, c.top_level, h.hierarchy_type, h.source_id, ec.active_trial_count | |
| 122 | + FROM cancer_hierarchy h JOIN cancers c ON c.id = h.child_id LEFT JOIN entity_counters ec ON ec.entity_type = 'cancer' AND ec.entity_id = c.id | |
| 123 | + WHERE h.parent_id = ${id} AND c.status = 'active' ORDER BY h.hierarchy_type, c.canonical_name LIMIT 500`), | |
| 124 | + db.execute<Record<string, unknown>>(sql`SELECT s.id, s.slug, s.name, s.system, s.ncit_code, s.uberon_id, a.relation, a.source_id FROM cancer_anatomy a JOIN anatomical_sites s ON s.id = a.site_id WHERE a.cancer_id = ${id}`), | |
| 125 | + db.execute<Record<string, unknown>>(sql`SELECT r.id AS ranking_id, r.metric_slug, m.name AS metric_name, m.unit, m.formula_version, m.category, r.rank, r.previous_rank, r.eligible_entities, r.percentile, r.value, r.confidence, | |
| 126 | + s.scope_key, s.geography, s.sex, s.age_group, s.year, s.entity_level, s.inputs_hash, s.generated_at, s.source_ids | |
| 127 | + FROM rankings r JOIN ranking_snapshots s ON s.id = r.snapshot_id JOIN metric_definitions m ON m.slug = r.metric_slug | |
| 128 | + WHERE r.cancer_id = ${id} AND s.is_current ORDER BY s.entity_level, m.category, r.metric_slug, s.scope_key`), | |
| 129 | + db.execute<Record<string, unknown>>(sql`SELECT id, kind, summary, before, after, ingest_run_id, created_at FROM change_events WHERE entity_type = 'cancer' AND entity_id = ${id} ORDER BY created_at DESC LIMIT 20`), | |
| 130 | + ancestorChain(db, id, 'ncit'), | |
| 131 | + ]); | |
| 132 | + const entityRow = entityRows[0]!; | |
| 133 | + const entity = shapeCancer(entityRow); | |
| 134 | + let descriptionProvenance: Record<string, unknown> | null = null; | |
| 135 | + if (entityRow.description_provenance_id) { | |
| 136 | + const p = await db.execute<Record<string, unknown>>(sql`SELECT p.id, p.source_id, s.slug AS source_slug, p.source_url, p.dataset, p.dataset_version, p.retrieved_at, p.evidence_type, p.license FROM provenance p JOIN sources s ON s.id = p.source_id WHERE p.id = ${Number(entityRow.description_provenance_id)}`); | |
| 137 | + descriptionProvenance = p[0] ? camel(p[0]) : null; | |
| 138 | + } | |
| 139 | + const rankingRows = camelRows(rankings); | |
| 140 | + const data = { | |
| 141 | + ...entity, | |
| 142 | + description: entityRow.description ?? null, | |
| 143 | + descriptionProvenance, | |
| 144 | + mergedInto: entityRow.merged_into ?? null, | |
| 145 | + deprecatedReason: entityRow.deprecated_reason ?? null, | |
| 146 | + aliases: camelRows(aliases), | |
| 147 | + codes: camelRows(codes), | |
| 148 | + hierarchy: { parents: camelRows(parents), children: camelRows(children), breadcrumbs }, | |
| 149 | + anatomy: camelRows(anatomy), | |
| 150 | + rankings: rankingRows, | |
| 151 | + changes: camelRows(changes), | |
| 152 | + }; | |
| 153 | + const sourceRefs = [ | |
| 154 | + ...(await cancerStructuralSourceIds(db, [id])), | |
| 155 | + ...pluck(anatomy, 'source_id'), | |
| 156 | + ...rankings.flatMap((r) => (r.source_ids as string[]) ?? []), | |
| 157 | + ...(descriptionProvenance ? [descriptionProvenance.sourceId as string] : []), | |
| 158 | + ]; | |
| 159 | + return respond(app, data, sourceRefs); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + app.get('/cancers/:id/statistics', { schema: { tags: ['cancers'], summary: 'Epidemiology observations (time-aware) with per-row provenance', params: idParam, querystring: z.object({ metric: z.string().optional(), geography: z.string().optional().describe('geography slug or ISO3'), sex: z.enum(['all', 'male', 'female']).optional(), ...pageQuery }), response: ok(AnyRecord, true) } }, async (req) => { | |
| 163 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 164 | + const q = req.query; | |
| 165 | + const conds = [sql`o.cancer_id = ${id}`]; | |
| 166 | + if (q.metric) conds.push(sql`o.metric = ${q.metric}`); | |
| 167 | + if (q.sex) conds.push(sql`o.sex = ${q.sex}`); | |
| 168 | + if (q.geography) conds.push(sql`(g.slug = ${q.geography.toLowerCase()} OR g.iso3 = ${q.geography.toUpperCase()})`); | |
| 169 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 170 | + SELECT o.id, o.metric, o.year, o.year_end, o.sex, o.age_group, o.value, o.unit, o.lower_ci, o.upper_ci, o.standard_population, o.estimate_type, o.site_definition, | |
| 171 | + g.id AS geography_id, g.slug AS geography_slug, g.name AS geography_name, g.iso3, g.kind AS geography_kind, | |
| 172 | + o.source_id, s.slug AS source_slug, s.name AS source_name, p.id AS provenance_id, p.dataset, p.dataset_version, p.retrieved_at, p.source_url, p.methodology, p.population, o.ingest_run_id, | |
| 173 | + count(*) OVER() AS total | |
| 174 | + FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id JOIN sources s ON s.id = o.source_id LEFT JOIN provenance p ON p.id = o.provenance_id | |
| 175 | + WHERE ${sql.join(conds, sql` AND `)} | |
| 176 | + ORDER BY o.metric, g.name, o.sex, o.age_group, o.year LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 177 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 178 | + const observations = rows.map((r) => ({ | |
| 179 | + id: r.id, | |
| 180 | + metric: r.metric, | |
| 181 | + year: r.year, | |
| 182 | + yearEnd: r.year_end, | |
| 183 | + sex: r.sex, | |
| 184 | + ageGroup: r.age_group, | |
| 185 | + value: r.value, | |
| 186 | + unit: r.unit, | |
| 187 | + lowerCi: r.lower_ci, | |
| 188 | + upperCi: r.upper_ci, | |
| 189 | + standardPopulation: r.standard_population, | |
| 190 | + estimateType: r.estimate_type, | |
| 191 | + siteDefinition: r.site_definition, | |
| 192 | + geography: { id: r.geography_id, slug: r.geography_slug, name: r.geography_name, iso3: r.iso3, kind: r.geography_kind }, | |
| 193 | + provenance: { sourceId: r.source_id, sourceSlug: r.source_slug, sourceName: r.source_name, provenanceId: r.provenance_id, dataset: r.dataset, datasetVersion: r.dataset_version, retrievedAt: r.retrieved_at, url: r.source_url, methodology: r.methodology, population: r.population, ingestRunId: r.ingest_run_id }, | |
| 194 | + })); | |
| 195 | + // Series grouped by metric × geography × sex × age group (points ordered by year) for charting. | |
| 196 | + const series = new Map<string, { metric: unknown; unit: unknown; geography: unknown; sex: unknown; ageGroup: unknown; sourceSlug: unknown; points: Array<Record<string, unknown>> }>(); | |
| 197 | + for (const o of observations) { | |
| 198 | + const key = `${o.metric}|${o.geography.id}|${o.sex}|${o.ageGroup}|${o.provenance.sourceSlug}`; | |
| 199 | + if (!series.has(key)) series.set(key, { metric: o.metric, unit: o.unit, geography: o.geography, sex: o.sex, ageGroup: o.ageGroup, sourceSlug: o.provenance.sourceSlug, points: [] }); | |
| 200 | + series.get(key)!.points.push({ year: o.year, yearEnd: o.yearEnd, value: o.value, lowerCi: o.lowerCi, upperCi: o.upperCi, estimateType: o.estimateType, observationId: o.id }); | |
| 201 | + } | |
| 202 | + return respond(app, { cancerId: id, observations, series: [...series.values()] }, pluck(rows, 'source_id'), paginate(total, q.limit, q.offset)); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + app.get('/cancers/:id/survival', { schema: { tags: ['cancers'], summary: 'Survival observations (population statistics, not individual prognosis) with provenance', params: idParam, querystring: z.object(pageQuery), response: ok(AnyRecord, true) } }, async (req) => { | |
| 206 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 207 | + const q = req.query; | |
| 208 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 209 | + SELECT o.id, o.stage, o.staging_system, o.sex, o.age_group, o.diagnosis_period, o.survival_type, o.duration_months, o.probability, o.median_months, o.cohort_size, o.lower_ci, o.upper_ci, o.method, | |
| 210 | + g.id AS geography_id, g.slug AS geography_slug, g.name AS geography_name, g.iso3, | |
| 211 | + o.source_id, s.slug AS source_slug, s.name AS source_name, p.id AS provenance_id, p.dataset, p.dataset_version, p.retrieved_at, p.source_url, p.methodology, p.population, o.ingest_run_id, | |
| 212 | + count(*) OVER() AS total | |
| 213 | + FROM survival_observations o LEFT JOIN geographies g ON g.id = o.geography_id JOIN sources s ON s.id = o.source_id LEFT JOIN provenance p ON p.id = o.provenance_id | |
| 214 | + WHERE o.cancer_id = ${id} | |
| 215 | + ORDER BY o.survival_type, o.stage NULLS FIRST, o.duration_months, o.diagnosis_period LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 216 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 217 | + const observations = rows.map((r) => ({ | |
| 218 | + id: r.id, | |
| 219 | + survivalType: r.survival_type, | |
| 220 | + durationMonths: r.duration_months, | |
| 221 | + probability: r.probability, | |
| 222 | + medianMonths: r.median_months, | |
| 223 | + cohortSize: r.cohort_size, | |
| 224 | + lowerCi: r.lower_ci, | |
| 225 | + upperCi: r.upper_ci, | |
| 226 | + stage: r.stage, | |
| 227 | + stagingSystem: r.staging_system, | |
| 228 | + sex: r.sex, | |
| 229 | + ageGroup: r.age_group, | |
| 230 | + diagnosisPeriod: r.diagnosis_period, | |
| 231 | + method: r.method, | |
| 232 | + geography: r.geography_id ? { id: r.geography_id, slug: r.geography_slug, name: r.geography_name, iso3: r.iso3 } : null, | |
| 233 | + provenance: { sourceId: r.source_id, sourceSlug: r.source_slug, sourceName: r.source_name, provenanceId: r.provenance_id, dataset: r.dataset, datasetVersion: r.dataset_version, retrievedAt: r.retrieved_at, url: r.source_url, methodology: r.methodology, population: r.population, ingestRunId: r.ingest_run_id }, | |
| 234 | + })); | |
| 235 | + return respond(app, { cancerId: id, observations, disclaimer: 'Population survival statistics describe groups of patients diagnosed in the past; they do not predict an individual outcome.' }, pluck(rows, 'source_id'), paginate(total, q.limit, q.offset)); | |
| 236 | + }); | |
| 237 | + | |
| 238 | + app.get('/cancers/:id/genes', { schema: { tags: ['cancers'], summary: 'Genes: cohort alteration frequencies (with denominators) and curated CIViC evidence, for the cancer and its descendants', params: idParam, querystring: z.object({ minFrequency: z.coerce.number().min(0).max(1).optional(), ...pageQuery }), response: ok(AnyRecord, true) } }, async (req) => { | |
| 239 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 240 | + const ids = await descendantIds(app.db, id); | |
| 241 | + const q = req.query; | |
| 242 | + const [freq, civic] = await Promise.all([ | |
| 243 | + app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 244 | + SELECT f.id, f.gene_id, f.gene_symbol, g.name AS gene_name, f.alteration_type, f.cases_affected, f.cases_profiled, f.frequency, f.rank, f.data_release, f.cancer_id, | |
| 245 | + c.id AS cohort_id, c.study_id, c.name AS cohort_name, c.program, c.case_count, c.cases_with_ssm, c.url AS cohort_url, c.source_id, c.cancer_match_type, | |
| 246 | + p.id AS provenance_id, p.dataset, p.dataset_version, p.retrieved_at, p.source_url, count(*) OVER() AS total | |
| 247 | + FROM cancer_gene_frequencies f JOIN genomic_cohorts c ON c.id = f.cohort_id LEFT JOIN genes g ON g.id = f.gene_id LEFT JOIN provenance p ON p.id = f.provenance_id | |
| 248 | + WHERE f.cancer_id = ANY(${sql.param(ids)}::text[]) ${q.minFrequency !== undefined ? sql`AND f.frequency >= ${q.minFrequency}` : sql``} | |
| 249 | + ORDER BY f.frequency DESC, f.gene_symbol LIMIT ${q.limit} OFFSET ${q.offset}`), | |
| 250 | + app.db.execute<Record<string, unknown>>(sql` | |
| 251 | + SELECT gs AS gene_symbol, g.id AS gene_id, g.name AS gene_name, count(*) AS evidence_items, | |
| 252 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, | |
| 253 | + count(*) FILTER (WHERE e.evidence_type = 'PREDICTIVE') AS predictive, count(*) FILTER (WHERE e.evidence_type = 'PROGNOSTIC') AS prognostic, count(*) FILTER (WHERE e.evidence_type = 'DIAGNOSTIC') AS diagnostic, count(*) FILTER (WHERE e.evidence_type = 'PREDISPOSING') AS predisposing, | |
| 254 | + array_agg(DISTINCT e.cancer_id) AS cancer_ids, min(p.source_id) AS source_id, max(p.retrieved_at) AS retrieved_at, array_agg(DISTINCT e.civic_id ORDER BY e.civic_id) AS civic_ids | |
| 255 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.gene_symbols) gs LEFT JOIN genes g ON upper(g.symbol) = upper(gs) LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 256 | + WHERE e.status = 'ACCEPTED' AND e.cancer_id = ANY(${sql.param(ids)}::text[]) | |
| 257 | + GROUP BY gs, g.id, g.name ORDER BY evidence_items DESC, gs LIMIT 500`), | |
| 258 | + ]); | |
| 259 | + const total = freq.length ? num(freq[0]!.total) : 0; | |
| 260 | + const frequencies = freq.map((r) => ({ | |
| 261 | + id: r.id, | |
| 262 | + gene: { id: r.gene_id, symbol: r.gene_symbol, name: r.gene_name }, | |
| 263 | + alterationType: r.alteration_type, | |
| 264 | + casesAffected: r.cases_affected, | |
| 265 | + casesProfiled: r.cases_profiled, | |
| 266 | + frequency: r.frequency, | |
| 267 | + rank: r.rank, | |
| 268 | + cancerId: r.cancer_id, | |
| 269 | + cohort: { id: r.cohort_id, studyId: r.study_id, name: r.cohort_name, program: r.program, caseCount: r.case_count, casesWithSsm: r.cases_with_ssm, url: r.cohort_url, cancerMatchType: r.cancer_match_type }, | |
| 270 | + provenance: { sourceId: r.source_id, provenanceId: r.provenance_id, dataset: r.dataset, datasetVersion: r.data_release ?? r.dataset_version, retrievedAt: r.retrieved_at, url: r.source_url }, | |
| 271 | + })); | |
| 272 | + const curated = civic.map((r) => ({ | |
| 273 | + gene: { id: r.gene_id, symbol: r.gene_symbol, name: r.gene_name }, | |
| 274 | + evidenceItems: num(r.evidence_items), | |
| 275 | + byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c) }, | |
| 276 | + byType: { predictive: num(r.predictive), prognostic: num(r.prognostic), diagnostic: num(r.diagnostic), predisposing: num(r.predisposing) }, | |
| 277 | + cancerIds: r.cancer_ids, | |
| 278 | + civicEvidenceIds: r.civic_ids, | |
| 279 | + provenance: { sourceId: r.source_id, retrievedAt: r.retrieved_at, category: 'curated_evidence' }, | |
| 280 | + })); | |
| 281 | + return respond(app, { cancerId: id, includesDescendants: ids.length - 1, cohortFrequencies: frequencies, curatedEvidence: curated }, [...pluck(freq, 'source_id'), ...pluck(civic, 'source_id')], paginate(total, q.limit, q.offset)); | |
| 282 | + }); | |
| 283 | + | |
| 284 | + app.get('/cancers/:id/variants', { schema: { tags: ['cancers'], summary: 'Variants with curated evidence counts for the cancer and its descendants', params: idParam, querystring: z.object(pageQuery), response: ok(AnyList, true) } }, async (req) => { | |
| 285 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 286 | + const ids = await descendantIds(app.db, id); | |
| 287 | + const q = req.query; | |
| 288 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 289 | + SELECT v.id, v.slug, v.name, v.gene_symbol, v.gene_id, v.variant_type, v.hgvs_p, v.hgvs_c, v.clinvar_variation_id, v.civic_variant_id, | |
| 290 | + count(*) AS evidence_items, | |
| 291 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, count(*) FILTER (WHERE e.evidence_level IN ('D','E')) AS level_de, | |
| 292 | + count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not_support, | |
| 293 | + count(*) FILTER (WHERE e.evidence_type = 'PREDICTIVE') AS predictive, count(*) FILTER (WHERE e.evidence_type = 'PROGNOSTIC') AS prognostic, count(*) FILTER (WHERE e.evidence_type = 'DIAGNOSTIC') AS diagnostic, | |
| 294 | + array_agg(DISTINCT e.cancer_id) AS cancer_ids, min(p.source_id) AS source_id, count(*) OVER() AS total | |
| 295 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.variant_ids) vid JOIN variants v ON v.id = vid LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 296 | + WHERE e.status = 'ACCEPTED' AND e.cancer_id = ANY(${sql.param(ids)}::text[]) | |
| 297 | + GROUP BY v.id ORDER BY evidence_items DESC, v.gene_symbol, v.name LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 298 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 299 | + const data = rows.map((r) => ({ | |
| 300 | + id: r.id, | |
| 301 | + slug: r.slug, | |
| 302 | + name: r.name, | |
| 303 | + gene: { id: r.gene_id, symbol: r.gene_symbol }, | |
| 304 | + variantType: r.variant_type, | |
| 305 | + hgvsP: r.hgvs_p, | |
| 306 | + hgvsC: r.hgvs_c, | |
| 307 | + clinvarVariationId: r.clinvar_variation_id, | |
| 308 | + civicVariantId: r.civic_variant_id, | |
| 309 | + evidence: { items: num(r.evidence_items), byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c), DE: num(r.level_de) }, byDirection: { supports: num(r.supports), doesNotSupport: num(r.does_not_support) }, byType: { predictive: num(r.predictive), prognostic: num(r.prognostic), diagnostic: num(r.diagnostic) }, category: 'curated_evidence' }, | |
| 310 | + cancerIds: r.cancer_ids, | |
| 311 | + })); | |
| 312 | + return respond(app, data, pluck(rows, 'source_id'), paginate(total, q.limit, q.offset)); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + app.get('/cancers/:id/drugs', { schema: { tags: ['cancers'], summary: 'Therapies: CIViC evidence counts (level/direction) and jurisdiction-aware approvals — never a bare "approved" flag (§13)', params: idParam, querystring: z.object(pageQuery), response: ok(AnyRecord, true) } }, async (req) => { | |
| 316 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 317 | + const ids = await descendantIds(app.db, id); | |
| 318 | + const q = req.query; | |
| 319 | + const [evidence, approvals] = await Promise.all([ | |
| 320 | + app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 321 | + SELECT d.id, d.slug, d.name, d.kind, d.ncit_code, d.chembl_id, d.mechanism, | |
| 322 | + count(*) AS evidence_items, | |
| 323 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, count(*) FILTER (WHERE e.evidence_level IN ('D','E')) AS level_de, | |
| 324 | + count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not_support, | |
| 325 | + count(*) FILTER (WHERE e.significance = 'SENSITIVITYRESPONSE') AS sensitivity, count(*) FILTER (WHERE e.significance = 'RESISTANCE') AS resistance, | |
| 326 | + array_agg(DISTINCT e.cancer_id) AS cancer_ids, array_agg(DISTINCT gs) FILTER (WHERE gs IS NOT NULL) AS gene_symbols, min(p.source_id) AS source_id, count(*) OVER() AS total | |
| 327 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid LEFT JOIN LATERAL unnest(e.gene_symbols) gs ON true LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 328 | + WHERE e.status = 'ACCEPTED' AND e.cancer_id = ANY(${sql.param(ids)}::text[]) | |
| 329 | + GROUP BY d.id ORDER BY evidence_items DESC, d.name LIMIT ${q.limit} OFFSET ${q.offset}`), | |
| 330 | + app.db.execute<Record<string, unknown>>(sql` | |
| 331 | + SELECT a.id, a.drug_id, d.slug AS drug_slug, d.name AS drug_name, a.cancer_id, a.biomarker_ids, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, a.line_of_therapy, a.disease_stage, a.approval_type, a.accelerated, a.conditional, | |
| 332 | + a.approval_date, a.withdrawal_date, a.status, a.application_number, a.source_id, p.source_url, p.retrieved_at, p.dataset_version | |
| 333 | + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN provenance p ON p.id = a.provenance_id | |
| 334 | + WHERE a.cancer_id = ANY(${sql.param(ids)}::text[]) OR a.tumor_agnostic | |
| 335 | + ORDER BY a.approval_date DESC NULLS LAST, d.name LIMIT 500`), | |
| 336 | + ]); | |
| 337 | + const total = evidence.length ? num(evidence[0]!.total) : 0; | |
| 338 | + const data = { | |
| 339 | + cancerId: id, | |
| 340 | + includesDescendants: ids.length - 1, | |
| 341 | + evidenceByDrug: evidence.map((r) => ({ | |
| 342 | + drug: { id: r.id, slug: r.slug, name: r.name, kind: r.kind, ncitCode: r.ncit_code, chemblId: r.chembl_id, mechanism: r.mechanism }, | |
| 343 | + evidence: { items: num(r.evidence_items), byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c), DE: num(r.level_de) }, byDirection: { supports: num(r.supports), doesNotSupport: num(r.does_not_support) }, bySignificance: { sensitivity: num(r.sensitivity), resistance: num(r.resistance) }, category: 'curated_evidence' }, | |
| 344 | + geneSymbols: r.gene_symbols ?? [], | |
| 345 | + cancerIds: r.cancer_ids, | |
| 346 | + })), | |
| 347 | + approvals: approvals.map((r) => ({ | |
| 348 | + id: r.id, | |
| 349 | + drug: { id: r.drug_id, slug: r.drug_slug, name: r.drug_name }, | |
| 350 | + cancerId: r.cancer_id, | |
| 351 | + tumorAgnostic: r.tumor_agnostic, | |
| 352 | + biomarkerIds: r.biomarker_ids, | |
| 353 | + jurisdiction: r.jurisdiction, | |
| 354 | + authority: r.authority, | |
| 355 | + indication: r.indication, | |
| 356 | + lineOfTherapy: r.line_of_therapy, | |
| 357 | + diseaseStage: r.disease_stage, | |
| 358 | + approvalType: r.approval_type, | |
| 359 | + accelerated: r.accelerated, | |
| 360 | + conditional: r.conditional, | |
| 361 | + approvalDate: r.approval_date, | |
| 362 | + withdrawalDate: r.withdrawal_date, | |
| 363 | + status: r.status, | |
| 364 | + applicationNumber: r.application_number, | |
| 365 | + provenance: { sourceId: r.source_id, url: r.source_url, retrievedAt: r.retrieved_at, datasetVersion: r.dataset_version, category: 'regulatory_status' }, | |
| 366 | + })), | |
| 367 | + }; | |
| 368 | + return respond(app, data, [...pluck(evidence, 'source_id'), ...pluck(approvals, 'source_id')], paginate(total, q.limit, q.offset)); | |
| 369 | + }); | |
| 370 | + | |
| 371 | + app.get('/cancers/:id/trials', { schema: { tags: ['cancers'], summary: 'Clinical trials mapped to the cancer or any descendant (recursive hierarchy, depth ≤ 12)', params: idParam, querystring: z.object({ status: z.string().optional().describe('ClinicalTrials.gov overall status, e.g. RECRUITING'), phase: z.string().optional().describe('PHASE1 | PHASE2 | PHASE3 | PHASE4 | EARLY_PHASE1 | NA'), interventionalOnly: boolQuery, ...pageQuery }), response: ok(AnyList, true) } }, async (req) => { | |
| 372 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 373 | + const ids = await descendantIds(app.db, id); | |
| 374 | + const q = req.query; | |
| 375 | + const conds = [sql`tc.cancer_id = ANY(${sql.param(ids)}::text[])`]; | |
| 376 | + if (q.status) conds.push(sql`t.overall_status = ${q.status.toUpperCase()}`); | |
| 377 | + if (q.phase) conds.push(sql`${q.phase.toUpperCase()} = ANY(t.phases)`); | |
| 378 | + if (q.interventionalOnly) conds.push(sql`t.study_type = 'INTERVENTIONAL'`); | |
| 379 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 380 | + SELECT t.id, t.nct_id, t.brief_title, t.acronym, t.study_type, t.phases, t.overall_status, t.start_date, t.primary_completion_date, t.last_update_posted_date, t.has_results, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.countries, t.locations_count, | |
| 381 | + array_agg(DISTINCT jsonb_build_object('cancerId', tc.cancer_id, 'condition', tc.condition_text, 'matchType', tc.match_type)) AS mapped_conditions, | |
| 382 | + sr.source_id, count(*) OVER() AS total | |
| 383 | + FROM clinical_trials t JOIN trial_conditions tc ON tc.trial_id = t.id LEFT JOIN source_records sr ON sr.id = t.source_record_id | |
| 384 | + WHERE ${sql.join(conds, sql` AND `)} | |
| 385 | + GROUP BY t.id, sr.source_id | |
| 386 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 387 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 388 | + const data = rows.map((r) => { | |
| 389 | + const { total: _t, source_id: _s, ...rest } = r; | |
| 390 | + return camel(rest); | |
| 391 | + }); | |
| 392 | + const srcs = pluck(rows, 'source_id'); | |
| 393 | + return respond(app, data, srcs.length ? srcs : ['clinicaltrials'], paginate(total, q.limit, q.offset)); | |
| 394 | + }); | |
| 395 | + | |
| 396 | + app.get('/cancers/:id/publications', { schema: { tags: ['cancers'], summary: 'Linked publications (entity edges with extraction method) and literature count windows (query stored verbatim)', params: idParam, querystring: z.object({ status: z.enum(['candidate', 'validated', 'all']).default('all'), ...pageQuery }), response: ok(AnyRecord, true) } }, async (req) => { | |
| 397 | + const { id } = await resolveCancer(app.db, req.params.id); | |
| 398 | + const q = req.query; | |
| 399 | + const [pubs, counts] = await Promise.all([ | |
| 400 | + app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 401 | + SELECT p.id, p.pmid, p.doi, p.pmcid, p.title, p.journal, p.pub_date, p.pub_year, p.publication_types, p.is_preprint, p.retracted, p.nct_ids, p.cited_by_count, | |
| 402 | + e.method, e.confidence, e.status AS edge_status, e.source_id, count(*) OVER() AS total | |
| 403 | + FROM publication_entity_edges e JOIN publications p ON p.id = e.publication_id | |
| 404 | + WHERE e.entity_type = 'cancer' AND e.entity_id = ${id} AND e.status <> 'rejected' ${q.status !== 'all' ? sql`AND e.status = ${q.status}` : sql``} | |
| 405 | + ORDER BY p.pub_year DESC NULLS LAST, p.pub_date DESC NULLS LAST, p.pmid LIMIT ${q.limit} OFFSET ${q.offset}`), | |
| 406 | + app.db.execute<Record<string, unknown>>(sql` | |
| 407 | + SELECT l.id, l.window_key, l.window_start, l.window_end, l.query, l.count, l.updated_at AS computed_at, p.source_id, p.retrieved_at, p.source_url | |
| 408 | + FROM literature_counts l LEFT JOIN provenance p ON p.id = l.provenance_id WHERE l.cancer_id = ${id} ORDER BY l.window_key`), | |
| 409 | + ]); | |
| 410 | + const total = pubs.length ? num(pubs[0]!.total) : 0; | |
| 411 | + const data = { | |
| 412 | + cancerId: id, | |
| 413 | + literatureCounts: counts.map((r) => ({ id: r.id, windowKey: r.window_key, windowStart: r.window_start, windowEnd: r.window_end, query: r.query, count: r.count, computedAt: r.computed_at, provenance: { sourceId: r.source_id, retrievedAt: r.retrieved_at, url: r.source_url, category: 'computed_metric' } })), | |
| 414 | + publications: pubs.map((r) => { | |
| 415 | + const { total: _t, source_id, method, confidence, edge_status, ...rest } = r; | |
| 416 | + return { ...camel(rest), edge: { method, confidence, status: edge_status, sourceId: source_id } }; | |
| 417 | + }), | |
| 418 | + }; | |
| 419 | + return respond(app, data, [...pluck(pubs, 'source_id'), ...pluck(counts, 'source_id')], paginate(total, q.limit, q.offset)); | |
| 420 | + }); | |
| 421 | +}; | |
added
apps/api/src/routes/changes.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { paginate } from '../lib/envelope.js'; | |
| 5 | +import { pageQuery } from '../lib/pagination.js'; | |
| 6 | +import { AnyList, camel, num, ok, respond } from '../lib/respond.js'; | |
| 7 | + | |
| 8 | +export const changeRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 9 | + app.get('/changes', { schema: { tags: ['changes'], summary: 'Entity change history (created/updated/trial_added/approval_added/ranking_changed/merged/deprecated)', querystring: z.object({ entityType: z.string().optional(), entityId: z.string().optional(), kind: z.string().optional(), since: z.string().optional().describe('ISO date lower bound'), ...pageQuery }), response: ok(AnyList, true) } }, async (req) => { | |
| 10 | + const q = req.query; | |
| 11 | + const conds = [sql`true`]; | |
| 12 | + if (q.entityType) conds.push(sql`e.entity_type = ${q.entityType}`); | |
| 13 | + if (q.entityId) conds.push(sql`e.entity_id = ${q.entityId}`); | |
| 14 | + if (q.kind) conds.push(sql`e.kind = ${q.kind}`); | |
| 15 | + if (q.since) conds.push(sql`e.created_at >= ${q.since}::timestamptz`); | |
| 16 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 17 | + SELECT e.id, e.entity_type, e.entity_id, e.kind, e.summary, e.before, e.after, e.ingest_run_id, e.created_at, r.connector_id, r.source_id, | |
| 18 | + CASE e.entity_type WHEN 'cancer' THEN (SELECT canonical_name FROM cancers WHERE id = e.entity_id) WHEN 'gene' THEN (SELECT symbol FROM genes WHERE id = e.entity_id) WHEN 'drug' THEN (SELECT name FROM drugs WHERE id = e.entity_id) WHEN 'trial' THEN (SELECT nct_id FROM clinical_trials WHERE id = e.entity_id) END AS entity_name, | |
| 19 | + count(*) OVER() AS total | |
| 20 | + FROM change_events e LEFT JOIN ingest_runs r ON r.id = e.ingest_run_id | |
| 21 | + WHERE ${sql.join(conds, sql` AND `)} ORDER BY e.created_at DESC, e.id DESC LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 22 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 23 | + const data = rows.map((r) => { | |
| 24 | + const { total: _t, source_id: _s, ...rest } = r; | |
| 25 | + return camel(rest); | |
| 26 | + }); | |
| 27 | + return respond( | |
| 28 | + app, | |
| 29 | + data, | |
| 30 | + rows.map((r) => r.source_id as string | null), | |
| 31 | + paginate(total, q.limit, q.offset), | |
| 32 | + ); | |
| 33 | + }); | |
| 34 | +}; | |
added
apps/api/src/routes/drugs.ts
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 5 | +import { paginate } from '../lib/envelope.js'; | |
| 6 | +import { pageQuery } from '../lib/pagination.js'; | |
| 7 | +import { resolveDrug } from '../lib/resolve.js'; | |
| 8 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 9 | +import { pluck } from '../lib/sources.js'; | |
| 10 | + | |
| 11 | +export const drugRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 12 | + app.get('/drugs', { schema: { tags: ['drugs'], summary: 'List drugs (generic/INN names; brands are aliases)', querystring: z.object({ q: z.string().trim().min(1).max(100).optional(), kind: z.string().optional(), ...pageQuery }), response: ok(AnyList, true) } }, async (req) => { | |
| 13 | + const q = req.query; | |
| 14 | + const conds = [sql`true`]; | |
| 15 | + if (q.kind) conds.push(sql`d.kind = ${q.kind}`); | |
| 16 | + if (q.q) { | |
| 17 | + const norm = normalizeLabel(q.q); | |
| 18 | + conds.push(sql`EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND (a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'}))`); | |
| 19 | + } | |
| 20 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 21 | + SELECT d.id, d.slug, d.name, d.kind, d.ncit_code, d.chembl_id, d.civic_therapy_id, d.drugbank_id, d.mechanism, d.development_status, | |
| 22 | + (SELECT count(*) FROM civic_evidence_items e WHERE e.status = 'ACCEPTED' AND d.id = ANY(e.therapy_ids)) AS evidence_items, | |
| 23 | + (SELECT count(*) FROM drug_approvals a WHERE a.drug_id = d.id AND a.status IN ('approved','accelerated','conditional')) AS approvals, | |
| 24 | + count(*) OVER() AS total | |
| 25 | + FROM drugs d WHERE ${sql.join(conds, sql` AND `)} ORDER BY d.name LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 26 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 27 | + const data = rows.map((r) => { | |
| 28 | + const { total: _t, ...rest } = r; | |
| 29 | + return camel(rest); | |
| 30 | + }); | |
| 31 | + const sourceRows = data.length ? await app.db.execute<{ source_id: string }>(sql`SELECT DISTINCT source_id FROM drug_aliases WHERE source_id IS NOT NULL AND drug_id = ANY(${sql.param(data.map((d) => d.id as string))}::text[])`) : []; | |
| 32 | + return respond(app, data, pluck(sourceRows, 'source_id'), paginate(total, q.limit, q.offset)); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + app.get('/drugs/:id', { schema: { tags: ['drugs'], summary: 'Drug: aliases, curated evidence grouped by cancer, jurisdiction-aware approvals, trials via interventions', params: z.object({ id: z.string().min(1).describe('CI-DRUG-… id or slug') }), response: ok(AnyRecord) } }, async (req) => { | |
| 36 | + const { id } = await resolveDrug(app.db, req.params.id); | |
| 37 | + const db = app.db; | |
| 38 | + const [drug, aliases, evidence, approvals, trials, targets] = await Promise.all([ | |
| 39 | + db.execute<Record<string, unknown>>(sql`SELECT * FROM drugs WHERE id = ${id}`), | |
| 40 | + db.execute<Record<string, unknown>>(sql`SELECT alias, alias_type, source_id FROM drug_aliases WHERE drug_id = ${id} ORDER BY CASE alias_type WHEN 'generic' THEN 0 WHEN 'brand' THEN 1 ELSE 2 END, alias`), | |
| 41 | + db.execute<Record<string, unknown>>(sql` | |
| 42 | + SELECT e.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, e.disease_name, count(*) AS evidence_items, | |
| 43 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, count(*) FILTER (WHERE e.evidence_level IN ('D','E')) AS level_de, | |
| 44 | + count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not_support, | |
| 45 | + count(*) FILTER (WHERE e.significance = 'SENSITIVITYRESPONSE') AS sensitivity, count(*) FILTER (WHERE e.significance = 'RESISTANCE') AS resistance, | |
| 46 | + array_agg(DISTINCT gs) FILTER (WHERE gs IS NOT NULL) AS gene_symbols, array_agg(DISTINCT e.civic_id) AS civic_ids, min(p.source_id) AS source_id | |
| 47 | + FROM civic_evidence_items e LEFT JOIN cancers c ON c.id = e.cancer_id LEFT JOIN LATERAL unnest(e.gene_symbols) gs ON true LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 48 | + WHERE e.status = 'ACCEPTED' AND ${id} = ANY(e.therapy_ids) | |
| 49 | + GROUP BY e.cancer_id, c.slug, c.canonical_name, e.disease_name ORDER BY evidence_items DESC`), | |
| 50 | + db.execute<Record<string, unknown>>(sql` | |
| 51 | + SELECT a.*, c.slug AS cancer_slug, c.canonical_name AS cancer_name, p.source_url, p.retrieved_at, p.dataset_version | |
| 52 | + FROM drug_approvals a LEFT JOIN cancers c ON c.id = a.cancer_id LEFT JOIN provenance p ON p.id = a.provenance_id | |
| 53 | + WHERE a.drug_id = ${id} ORDER BY a.approval_date DESC NULLS LAST`), | |
| 54 | + db.execute<Record<string, unknown>>(sql` | |
| 55 | + SELECT t.id, t.nct_id, t.brief_title, t.phases, t.overall_status, t.study_type, t.start_date, t.enrollment_count, t.lead_sponsor, ti.name AS intervention_name, ti.match_type | |
| 56 | + FROM trial_interventions ti JOIN clinical_trials t ON t.id = ti.trial_id WHERE ti.drug_id = ${id} | |
| 57 | + ORDER BY t.last_update_posted_date DESC NULLS LAST LIMIT 200`), | |
| 58 | + db.execute<Record<string, unknown>>(sql`SELECT g.id, g.symbol, g.name FROM drugs d CROSS JOIN LATERAL unnest(d.target_gene_ids) gid JOIN genes g ON g.id = gid WHERE d.id = ${id}`), | |
| 59 | + ]); | |
| 60 | + const data = { | |
| 61 | + ...camel(drug[0]!), | |
| 62 | + targets: camelRows(targets), | |
| 63 | + aliases: camelRows(aliases), | |
| 64 | + evidenceByCancer: evidence.map((r) => ({ | |
| 65 | + cancer: r.cancer_id ? { id: r.cancer_id, slug: r.cancer_slug, name: r.cancer_name } : null, | |
| 66 | + diseaseName: r.disease_name, | |
| 67 | + evidenceItems: num(r.evidence_items), | |
| 68 | + byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c), DE: num(r.level_de) }, | |
| 69 | + byDirection: { supports: num(r.supports), doesNotSupport: num(r.does_not_support) }, | |
| 70 | + bySignificance: { sensitivity: num(r.sensitivity), resistance: num(r.resistance) }, | |
| 71 | + geneSymbols: r.gene_symbols ?? [], | |
| 72 | + civicEvidenceIds: r.civic_ids, | |
| 73 | + sourceId: r.source_id, | |
| 74 | + category: 'curated_evidence', | |
| 75 | + })), | |
| 76 | + approvals: approvals.map((r) => { | |
| 77 | + const { cancer_slug, cancer_name, source_url, retrieved_at, dataset_version, raw: _raw, provenance_id: _p, ...rest } = r; | |
| 78 | + return { ...camel(rest), cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, provenance: { sourceId: r.source_id, url: source_url, retrievedAt: retrieved_at, datasetVersion: dataset_version, category: 'regulatory_status' } }; | |
| 79 | + }), | |
| 80 | + trials: camelRows(trials), | |
| 81 | + }; | |
| 82 | + return respond(app, data, [...pluck(aliases, 'source_id'), ...pluck(evidence, 'source_id'), ...pluck(approvals, 'source_id'), ...(trials.length ? ['clinicaltrials'] : [])]); | |
| 83 | + }); | |
| 84 | +}; | |
added
apps/api/src/routes/genes.ts
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { paginate } from '../lib/envelope.js'; | |
| 5 | +import { boolQuery, pageQuery } from '../lib/pagination.js'; | |
| 6 | +import { resolveGene } from '../lib/resolve.js'; | |
| 7 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 8 | +import { pluck } from '../lib/sources.js'; | |
| 9 | + | |
| 10 | +export const geneRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 11 | + app.get('/genes', { schema: { tags: ['genes'], summary: 'List genes (HGNC symbols)', querystring: z.object({ q: z.string().trim().min(1).max(50).optional().describe('Symbol/alias prefix'), cancerOnly: boolQuery.describe('Only genes with curated or cohort cancer evidence'), sort: z.enum(['symbol', 'evidence']).default('symbol'), ...pageQuery }), response: ok(AnyList, true) } }, async (req) => { | |
| 12 | + const q = req.query; | |
| 13 | + const conds = [sql`g.status = 'Approved'`]; | |
| 14 | + if (q.cancerOnly) conds.push(sql`g.is_cancer_gene`); | |
| 15 | + if (q.q) { | |
| 16 | + const up = q.q.toUpperCase(); | |
| 17 | + conds.push(sql`(upper(g.symbol) LIKE ${up + '%'} OR EXISTS (SELECT 1 FROM gene_aliases a WHERE a.gene_id = g.id AND upper(a.alias) LIKE ${up + '%'}))`); | |
| 18 | + } | |
| 19 | + const order = q.sort === 'evidence' ? sql`ec.evidence_count DESC NULLS LAST, g.symbol` : sql`g.symbol`; | |
| 20 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 21 | + SELECT g.id, g.hgnc_id, g.symbol, g.name, g.locus_type, g.location, g.chromosome, g.ensembl_gene_id, g.ncbi_gene_id, g.is_cancer_gene, g.civic_gene_id, | |
| 22 | + ec.evidence_count, ec.variant_count, ec.drug_count, ec.updated_at AS counters_computed_at, count(*) OVER() AS total | |
| 23 | + FROM genes g LEFT JOIN entity_counters ec ON ec.entity_type = 'gene' AND ec.entity_id = g.id | |
| 24 | + WHERE ${sql.join(conds, sql` AND `)} ORDER BY ${order} LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 25 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 26 | + const data = rows.map((r) => { | |
| 27 | + const { total: _t, ...rest } = r; | |
| 28 | + return camel(rest); | |
| 29 | + }); | |
| 30 | + return respond(app, data, ['hgnc'], paginate(total, q.limit, q.offset)); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + app.get('/genes/:symbol', { schema: { tags: ['genes'], summary: 'Gene: aliases, counters, linked cancers (curated evidence + cohort frequencies), variants, drugs, literature edges', params: z.object({ symbol: z.string().min(1).describe('HGNC symbol, alias, HGNC:id or CI-GENE-… id') }), response: ok(AnyRecord) } }, async (req) => { | |
| 34 | + const { id } = await resolveGene(app.db, req.params.symbol); | |
| 35 | + const db = app.db; | |
| 36 | + const [gene, aliases, civicCancers, cohortCancers, variants, drugs, pubs] = await Promise.all([ | |
| 37 | + db.execute<Record<string, unknown>>(sql`SELECT g.*, ec.evidence_count, ec.variant_count, ec.drug_count, ec.updated_at AS counters_computed_at | |
| 38 | + FROM genes g LEFT JOIN entity_counters ec ON ec.entity_type = 'gene' AND ec.entity_id = g.id WHERE g.id = ${id}`), | |
| 39 | + db.execute<Record<string, unknown>>(sql`SELECT alias, alias_type, source_id FROM gene_aliases WHERE gene_id = ${id} ORDER BY alias_type, alias`), | |
| 40 | + db.execute<Record<string, unknown>>(sql` | |
| 41 | + SELECT c.id, c.slug, c.canonical_name AS name, c.top_level, count(*) AS evidence_items, | |
| 42 | + count(*) FILTER (WHERE e.evidence_type = 'PREDICTIVE') AS predictive, count(*) FILTER (WHERE e.evidence_type = 'PROGNOSTIC') AS prognostic, | |
| 43 | + count(*) FILTER (WHERE e.evidence_type = 'DIAGNOSTIC') AS diagnostic, count(*) FILTER (WHERE e.evidence_type = 'PREDISPOSING') AS predisposing, | |
| 44 | + count(*) FILTER (WHERE e.evidence_level IN ('A','B')) AS level_ab, min(p.source_id) AS source_id | |
| 45 | + FROM civic_evidence_items e JOIN cancers c ON c.id = e.cancer_id LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 46 | + WHERE e.status = 'ACCEPTED' AND ${id} = ANY(e.gene_ids) | |
| 47 | + GROUP BY c.id ORDER BY evidence_items DESC, c.canonical_name LIMIT 200`), | |
| 48 | + db.execute<Record<string, unknown>>(sql` | |
| 49 | + SELECT f.id, f.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, f.alteration_type, f.cases_affected, f.cases_profiled, f.frequency, f.rank, | |
| 50 | + co.id AS cohort_id, co.study_id, co.name AS cohort_name, co.program, co.source_id, p.retrieved_at, p.source_url, p.dataset_version | |
| 51 | + FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id LEFT JOIN cancers c ON c.id = f.cancer_id LEFT JOIN provenance p ON p.id = f.provenance_id | |
| 52 | + WHERE f.gene_id = ${id} ORDER BY f.frequency DESC LIMIT 200`), | |
| 53 | + db.execute<Record<string, unknown>>(sql` | |
| 54 | + SELECT v.id, v.slug, v.name, v.variant_type, v.hgvs_p, v.hgvs_c, v.clinvar_variation_id, v.civic_variant_id, | |
| 55 | + (SELECT count(*) FROM civic_evidence_items e WHERE e.status = 'ACCEPTED' AND v.id = ANY(e.variant_ids)) AS evidence_items, | |
| 56 | + (SELECT cs.clinical_significance FROM variant_clinical_significance cs WHERE cs.variant_id = v.id LIMIT 1) AS clinical_significance | |
| 57 | + FROM variants v WHERE v.gene_id = ${id} ORDER BY evidence_items DESC, v.name LIMIT 500`), | |
| 58 | + db.execute<Record<string, unknown>>(sql` | |
| 59 | + SELECT d.id, d.slug, d.name, d.kind, count(*) AS evidence_items, | |
| 60 | + count(*) FILTER (WHERE e.significance = 'SENSITIVITYRESPONSE') AS sensitivity, count(*) FILTER (WHERE e.significance = 'RESISTANCE') AS resistance, | |
| 61 | + array_agg(DISTINCT e.cancer_id) FILTER (WHERE e.cancer_id IS NOT NULL) AS cancer_ids, min(p.source_id) AS source_id | |
| 62 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 63 | + WHERE e.status = 'ACCEPTED' AND ${id} = ANY(e.gene_ids) | |
| 64 | + GROUP BY d.id ORDER BY evidence_items DESC, d.name LIMIT 200`), | |
| 65 | + db.execute<Record<string, unknown>>(sql` | |
| 66 | + SELECT p.id, p.pmid, p.doi, p.title, p.journal, p.pub_year, p.retracted, e.method, e.confidence, e.status AS edge_status, e.source_id | |
| 67 | + FROM publication_entity_edges e JOIN publications p ON p.id = e.publication_id | |
| 68 | + WHERE e.entity_type = 'gene' AND e.entity_id = ${id} AND e.status <> 'rejected' ORDER BY p.pub_year DESC NULLS LAST LIMIT 100`), | |
| 69 | + ]); | |
| 70 | + const g = camel<Record<string, unknown>>(gene[0]!); | |
| 71 | + const counters = g.countersComputedAt ? { evidenceItems: num(g.evidenceCount), variants: num(g.variantCount), drugs: num(g.drugCount), computedAt: g.countersComputedAt } : null; | |
| 72 | + delete g.evidenceCount; | |
| 73 | + delete g.variantCount; | |
| 74 | + delete g.drugCount; | |
| 75 | + delete g.countersComputedAt; | |
| 76 | + const data = { | |
| 77 | + ...g, | |
| 78 | + counters, | |
| 79 | + aliases: camelRows(aliases), | |
| 80 | + cancers: { | |
| 81 | + curatedEvidence: civicCancers.map((r) => ({ cancer: { id: r.id, slug: r.slug, name: r.name, topLevel: r.top_level }, evidenceItems: num(r.evidence_items), byType: { predictive: num(r.predictive), prognostic: num(r.prognostic), diagnostic: num(r.diagnostic), predisposing: num(r.predisposing) }, levelAB: num(r.level_ab), sourceId: r.source_id, category: 'curated_evidence' })), | |
| 82 | + cohortFrequencies: cohortCancers.map((r) => ({ id: r.id, cancer: r.cancer_id ? { id: r.cancer_id, slug: r.cancer_slug, name: r.cancer_name } : null, alterationType: r.alteration_type, casesAffected: r.cases_affected, casesProfiled: r.cases_profiled, frequency: r.frequency, rank: r.rank, cohort: { id: r.cohort_id, studyId: r.study_id, name: r.cohort_name, program: r.program }, provenance: { sourceId: r.source_id, retrievedAt: r.retrieved_at, url: r.source_url, datasetVersion: r.dataset_version, category: 'observed_data' } })), | |
| 83 | + }, | |
| 84 | + variants: camelRows(variants), | |
| 85 | + drugs: drugs.map((r) => ({ drug: { id: r.id, slug: r.slug, name: r.name, kind: r.kind }, evidenceItems: num(r.evidence_items), sensitivity: num(r.sensitivity), resistance: num(r.resistance), cancerIds: r.cancer_ids ?? [], sourceId: r.source_id })), | |
| 86 | + publications: pubs.map((r) => { | |
| 87 | + const { method, confidence, edge_status, source_id, ...rest } = r; | |
| 88 | + return { ...camel(rest), edge: { method, confidence, status: edge_status, sourceId: source_id } }; | |
| 89 | + }), | |
| 90 | + }; | |
| 91 | + return respond(app, data, ['hgnc', ...pluck(civicCancers, 'source_id'), ...pluck(cohortCancers, 'source_id'), ...pluck(drugs, 'source_id'), ...pluck(pubs, 'source_id'), ...pluck(aliases, 'source_id')]); | |
| 92 | + }); | |
| 93 | +}; | |
added
apps/api/src/routes/health.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { dataRelease } from '../lib/envelope.js'; | |
| 4 | + | |
| 5 | +export const healthRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 6 | + // Root of the API host: a minimal, honest landing (used while the web app is not yet in front). | |
| 7 | + app.get('/', { schema: { hide: true }, config: { rateLimit: false } }, async (_req, reply) => { | |
| 8 | + reply.type('text/html; charset=utf-8'); | |
| 9 | + return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>CancerIndex API</title> | |
| 10 | +<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"> | |
| 11 | +<style>body{font:16px/1.5 -apple-system,Inter,Segoe UI,sans-serif;max-width:720px;margin:8vh auto;padding:0 24px;color:#1f2321;background:#fafaf7}h1{font-family:Georgia,serif;font-weight:500;letter-spacing:-.01em}code,a{color:#155e63}ul{padding-left:1.2em}small{color:#666}</style></head> | |
| 12 | +<body><h1>CancerIndex <small>— the global index of cancer</small></h1> | |
| 13 | +<p>This host serves the public API (v1). The web interface is being deployed.</p> | |
| 14 | +<ul><li><a href="/v1/docs">API documentation (OpenAPI)</a></li><li><a href="/v1/stats">/v1/stats</a> — live database counts</li><li><a href="/v1/cancers?level=top">/v1/cancers?level=top</a> — top-level cancer set</li><li><a href="/v1/search?q=glioblastoma">/v1/search?q=glioblastoma</a></li><li><a href="/v1/sources">/v1/sources</a> — source registry and license status</li><li><a href="/healthz">/healthz</a></li></ul> | |
| 15 | +<p><small>CancerIndex is a research and information platform. It is not a physician, does not diagnose, and does not recommend treatment.</small></p></body></html>`; | |
| 16 | + }); | |
| 17 | + app.get( | |
| 18 | + '/healthz', | |
| 19 | + { | |
| 20 | + schema: { | |
| 21 | + tags: ['system'], | |
| 22 | + summary: 'Liveness/readiness probe', | |
| 23 | + response: { 200: z.object({ ok: z.boolean(), dataRelease: z.string(), db: z.boolean(), service: z.string(), uptimeSeconds: z.number() }) }, | |
| 24 | + }, | |
| 25 | + config: { rateLimit: false }, | |
| 26 | + }, | |
| 27 | + async (_req, reply) => { | |
| 28 | + const db = await app.dbHealthy(); | |
| 29 | + reply.code(db ? 200 : 200); | |
| 30 | + return { ok: db, dataRelease: dataRelease(), db, service: 'cancerindex-api', uptimeSeconds: Math.round(process.uptime()) }; | |
| 31 | + }, | |
| 32 | + ); | |
| 33 | +}; | |
added
apps/api/src/routes/publications.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { resolvePublication } from '../lib/resolve.js'; | |
| 5 | +import { AnyRecord, camel, ok, respond } from '../lib/respond.js'; | |
| 6 | +import { pluck } from '../lib/sources.js'; | |
| 7 | + | |
| 8 | +export const publicationRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 9 | + app.get('/publications/:pmid', { schema: { tags: ['publications'], summary: 'Publication with entity edges (method, confidence, status). Abstracts are truncated; follow the PubMed link.', params: z.object({ pmid: z.string().min(1).describe('PMID or CI-PUB-… id') }), response: ok(AnyRecord) } }, async (req) => { | |
| 10 | + const { id } = await resolvePublication(app.db, req.params.pmid); | |
| 11 | + const [pub, edges, source] = await Promise.all([ | |
| 12 | + app.db.execute<Record<string, unknown>>(sql`SELECT * FROM publications WHERE id = ${id}`), | |
| 13 | + app.db.execute<Record<string, unknown>>(sql` | |
| 14 | + SELECT e.entity_type, e.entity_id, e.method, e.confidence, e.status, e.source_id, | |
| 15 | + CASE e.entity_type WHEN 'cancer' THEN (SELECT canonical_name FROM cancers WHERE id = e.entity_id) WHEN 'gene' THEN (SELECT symbol FROM genes WHERE id = e.entity_id) | |
| 16 | + WHEN 'drug' THEN (SELECT name FROM drugs WHERE id = e.entity_id) WHEN 'variant' THEN (SELECT name FROM variants WHERE id = e.entity_id) WHEN 'trial' THEN (SELECT nct_id FROM clinical_trials WHERE id = e.entity_id) END AS entity_name, | |
| 17 | + CASE e.entity_type WHEN 'cancer' THEN (SELECT slug FROM cancers WHERE id = e.entity_id) WHEN 'gene' THEN (SELECT symbol FROM genes WHERE id = e.entity_id) | |
| 18 | + WHEN 'drug' THEN (SELECT slug FROM drugs WHERE id = e.entity_id) WHEN 'variant' THEN (SELECT slug FROM variants WHERE id = e.entity_id) WHEN 'trial' THEN (SELECT nct_id FROM clinical_trials WHERE id = e.entity_id) END AS entity_slug | |
| 19 | + FROM publication_entity_edges e WHERE e.publication_id = ${id} ORDER BY e.entity_type, e.status, e.entity_id`), | |
| 20 | + app.db.execute<{ source_id: string; retrieved_at: string }>(sql`SELECT sr.source_id, sr.retrieved_at FROM publications p JOIN source_records sr ON sr.id = p.source_record_id WHERE p.id = ${id}`), | |
| 21 | + ]); | |
| 22 | + const p = camel<Record<string, unknown>>(pub[0]!); | |
| 23 | + const abstract = typeof p.abstract === 'string' ? p.abstract : null; | |
| 24 | + const data = { | |
| 25 | + ...p, | |
| 26 | + abstract: abstract && abstract.length > 600 ? `${abstract.slice(0, 600)}…` : abstract, | |
| 27 | + abstractTruncated: !!abstract && abstract.length > 600, | |
| 28 | + links: { pubmed: p.pmid ? `https://pubmed.ncbi.nlm.nih.gov/${p.pmid as string}/` : null, doi: p.doi ? `https://doi.org/${p.doi as string}` : null, pmc: p.pmcid ? `https://www.ncbi.nlm.nih.gov/pmc/articles/${p.pmcid as string}/` : null }, | |
| 29 | + provenance: source[0] ? { sourceId: source[0].source_id, retrievedAt: source[0].retrieved_at, category: 'published_evidence' } : null, | |
| 30 | + edges: edges.map((e) => camel(e)), | |
| 31 | + }; | |
| 32 | + return respond(app, data, [source[0]?.source_id ?? 'pubmed', ...pluck(edges, 'source_id')]); | |
| 33 | + }); | |
| 34 | +}; | |
added
apps/api/src/routes/rankings.ts
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { traceValue } from '@cancerindex/ranking'; | |
| 5 | +import { NotFound } from '../lib/errors.js'; | |
| 6 | +import { paginate } from '../lib/envelope.js'; | |
| 7 | +import { pageQuery } from '../lib/pagination.js'; | |
| 8 | +import { resolveCancer } from '../lib/resolve.js'; | |
| 9 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 10 | + | |
| 11 | +const scopeQuery = { | |
| 12 | + geography: z.string().default('WORLD').describe('WORLD or ISO3 / geography slug (upper-cased)'), | |
| 13 | + sex: z.enum(['all', 'male', 'female']).default('all'), | |
| 14 | + age: z.string().default('all').describe('Age group key (all)'), | |
| 15 | + year: z.coerce.number().int().optional().describe('Reference year; omitted = latest available (count metrics have no year)'), | |
| 16 | + level: z.enum(['top', 'all']).default('top').describe('top = mutually exclusive ranking set (§247); all = every active malignant entity'), | |
| 17 | +}; | |
| 18 | + | |
| 19 | +/** Pick the current snapshot for a metric + scope; without `year` prefer the most recent year, then year-less snapshots. */ | |
| 20 | +async function findSnapshot(app: Parameters<FastifyPluginAsyncZod>[0], metric: string, s: { geography: string; sex: string; age: string; year?: number; level: string }) { | |
| 21 | + const rows = await app.db.execute<Record<string, unknown>>(sql` | |
| 22 | + SELECT s.*, m.name AS metric_name, m.description AS metric_description, m.formula, m.unit, m.higher_is_worse, m.category, m.aggregation, m.eligibility, m.experimental, m.source_slugs | |
| 23 | + FROM ranking_snapshots s JOIN metric_definitions m ON m.slug = s.metric_slug | |
| 24 | + WHERE s.metric_slug = ${metric} AND s.is_current AND upper(s.geography) = ${s.geography.toUpperCase()} AND s.sex = ${s.sex} AND s.age_group = ${s.age} AND s.entity_level = ${s.level} | |
| 25 | + ${s.year !== undefined ? sql`AND s.year = ${s.year}` : sql``} | |
| 26 | + ORDER BY s.year DESC NULLS LAST, s.generated_at DESC LIMIT 1`); | |
| 27 | + return rows[0] ?? null; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export const rankingRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 31 | + app.get('/rankings/metrics', { schema: { tags: ['rankings'], summary: 'Metric catalog: formula, version, unit, eligibility and the scopes with a current snapshot', response: ok(AnyList) } }, async () => { | |
| 32 | + const rows = await app.db.execute<Record<string, unknown>>(sql` | |
| 33 | + SELECT m.*, coalesce(json_agg(json_build_object('scopeKey', s.scope_key, 'geography', s.geography, 'sex', s.sex, 'ageGroup', s.age_group, 'year', s.year, 'entityLevel', s.entity_level, 'eligibleEntities', s.eligible_entities, 'generatedAt', s.generated_at, 'inputsHash', s.inputs_hash) ORDER BY s.entity_level, s.geography, s.year DESC) FILTER (WHERE s.id IS NOT NULL), '[]'::json) AS scopes | |
| 34 | + FROM metric_definitions m LEFT JOIN ranking_snapshots s ON s.metric_id = m.id AND s.is_current | |
| 35 | + GROUP BY m.id ORDER BY m.category, m.slug`); | |
| 36 | + const data = camelRows(rows); | |
| 37 | + return respond(app, data, rows.flatMap((r) => (r.source_slugs as string[]) ?? [])); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + app.get('/rankings', { schema: { tags: ['rankings'], summary: 'Current ranking snapshot for a metric and scope, with snapshot metadata and the metric definition (§33, §179)', querystring: z.object({ metric: z.string().default('active_trials'), ...scopeQuery, ...pageQuery }), response: ok(AnyRecord, true) } }, async (req) => { | |
| 41 | + const q = req.query; | |
| 42 | + const snap = await findSnapshot(app, q.metric, q); | |
| 43 | + if (!snap) { | |
| 44 | + const def = await app.db.execute<Record<string, unknown>>(sql`SELECT * FROM metric_definitions WHERE slug = ${q.metric}`); | |
| 45 | + if (!def[0]) throw new NotFound('metric', q.metric); | |
| 46 | + // Metric exists but no snapshot for this scope: "Data not yet available" (never an empty fake ranking). | |
| 47 | + return respond(app, { metric: camel(def[0]), snapshot: null, rows: [], status: 'not_available', message: `No current ranking for ${q.metric} in scope geo=${q.geography} sex=${q.sex} age=${q.age} year=${q.year ?? 'latest'} level=${q.level}.` }, (def[0].source_slugs as string[]) ?? [], paginate(0, q.limit, q.offset)); | |
| 48 | + } | |
| 49 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 50 | + SELECT r.id AS ranking_id, r.rank, r.previous_rank, r.percentile, r.value, r.unit, r.confidence, r.eligible_entities, r.inputs, r.breakdown, | |
| 51 | + c.id AS cancer_id, c.slug, c.canonical_name AS name, c.short_name, c.entity_type, c.hematologic, c.top_level, count(*) OVER() AS total | |
| 52 | + FROM rankings r JOIN cancers c ON c.id = r.cancer_id WHERE r.snapshot_id = ${Number(snap.id)} | |
| 53 | + ORDER BY r.rank, c.canonical_name LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 54 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 55 | + const { metric_name, metric_description, formula, unit, higher_is_worse, category, aggregation, eligibility, experimental, source_slugs, ...snapshot } = snap; | |
| 56 | + const data = { | |
| 57 | + metric: { slug: snap.metric_slug, name: metric_name, description: metric_description, formula, formulaVersion: snap.formula_version, unit, higherIsWorse: higher_is_worse, category, aggregation, eligibility, experimental, sourceSlugs: source_slugs }, | |
| 58 | + snapshot: camel(snapshot), | |
| 59 | + rows: rows.map((r) => ({ | |
| 60 | + rankingId: r.ranking_id, | |
| 61 | + rank: r.rank, | |
| 62 | + previousRank: r.previous_rank, | |
| 63 | + rankChange: r.previous_rank == null ? null : num(r.previous_rank) - num(r.rank), | |
| 64 | + percentile: r.percentile, | |
| 65 | + value: r.value, | |
| 66 | + unit: r.unit, | |
| 67 | + confidence: r.confidence, | |
| 68 | + eligibleEntities: r.eligible_entities, | |
| 69 | + cancer: { id: r.cancer_id, slug: r.slug, name: r.name, shortName: r.short_name, entityType: r.entity_type, hematologic: r.hematologic, topLevel: r.top_level }, | |
| 70 | + inputs: r.inputs, | |
| 71 | + breakdown: r.breakdown, | |
| 72 | + explain: `/v1/rankings/${snap.metric_slug as string}/${r.cancer_id as string}/explain?geography=${encodeURIComponent(q.geography)}&sex=${q.sex}&age=${q.age}&level=${q.level}${snap.year ? `&year=${snap.year as number}` : ''}`, | |
| 73 | + })), | |
| 74 | + }; | |
| 75 | + return respond(app, data, (snap.source_ids as string[]) ?? [], paginate(total, q.limit, q.offset)); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + app.get('/rankings/:metric/:cancerId/explain', { schema: { tags: ['rankings'], summary: '"Why this rank?" — the row inputs, previous rank, snapshot metadata and a lineage trace down to provenance and raw records (§183, §252)', params: z.object({ metric: z.string(), cancerId: z.string() }), querystring: z.object(scopeQuery), response: ok(AnyRecord) } }, async (req) => { | |
| 79 | + const { id } = await resolveCancer(app.db, req.params.cancerId); | |
| 80 | + const snap = await findSnapshot(app, req.params.metric, req.query); | |
| 81 | + if (!snap) throw new NotFound('ranking snapshot', `${req.params.metric} in the requested scope`); | |
| 82 | + const rows = await app.db.execute<Record<string, unknown>>(sql` | |
| 83 | + SELECT r.*, c.slug, c.canonical_name AS name FROM rankings r JOIN cancers c ON c.id = r.cancer_id WHERE r.snapshot_id = ${Number(snap.id)} AND r.cancer_id = ${id}`); | |
| 84 | + const row = rows[0]; | |
| 85 | + if (!row) throw new NotFound('ranking row', `${req.params.metric}/${id} (not eligible in this scope)`); | |
| 86 | + const [trace, neighbours] = await Promise.all([ | |
| 87 | + traceValue(app.db, 'rankings', String(row.id)), | |
| 88 | + app.db.execute<Record<string, unknown>>(sql`SELECT r.rank, r.value, c.id, c.slug, c.canonical_name AS name FROM rankings r JOIN cancers c ON c.id = r.cancer_id WHERE r.snapshot_id = ${Number(snap.id)} AND r.rank BETWEEN ${num(row.rank) - 2} AND ${num(row.rank) + 2} ORDER BY r.rank`), | |
| 89 | + ]); | |
| 90 | + const { metric_name, metric_description, formula, unit, higher_is_worse, category, aggregation, eligibility, experimental, source_slugs, ...snapshot } = snap; | |
| 91 | + const data = { | |
| 92 | + cancer: { id, slug: row.slug, name: row.name }, | |
| 93 | + metric: { slug: snap.metric_slug, name: metric_name, description: metric_description, formula, formulaVersion: snap.formula_version, unit, higherIsWorse: higher_is_worse, category, aggregation, eligibility, experimental, sourceSlugs: source_slugs }, | |
| 94 | + snapshot: camel(snapshot), | |
| 95 | + rank: row.rank, | |
| 96 | + previousRank: row.previous_rank, | |
| 97 | + rankChange: row.previous_rank == null ? null : num(row.previous_rank) - num(row.rank), | |
| 98 | + percentile: row.percentile, | |
| 99 | + value: row.value, | |
| 100 | + unit: row.unit, | |
| 101 | + confidence: row.confidence, | |
| 102 | + eligibleEntities: row.eligible_entities, | |
| 103 | + inputs: row.inputs, | |
| 104 | + breakdown: row.breakdown, | |
| 105 | + neighbours: camelRows(neighbours), | |
| 106 | + trace, | |
| 107 | + reproduce: `pnpm cix rank # recompute; compare ranking_snapshots.inputs_hash = ${snap.inputs_hash as string}`, | |
| 108 | + }; | |
| 109 | + return respond(app, data, (snap.source_ids as string[]) ?? []); | |
| 110 | + }); | |
| 111 | +}; | |
added
apps/api/src/routes/search.ts
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { searchAll, type SearchType } from '../lib/search.js'; | |
| 4 | +import { ok, respond } from '../lib/respond.js'; | |
| 5 | + | |
| 6 | +const Result = z.object({ type: z.enum(['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']), id: z.string(), slug: z.string(), name: z.string(), subtitle: z.string().nullable(), score: z.number(), match: z.enum(['exact', 'alias', 'prefix', 'fuzzy']) }); | |
| 7 | + | |
| 8 | +export const searchRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 9 | + app.get('/search', { schema: { tags: ['search'], summary: 'Cross-entity search (exact > alias > prefix > fuzzy; deterministic order; max 20)', querystring: z.object({ q: z.string().trim().min(1).max(200), types: z.string().optional().describe('Comma-separated subset of cancer,gene,variant,drug,trial,publication'), limit: z.coerce.number().int().min(1).max(20).default(20) }), response: ok(z.array(Result)) } }, async (req) => { | |
| 10 | + const types = req.query.types | |
| 11 | + ?.split(',') | |
| 12 | + .map((t) => t.trim()) | |
| 13 | + .filter((t): t is SearchType => ['cancer', 'gene', 'variant', 'drug', 'trial', 'publication'].includes(t)); | |
| 14 | + const results = await searchAll(app.db, req.query.q, types, req.query.limit); | |
| 15 | + return respond(app, results, []); | |
| 16 | + }); | |
| 17 | +}; | |
added
apps/api/src/routes/sources.ts
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { NotFound } from '../lib/errors.js'; | |
| 5 | +import { AnyList, AnyRecord, camel, camelRows, ok, respond } from '../lib/respond.js'; | |
| 6 | + | |
| 7 | +const SOURCE_COLS = sql` | |
| 8 | + s.id, s.slug, s.name, s.organization, s.category, s.description, s.homepage, s.docs_url, s.terms_url, s.access_type, s.access_auth, s.license, s.license_status, s.commercial_use, s.redistribution, | |
| 9 | + s.attribution, s.license_reviewed_at, s.approved_for_production, s.update_frequency, s.supports_incremental, s.entities, s.metrics, s.rate_limit, s.status, s.tier, | |
| 10 | + s.manifest->>'documentationVerifiedAt' AS documentation_verified_at, s.manifest->>'schedule' AS schedule, s.manifest->>'termsNotes' AS terms_notes, | |
| 11 | + cc.health, cc.health_detail, cc.paused, cc.last_success_at, cc.last_attempt_at, cc.cursor, | |
| 12 | + (SELECT count(*) FROM source_records sr WHERE sr.source_id = s.id) AS record_count, | |
| 13 | + (SELECT count(*) FROM provenance p WHERE p.source_id = s.id) AS provenance_count, | |
| 14 | + (SELECT count(*) FROM unresolved_labels u WHERE u.source_id = s.id AND u.status = 'open') AS unresolved_open`; | |
| 15 | + | |
| 16 | +function shape(r: Record<string, unknown>) { | |
| 17 | + const c = camel<Record<string, unknown>>(r); | |
| 18 | + const { health, healthDetail, paused, lastSuccessAt, lastAttemptAt, cursor, recordCount, provenanceCount, unresolvedOpen, ...rest } = c; | |
| 19 | + return { | |
| 20 | + ...rest, | |
| 21 | + connector: { health: health ?? 'never_run', healthDetail: healthDetail ?? null, paused: paused ?? false, lastSuccessAt: lastSuccessAt ?? null, lastAttemptAt: lastAttemptAt ?? null, cursor: cursor ?? null }, | |
| 22 | + counts: { sourceRecords: Number(recordCount ?? 0), provenanceRows: Number(provenanceCount ?? 0), unresolvedLabelsOpen: Number(unresolvedOpen ?? 0) }, | |
| 23 | + }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export const sourceRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 27 | + app.get('/sources', { schema: { tags: ['sources'], summary: 'Source registry with license status, connector health, last runs and record counts', response: ok(AnyList) } }, async () => { | |
| 28 | + const [rows, lastRuns] = await Promise.all([ | |
| 29 | + app.db.execute<Record<string, unknown>>(sql`SELECT ${SOURCE_COLS} FROM sources s LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug ORDER BY s.tier, s.slug`), | |
| 30 | + app.db.execute<Record<string, unknown>>(sql` | |
| 31 | + SELECT DISTINCT ON (connector_id) connector_id, id, mode, status, started_at, finished_at, duration_ms, records_fetched, records_created, records_updated, records_unchanged, records_rejected, error, anomaly, dataset_version | |
| 32 | + FROM ingest_runs ORDER BY connector_id, started_at DESC`), | |
| 33 | + ]); | |
| 34 | + const lastBy = new Map(lastRuns.map((r) => [r.connector_id as string, camel(r)])); | |
| 35 | + const data = rows.map((r) => ({ ...shape(r), lastRun: lastBy.get(r.slug as string) ?? null })); | |
| 36 | + return respond(app, data, rows.map((r) => r.id as string)); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + app.get('/sources/:slug', { schema: { tags: ['sources'], summary: 'Source detail: manifest, license, health, last 10 runs, record counts by entity', params: z.object({ slug: z.string() }), response: ok(AnyRecord) } }, async (req) => { | |
| 40 | + const rows = await app.db.execute<Record<string, unknown>>(sql`SELECT ${SOURCE_COLS}, s.manifest FROM sources s LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug WHERE s.slug = ${req.params.slug} OR s.id = ${req.params.slug}`); | |
| 41 | + const src = rows[0]; | |
| 42 | + if (!src) throw new NotFound('source', req.params.slug); | |
| 43 | + const [runs, byEntity, fieldStats] = await Promise.all([ | |
| 44 | + app.db.execute<Record<string, unknown>>(sql` | |
| 45 | + SELECT id, mode, status, started_at, finished_at, duration_ms, records_fetched, records_created, records_updated, records_unchanged, records_rejected, http_requests, http_failures, rate_limit_events, validation_failures, | |
| 46 | + jsonb_array_length(schema_drift) AS drift_signals, error, anomaly, dataset_version | |
| 47 | + FROM ingest_runs WHERE connector_id = ${src.slug as string} ORDER BY started_at DESC LIMIT 10`), | |
| 48 | + app.db.execute<Record<string, unknown>>(sql`SELECT entity_kind, count(*) AS n, max(retrieved_at) AS last_retrieved_at FROM source_records WHERE source_id = ${src.id as string} GROUP BY entity_kind ORDER BY entity_kind`), | |
| 49 | + app.db.execute<{ n: string }>(sql`SELECT count(*) AS n FROM connector_field_stats WHERE connector_id = ${src.slug as string}`), | |
| 50 | + ]); | |
| 51 | + const data = { ...shape(src), recentRuns: camelRows(runs), recordsByEntity: camelRows(byEntity), observedFields: Number(fieldStats[0]?.n ?? 0) }; | |
| 52 | + return respond(app, data, [src.id as string]); | |
| 53 | + }); | |
| 54 | +}; | |
added
apps/api/src/routes/stats.ts
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { TtlCache } from '../lib/cache.js'; | |
| 5 | +import { ok, respond } from '../lib/respond.js'; | |
| 6 | + | |
| 7 | +const Stats = z.object({ | |
| 8 | + cancers: z.number().describe('Active malignant canonical entities'), | |
| 9 | + entities: z.number().describe('All active canonical entities (incl. precursor/benign)'), | |
| 10 | + topLevel: z.number(), | |
| 11 | + subtypes: z.number().describe('Hierarchy edges (child relationships)'), | |
| 12 | + genes: z.number(), | |
| 13 | + cancerGenes: z.number(), | |
| 14 | + variants: z.number(), | |
| 15 | + drugs: z.number(), | |
| 16 | + trials: z.number(), | |
| 17 | + activeTrials: z.number(), | |
| 18 | + publications: z.number(), | |
| 19 | + evidenceItems: z.number(), | |
| 20 | + epidemiologyObservations: z.number(), | |
| 21 | + survivalObservations: z.number(), | |
| 22 | + countriesWithData: z.number(), | |
| 23 | + sources: z.number(), | |
| 24 | + activeSources: z.number(), | |
| 25 | + rankingSnapshots: z.number(), | |
| 26 | + unresolvedLabelsOpen: z.number(), | |
| 27 | + lastIngestAt: z.string().nullable(), | |
| 28 | + computedAt: z.string(), | |
| 29 | +}); | |
| 30 | +type Stats = z.infer<typeof Stats>; | |
| 31 | + | |
| 32 | +const cache = new TtlCache<Stats>(60_000); | |
| 33 | + | |
| 34 | +export const statsRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 35 | + app.get('/stats', { schema: { tags: ['stats'], summary: 'Live counts for the homepage ticker (computed from the database, cached 60 s)', response: ok(Stats) } }, async () => { | |
| 36 | + const data = await cache.getOrLoad('stats', async () => { | |
| 37 | + const rows = await app.db.execute<Record<string, string | null>>(sql` | |
| 38 | + SELECT | |
| 39 | + (SELECT count(*) FROM cancers WHERE status = 'active' AND malignant) AS cancers, | |
| 40 | + (SELECT count(*) FROM cancers WHERE status = 'active') AS entities, | |
| 41 | + (SELECT count(*) FROM cancers WHERE status = 'active' AND top_level) AS top_level, | |
| 42 | + (SELECT count(DISTINCT child_id) FROM cancer_hierarchy) AS subtypes, | |
| 43 | + (SELECT count(*) FROM genes) AS genes, | |
| 44 | + (SELECT count(*) FROM genes WHERE is_cancer_gene) AS cancer_genes, | |
| 45 | + (SELECT count(*) FROM variants) AS variants, | |
| 46 | + (SELECT count(*) FROM drugs) AS drugs, | |
| 47 | + (SELECT count(*) FROM clinical_trials) AS trials, | |
| 48 | + (SELECT count(*) FROM clinical_trials WHERE overall_status IN ('RECRUITING','NOT_YET_RECRUITING','ENROLLING_BY_INVITATION','ACTIVE_NOT_RECRUITING')) AS active_trials, | |
| 49 | + (SELECT count(*) FROM publications) AS publications, | |
| 50 | + (SELECT count(*) FROM civic_evidence_items WHERE status = 'ACCEPTED') AS evidence_items, | |
| 51 | + (SELECT count(*) FROM epidemiology_observations) AS epidemiology_observations, | |
| 52 | + (SELECT count(*) FROM survival_observations) AS survival_observations, | |
| 53 | + (SELECT count(DISTINCT o.geography_id) FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id WHERE g.kind = 'country') AS countries_with_data, | |
| 54 | + (SELECT count(*) FROM sources) AS sources, | |
| 55 | + (SELECT count(*) FROM sources WHERE status = 'active') AS active_sources, | |
| 56 | + (SELECT count(*) FROM ranking_snapshots WHERE is_current) AS ranking_snapshots, | |
| 57 | + (SELECT count(*) FROM unresolved_labels WHERE status = 'open') AS unresolved_labels_open, | |
| 58 | + (SELECT max(finished_at)::text FROM ingest_runs WHERE status IN ('succeeded','partial')) AS last_ingest_at`); | |
| 59 | + const r = rows[0]!; | |
| 60 | + const n = (k: string) => Number(r[k] ?? 0); | |
| 61 | + return { | |
| 62 | + cancers: n('cancers'), | |
| 63 | + entities: n('entities'), | |
| 64 | + topLevel: n('top_level'), | |
| 65 | + subtypes: n('subtypes'), | |
| 66 | + genes: n('genes'), | |
| 67 | + cancerGenes: n('cancer_genes'), | |
| 68 | + variants: n('variants'), | |
| 69 | + drugs: n('drugs'), | |
| 70 | + trials: n('trials'), | |
| 71 | + activeTrials: n('active_trials'), | |
| 72 | + publications: n('publications'), | |
| 73 | + evidenceItems: n('evidence_items'), | |
| 74 | + epidemiologyObservations: n('epidemiology_observations'), | |
| 75 | + survivalObservations: n('survival_observations'), | |
| 76 | + countriesWithData: n('countries_with_data'), | |
| 77 | + sources: n('sources'), | |
| 78 | + activeSources: n('active_sources'), | |
| 79 | + rankingSnapshots: n('ranking_snapshots'), | |
| 80 | + unresolvedLabelsOpen: n('unresolved_labels_open'), | |
| 81 | + lastIngestAt: r.last_ingest_at ? new Date(r.last_ingest_at).toISOString() : null, | |
| 82 | + computedAt: new Date().toISOString(), | |
| 83 | + }; | |
| 84 | + }); | |
| 85 | + const active = await app.db.execute<{ id: string }>(sql`SELECT id FROM sources WHERE status = 'active'`); | |
| 86 | + return respond( | |
| 87 | + app, | |
| 88 | + data, | |
| 89 | + active.map((s) => s.id), | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | +}; | |
added
apps/api/src/routes/trials.ts
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { paginate } from '../lib/envelope.js'; | |
| 5 | +import { descendantIds } from '../lib/descendants.js'; | |
| 6 | +import { pageQuery } from '../lib/pagination.js'; | |
| 7 | +import { resolveCancer, resolveTrial } from '../lib/resolve.js'; | |
| 8 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 9 | +import { pluck } from '../lib/sources.js'; | |
| 10 | + | |
| 11 | +export const trialRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 12 | + app.get('/trials', { schema: { tags: ['trials'], summary: 'Search clinical trials', querystring: z.object({ q: z.string().trim().min(1).max(200).optional().describe('NCT id, acronym or title words'), status: z.string().optional(), phase: z.string().optional(), cancer: z.string().optional().describe('Cancer id/slug — includes descendants'), country: z.string().optional(), studyType: z.string().optional(), ...pageQuery }), response: ok(AnyList, true) } }, async (req) => { | |
| 13 | + const q = req.query; | |
| 14 | + const conds = [sql`true`]; | |
| 15 | + if (q.status) conds.push(sql`t.overall_status = ${q.status.toUpperCase()}`); | |
| 16 | + if (q.phase) conds.push(sql`${q.phase.toUpperCase()} = ANY(t.phases)`); | |
| 17 | + if (q.studyType) conds.push(sql`t.study_type = ${q.studyType.toUpperCase()}`); | |
| 18 | + if (q.country) conds.push(sql`${q.country} = ANY(t.countries)`); | |
| 19 | + if (q.q) { | |
| 20 | + const raw = q.q.trim(); | |
| 21 | + if (/^NCT\d+$/i.test(raw)) conds.push(sql`t.nct_id LIKE ${raw.toUpperCase() + '%'}`); | |
| 22 | + else conds.push(sql`(upper(coalesce(t.acronym,'')) = ${raw.toUpperCase()} OR to_tsvector('english', t.brief_title || ' ' || coalesce(t.official_title,'')) @@ plainto_tsquery('english', ${raw}))`); | |
| 23 | + } | |
| 24 | + if (q.cancer) { | |
| 25 | + const { id } = await resolveCancer(app.db, q.cancer); | |
| 26 | + const ids = await descendantIds(app.db, id); | |
| 27 | + conds.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id = ANY(${sql.param(ids)}::text[]))`); | |
| 28 | + } | |
| 29 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 30 | + SELECT t.id, t.nct_id, t.brief_title, t.acronym, t.study_type, t.phases, t.overall_status, t.start_date, t.primary_completion_date, t.last_update_posted_date, t.has_results, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.conditions, t.countries, t.locations_count, | |
| 31 | + count(*) OVER() AS total | |
| 32 | + FROM clinical_trials t WHERE ${sql.join(conds, sql` AND `)} | |
| 33 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 34 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 35 | + const data = rows.map((r) => { | |
| 36 | + const { total: _t, ...rest } = r; | |
| 37 | + return camel(rest); | |
| 38 | + }); | |
| 39 | + return respond(app, data, data.length ? ['clinicaltrials'] : [], paginate(total, q.limit, q.offset)); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + app.get('/trials/:nct', { schema: { tags: ['trials'], summary: 'Trial: conditions with cancer mapping (match type), interventions with drug mapping, locations (first 200), linked publications', params: z.object({ nct: z.string().min(1).describe('NCT id or CI-TRIAL-… id') }), response: ok(AnyRecord) } }, async (req) => { | |
| 43 | + const { id } = await resolveTrial(app.db, req.params.nct); | |
| 44 | + const db = app.db; | |
| 45 | + const [trial, conditions, interventions, locations, pubs, source] = await Promise.all([ | |
| 46 | + db.execute<Record<string, unknown>>(sql`SELECT * FROM clinical_trials WHERE id = ${id}`), | |
| 47 | + db.execute<Record<string, unknown>>(sql`SELECT tc.id, tc.condition_text, tc.normalized, tc.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, tc.match_type, tc.confidence FROM trial_conditions tc LEFT JOIN cancers c ON c.id = tc.cancer_id WHERE tc.trial_id = ${id} ORDER BY tc.id`), | |
| 48 | + db.execute<Record<string, unknown>>(sql`SELECT ti.id, ti.name, ti.intervention_type, ti.drug_id, d.slug AS drug_slug, d.name AS drug_name, ti.match_type FROM trial_interventions ti LEFT JOIN drugs d ON d.id = ti.drug_id WHERE ti.trial_id = ${id} ORDER BY ti.id`), | |
| 49 | + db.execute<Record<string, unknown>>(sql`SELECT facility, city, state, zip, country, status, lat, lng FROM trial_locations WHERE trial_id = ${id} ORDER BY country, city LIMIT 200`), | |
| 50 | + db.execute<Record<string, unknown>>(sql` | |
| 51 | + SELECT p.id, p.pmid, p.doi, p.title, p.journal, p.pub_year, p.retracted, e.method, e.status AS edge_status, e.source_id | |
| 52 | + FROM publication_entity_edges e JOIN publications p ON p.id = e.publication_id WHERE e.entity_type = 'trial' AND e.entity_id = ${id} AND e.status <> 'rejected' ORDER BY p.pub_year DESC NULLS LAST LIMIT 100`), | |
| 53 | + db.execute<{ source_id: string; retrieved_at: string; raw_path: string | null }>(sql`SELECT sr.source_id, sr.retrieved_at, sr.raw_path FROM clinical_trials t JOIN source_records sr ON sr.id = t.source_record_id WHERE t.id = ${id}`), | |
| 54 | + ]); | |
| 55 | + const t = camel<Record<string, unknown>>(trial[0]!); | |
| 56 | + const data = { | |
| 57 | + ...t, | |
| 58 | + provenance: source[0] ? { sourceId: source[0].source_id, retrievedAt: source[0].retrieved_at, url: `https://clinicaltrials.gov/study/${t.nctId as string}`, category: 'observed_data' } : { sourceSlug: 'clinicaltrials', url: `https://clinicaltrials.gov/study/${t.nctId as string}` }, | |
| 59 | + conditionMappings: conditions.map((r) => ({ id: r.id, conditionText: r.condition_text, normalized: r.normalized, cancer: r.cancer_id ? { id: r.cancer_id, slug: r.cancer_slug, name: r.cancer_name } : null, matchType: r.match_type, confidence: r.confidence })), | |
| 60 | + interventionMappings: interventions.map((r) => ({ id: r.id, name: r.name, interventionType: r.intervention_type, drug: r.drug_id ? { id: r.drug_id, slug: r.drug_slug, name: r.drug_name } : null, matchType: r.match_type })), | |
| 61 | + locations: camelRows(locations), | |
| 62 | + locationsShown: locations.length, | |
| 63 | + publications: pubs.map((r) => { | |
| 64 | + const { method, edge_status, source_id, ...rest } = r; | |
| 65 | + return { ...camel(rest), edge: { method, status: edge_status, sourceId: source_id } }; | |
| 66 | + }), | |
| 67 | + }; | |
| 68 | + return respond(app, data, [source[0]?.source_id ?? 'clinicaltrials', ...pluck(pubs, 'source_id')]); | |
| 69 | + }); | |
| 70 | +}; | |
added
apps/api/src/routes/variants.ts
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +import { sql } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { resolveVariant } from '../lib/resolve.js'; | |
| 5 | +import { AnyRecord, camel, camelRows, ok, respond } from '../lib/respond.js'; | |
| 6 | +import { pluck } from '../lib/sources.js'; | |
| 7 | + | |
| 8 | +export const variantRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 9 | + app.get('/variants/:id', { schema: { tags: ['variants'], summary: 'Variant: coordinates per assembly, ClinVar interpretations (structured), CIViC evidence grouped by cancer (§50), knowledge edges', params: z.object({ id: z.string().min(1).describe('CI-VAR-… id or slug') }), response: ok(AnyRecord) } }, async (req) => { | |
| 10 | + const { id } = await resolveVariant(app.db, req.params.id); | |
| 11 | + const db = app.db; | |
| 12 | + const [variant, aliases, clinsig, evidence, edges, pubs] = await Promise.all([ | |
| 13 | + db.execute<Record<string, unknown>>(sql`SELECT v.*, g.name AS gene_name, g.hgnc_id FROM variants v LEFT JOIN genes g ON g.id = v.gene_id WHERE v.id = ${id}`), | |
| 14 | + db.execute<Record<string, unknown>>(sql`SELECT alias, source_id FROM variant_aliases WHERE variant_id = ${id} ORDER BY alias`), | |
| 15 | + db.execute<Record<string, unknown>>(sql`SELECT cs.*, p.source_id, p.source_url, p.retrieved_at, p.dataset_version FROM variant_clinical_significance cs LEFT JOIN provenance p ON p.id = cs.provenance_id WHERE cs.variant_id = ${id}`), | |
| 16 | + db.execute<Record<string, unknown>>(sql` | |
| 17 | + SELECT e.civic_id, e.name, e.molecular_profile_name, e.disease_name, e.doid, e.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, e.cancer_match_type, | |
| 18 | + e.therapy_names, e.therapy_ids, e.therapy_interaction_type, e.evidence_type, e.evidence_level, e.evidence_direction, e.significance, e.evidence_rating, e.status, e.description, e.pmid, e.source_citation, e.phenotypes, | |
| 19 | + p.source_id, p.source_url, p.retrieved_at | |
| 20 | + FROM civic_evidence_items e LEFT JOIN cancers c ON c.id = e.cancer_id LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 21 | + WHERE ${id} = ANY(e.variant_ids) ORDER BY e.cancer_id NULLS LAST, e.evidence_level, e.civic_id`), | |
| 22 | + db.execute<Record<string, unknown>>(sql` | |
| 23 | + SELECT k.id, k.source_entity_type, k.source_entity_id, k.target_entity_type, k.target_entity_id, k.relationship_type, k.cancer_context_ids, k.direction, k.evidence_level, k.evidence_category, k.status, k.source_id, k.support_count, k.last_seen_at | |
| 24 | + FROM knowledge_edges k WHERE (k.source_entity_type = 'variant' AND k.source_entity_id = ${id}) OR (k.target_entity_type = 'variant' AND k.target_entity_id = ${id}) ORDER BY k.relationship_type LIMIT 500`), | |
| 25 | + db.execute<Record<string, unknown>>(sql` | |
| 26 | + SELECT p.id, p.pmid, p.doi, p.title, p.journal, p.pub_year, p.retracted, e.method, e.status AS edge_status, e.source_id | |
| 27 | + FROM publication_entity_edges e JOIN publications p ON p.id = e.publication_id WHERE e.entity_type = 'variant' AND e.entity_id = ${id} AND e.status <> 'rejected' ORDER BY p.pub_year DESC NULLS LAST LIMIT 100`), | |
| 28 | + ]); | |
| 29 | + // Evidence separated by cancer context — never pooled across diseases (CLAUDE.md §50). | |
| 30 | + const byCancer = new Map<string, { cancer: Record<string, unknown> | null; items: Array<Record<string, unknown>>; accepted: number }>(); | |
| 31 | + for (const e of evidence) { | |
| 32 | + const key = (e.cancer_id as string | null) ?? `unmapped:${e.disease_name ?? 'unknown'}`; | |
| 33 | + if (!byCancer.has(key)) byCancer.set(key, { cancer: e.cancer_id ? { id: e.cancer_id, slug: e.cancer_slug, name: e.cancer_name, matchType: e.cancer_match_type } : null, items: [], accepted: 0 }); | |
| 34 | + const grp = byCancer.get(key)!; | |
| 35 | + if (e.status === 'ACCEPTED') grp.accepted++; | |
| 36 | + const { cancer_slug: _a, cancer_name: _b, cancer_match_type: _c, source_id, source_url, retrieved_at, ...rest } = e; | |
| 37 | + grp.items.push({ ...camel(rest), provenance: { sourceId: source_id, url: source_url, retrievedAt: retrieved_at, category: 'curated_evidence' } }); | |
| 38 | + } | |
| 39 | + const data = { | |
| 40 | + ...camel(variant[0]!), | |
| 41 | + aliases: camelRows(aliases), | |
| 42 | + clinicalSignificance: clinsig.map((r) => { | |
| 43 | + const { source_id, source_url, retrieved_at, dataset_version, provenance_id: _p, ...rest } = r; | |
| 44 | + return { ...camel(rest), provenance: { sourceId: source_id, url: source_url, retrievedAt: retrieved_at, datasetVersion: dataset_version, category: 'curated_evidence' } }; | |
| 45 | + }), | |
| 46 | + evidenceByCancer: [...byCancer.values()].map((g) => ({ cancer: g.cancer, acceptedItems: g.accepted, items: g.items })), | |
| 47 | + edges: camelRows(edges), | |
| 48 | + publications: pubs.map((r) => { | |
| 49 | + const { method, edge_status, source_id, ...rest } = r; | |
| 50 | + return { ...camel(rest), edge: { method, status: edge_status, sourceId: source_id } }; | |
| 51 | + }), | |
| 52 | + }; | |
| 53 | + return respond(app, data, [...pluck(clinsig, 'source_id'), ...pluck(evidence, 'source_id'), ...pluck(edges, 'source_id'), ...pluck(pubs, 'source_id'), ...pluck(aliases, 'source_id')]); | |
| 54 | + }); | |
| 55 | +}; | |
added
apps/api/src/server.ts
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import { loadEnv, API_HOST, API_PORT } from './lib/env.js'; | |
| 2 | + | |
| 3 | +loadEnv(); | |
| 4 | + | |
| 5 | +const { buildApp } = await import('./app.js'); | |
| 6 | +const { closeDb } = await import('@cancerindex/database'); | |
| 7 | + | |
| 8 | +const app = await buildApp(); | |
| 9 | + | |
| 10 | +const shutdown = async (signal: string) => { | |
| 11 | + app.log.info({ signal }, 'shutting down'); | |
| 12 | + try { | |
| 13 | + await app.close(); | |
| 14 | + await closeDb(); | |
| 15 | + } finally { | |
| 16 | + process.exit(0); | |
| 17 | + } | |
| 18 | +}; | |
| 19 | +process.on('SIGTERM', () => void shutdown('SIGTERM')); | |
| 20 | +process.on('SIGINT', () => void shutdown('SIGINT')); | |
| 21 | + | |
| 22 | +try { | |
| 23 | + await app.listen({ host: API_HOST(), port: API_PORT() }); | |
| 24 | + app.log.info({ docs: `http://${API_HOST()}:${API_PORT()}/v1/docs` }, 'CancerIndex API ready'); | |
| 25 | +} catch (err) { | |
| 26 | + app.log.error({ err }, 'failed to start'); | |
| 27 | + process.exit(1); | |
| 28 | +} | |
added
apps/api/src/types.ts
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import type { Database } from '@cancerindex/database'; | |
| 2 | + | |
| 3 | +export interface ApiKeyInfo { | |
| 4 | + id: number; | |
| 5 | + prefix: string; | |
| 6 | + tier: string; | |
| 7 | + rateLimitPerMinute: number; | |
| 8 | + label: string | null; | |
| 9 | +} | |
| 10 | + | |
| 11 | +declare module 'fastify' { | |
| 12 | + interface FastifyInstance { | |
| 13 | + db: Database; | |
| 14 | + /** True when the database answered the last liveness probe. */ | |
| 15 | + dbHealthy(): Promise<boolean>; | |
| 16 | + } | |
| 17 | + interface FastifyRequest { | |
| 18 | + apiKey: ApiKeyInfo | null; | |
| 19 | + } | |
| 20 | +} | |
added
apps/api/test/envelope.test.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { dataRelease, dedupeSources, envelope, paginate } from '../src/lib/envelope.js'; | |
| 3 | + | |
| 4 | +const src = (id: string, slug: string) => ({ id, slug, name: slug, license: 'CC BY 4.0', attribution: null, url: null }); | |
| 5 | + | |
| 6 | +describe('envelope', () => { | |
| 7 | + it('wraps data with sources, dataRelease and generatedAt', () => { | |
| 8 | + const out = envelope({ a: 1 }, [src('CI-SOURCE-00000001', 'oncotree')], undefined, new Date('2026-09-08T10:00:00Z')); | |
| 9 | + expect(out.data).toEqual({ a: 1 }); | |
| 10 | + expect(out.sources).toHaveLength(1); | |
| 11 | + expect(out.dataRelease).toBe('CancerIndex 2026-09'); | |
| 12 | + expect(new Date(out.generatedAt).getTime()).toBeGreaterThan(0); | |
| 13 | + expect(out).not.toHaveProperty('total'); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it('spreads pagination fields when provided', () => { | |
| 17 | + const out = envelope([], [], paginate(250, 50, 200)); | |
| 18 | + expect(out.total).toBe(250); | |
| 19 | + expect(out.limit).toBe(50); | |
| 20 | + expect(out.offset).toBe(200); | |
| 21 | + expect(out.hasMore).toBe(false); | |
| 22 | + expect(paginate(250, 50, 100).hasMore).toBe(true); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it('deduplicates sources by id and sorts by slug', () => { | |
| 26 | + const out = dedupeSources([src('CI-SOURCE-00000002', 'seer'), src('CI-SOURCE-00000001', 'oncotree'), src('CI-SOURCE-00000002', 'seer')]); | |
| 27 | + expect(out.map((s) => s.slug)).toEqual(['oncotree', 'seer']); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it('formats the data release as CancerIndex YYYY-MM', () => { | |
| 31 | + expect(dataRelease(new Date('2025-01-31T23:59:59Z'))).toBe('CancerIndex 2025-01'); | |
| 32 | + }); | |
| 33 | +}); | |
added
apps/api/test/resolve.test.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { classifyRef } from '../src/lib/resolve.js'; | |
| 3 | + | |
| 4 | +describe('classifyRef', () => { | |
| 5 | + it('recognises CancerIndex public ids per namespace', () => { | |
| 6 | + expect(classifyRef('CI-CAN-00000042', 'CAN')).toBe('id'); | |
| 7 | + expect(classifyRef('CI-GENE-00000001', 'GENE')).toBe('id'); | |
| 8 | + // an id from another namespace is not accepted as this entity's id | |
| 9 | + expect(classifyRef('CI-GENE-00000001', 'CAN')).toBe('slug'); | |
| 10 | + }); | |
| 11 | + it('never treats bare integers as entity ids', () => { | |
| 12 | + expect(classifyRef('42', 'CAN')).toBe('slug'); | |
| 13 | + expect(classifyRef('42', 'PUB')).toBe('pmid'); // PMIDs are the only numeric public references | |
| 14 | + }); | |
| 15 | + it('classifies slugs, symbols and registry ids', () => { | |
| 16 | + expect(classifyRef('lung-adenocarcinoma', 'CAN')).toBe('slug'); | |
| 17 | + expect(classifyRef('BRAF', 'GENE')).toBe('symbol'); | |
| 18 | + expect(classifyRef('NCT01234567', 'TRIAL')).toBe('nct'); | |
| 19 | + expect(classifyRef('nct01234567', 'TRIAL')).toBe('nct'); | |
| 20 | + expect(classifyRef('NCT123', 'TRIAL')).toBe('slug'); | |
| 21 | + }); | |
| 22 | +}); | |
added
apps/api/test/search.test.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { rankSearchHits, TIER, type SearchHit } from '../src/lib/search.js'; | |
| 3 | + | |
| 4 | +const hit = (p: Partial<SearchHit> & { id: string; tier: SearchHit['tier'] }): SearchHit => ({ type: 'cancer', slug: p.id, name: p.id, subtitle: null, score: 0.5, ...p }); | |
| 5 | + | |
| 6 | +describe('rankSearchHits', () => { | |
| 7 | + it('orders exact > alias > prefix > fuzzy regardless of input order', () => { | |
| 8 | + const out = rankSearchHits([hit({ id: 'fz', tier: TIER.fuzzy, score: 0.99 }), hit({ id: 'pf', tier: TIER.prefix }), hit({ id: 'ex', tier: TIER.exact, score: 0.1 }), hit({ id: 'al', tier: TIER.alias })]); | |
| 9 | + expect(out.map((r) => r.id)).toEqual(['ex', 'al', 'pf', 'fz']); | |
| 10 | + expect(out.map((r) => r.match)).toEqual(['exact', 'alias', 'prefix', 'fuzzy']); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it('is deterministic within a tier: score desc, entity type, shorter name, name, id', () => { | |
| 14 | + const out = rankSearchHits([ | |
| 15 | + hit({ id: 'b', tier: TIER.prefix, score: 0.5, name: 'Lung Cancer' }), | |
| 16 | + hit({ id: 'a', tier: TIER.prefix, score: 0.5, name: 'Lung Adenocarcinoma' }), | |
| 17 | + hit({ id: 'g', tier: TIER.prefix, score: 0.5, type: 'gene', name: 'LUNG' }), | |
| 18 | + hit({ id: 'c', tier: TIER.prefix, score: 0.9, name: 'Lung Neoplasm' }), | |
| 19 | + ]); | |
| 20 | + expect(out.map((r) => r.id)).toEqual(['c', 'b', 'a', 'g']); | |
| 21 | + expect(rankSearchHits([hit({ id: 'x', tier: TIER.prefix }), hit({ id: 'y', tier: TIER.prefix })]).map((r) => r.id)).toEqual(['x', 'y']); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it('keeps one result per (type,id) using the best tier', () => { | |
| 25 | + const out = rankSearchHits([hit({ id: 'x', tier: TIER.fuzzy }), hit({ id: 'x', tier: TIER.exact }), hit({ id: 'x', tier: TIER.alias })]); | |
| 26 | + expect(out).toHaveLength(1); | |
| 27 | + expect(out[0]!.match).toBe('exact'); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it('caps the result count', () => { | |
| 31 | + const many = Array.from({ length: 50 }, (_, i) => hit({ id: `id${String(i).padStart(2, '0')}`, tier: TIER.prefix })); | |
| 32 | + expect(rankSearchHits(many, 20)).toHaveLength(20); | |
| 33 | + }); | |
| 34 | +}); | |
added
apps/api/test/smoke.test.ts
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +/** | |
| 2 | + * Read-only smoke tests against the local `cancerindex` database. Skipped when the database is | |
| 3 | + * unreachable so CI without Postgres still passes. | |
| 4 | + */ | |
| 5 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | |
| 6 | +import type { FastifyInstance } from 'fastify'; | |
| 7 | +import { loadEnv } from '../src/lib/env.js'; | |
| 8 | + | |
| 9 | +loadEnv(); | |
| 10 | + | |
| 11 | +let app: FastifyInstance | null = null; | |
| 12 | +let reachable = false; | |
| 13 | + | |
| 14 | +beforeAll(async () => { | |
| 15 | + try { | |
| 16 | + const { getDb } = await import('@cancerindex/database'); | |
| 17 | + const { sql } = await import('drizzle-orm'); | |
| 18 | + await getDb().execute(sql`SELECT 1`); | |
| 19 | + reachable = true; | |
| 20 | + const { buildApp } = await import('../src/app.js'); | |
| 21 | + app = await buildApp({ logger: false }); | |
| 22 | + await app.ready(); | |
| 23 | + } catch { | |
| 24 | + reachable = false; | |
| 25 | + } | |
| 26 | +}, 30_000); | |
| 27 | + | |
| 28 | +afterAll(async () => { | |
| 29 | + await app?.close(); | |
| 30 | + const { closeDb } = await import('@cancerindex/database'); | |
| 31 | + await closeDb().catch(() => {}); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +const maybe = (name: string, fn: () => Promise<void>) => | |
| 35 | + it(name, async (ctx) => { | |
| 36 | + if (!reachable || !app) return ctx.skip(); | |
| 37 | + await fn(); | |
| 38 | + }); | |
| 39 | + | |
| 40 | +describe('API smoke (real DB, read-only)', () => { | |
| 41 | + maybe('GET /healthz', async () => { | |
| 42 | + const res = await app!.inject({ url: '/healthz' }); | |
| 43 | + expect(res.statusCode).toBe(200); | |
| 44 | + const body = res.json(); | |
| 45 | + expect(body.ok).toBe(true); | |
| 46 | + expect(body.db).toBe(true); | |
| 47 | + expect(body.dataRelease).toMatch(/^CancerIndex \d{4}-\d{2}$/); | |
| 48 | + expect(res.headers['x-request-id']).toBeTruthy(); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + maybe('GET /v1/cancers?limit=2 returns the envelope', async () => { | |
| 52 | + const res = await app!.inject({ url: '/v1/cancers?limit=2' }); | |
| 53 | + expect(res.statusCode).toBe(200); | |
| 54 | + const body = res.json(); | |
| 55 | + expect(Array.isArray(body.data)).toBe(true); | |
| 56 | + expect(body.data.length).toBeLessThanOrEqual(2); | |
| 57 | + expect(body).toHaveProperty('sources'); | |
| 58 | + expect(body).toHaveProperty('dataRelease'); | |
| 59 | + expect(body).toHaveProperty('total'); | |
| 60 | + expect(body.limit).toBe(2); | |
| 61 | + if (body.data.length) { | |
| 62 | + expect(body.data[0].id).toMatch(/^CI-CAN-\d{8}$/); | |
| 63 | + expect(body.data[0]).toHaveProperty('counters'); | |
| 64 | + } | |
| 65 | + }); | |
| 66 | + | |
| 67 | + maybe('GET /v1/cancers?limit=500 is rejected (limit ≤ 200)', async () => { | |
| 68 | + const res = await app!.inject({ url: '/v1/cancers?limit=500' }); | |
| 69 | + expect(res.statusCode).toBe(400); | |
| 70 | + expect(res.json().error.code).toBe('bad_request'); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + maybe('GET /v1/search?q=lung returns typed, ordered results', async () => { | |
| 74 | + const res = await app!.inject({ url: '/v1/search?q=lung' }); | |
| 75 | + expect(res.statusCode).toBe(200); | |
| 76 | + const body = res.json(); | |
| 77 | + expect(body.data.length).toBeLessThanOrEqual(20); | |
| 78 | + for (const r of body.data) expect(['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']).toContain(r.type); | |
| 79 | + const tiers = body.data.map((r: { match: string }) => ['exact', 'alias', 'prefix', 'fuzzy'].indexOf(r.match)); | |
| 80 | + expect([...tiers].sort((a, b) => a - b)).toEqual(tiers); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + maybe('GET /v1/rankings/metrics lists the metric catalog', async () => { | |
| 84 | + const res = await app!.inject({ url: '/v1/rankings/metrics' }); | |
| 85 | + expect(res.statusCode).toBe(200); | |
| 86 | + const body = res.json(); | |
| 87 | + expect(body.data.length).toBeGreaterThanOrEqual(17); | |
| 88 | + const mir = body.data.find((m: { slug: string }) => m.slug === 'mortality_incidence_ratio'); | |
| 89 | + expect(mir.formulaVersion).toBe('ci-mir-v1'); | |
| 90 | + expect(Array.isArray(mir.scopes)).toBe(true); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + maybe('GET /v1/cancers/does-not-exist → 404 envelope-less error', async () => { | |
| 94 | + const res = await app!.inject({ url: '/v1/cancers/does-not-exist' }); | |
| 95 | + expect(res.statusCode).toBe(404); | |
| 96 | + expect(res.json().error.code).toBe('not_found'); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + maybe('admin routes require x-admin-token', async () => { | |
| 100 | + const res = await app!.inject({ url: '/v1/admin/connectors' }); | |
| 101 | + expect([401, 503]).toContain(res.statusCode); | |
| 102 | + }); | |
| 103 | +}); | |
added
apps/api/tsconfig.json
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "rootDir": ".", | |
| 5 | + "noEmit": true, | |
| 6 | + "types": ["node"] | |
| 7 | + }, | |
| 8 | + "include": ["src", "test", "scripts"] | |
| 9 | +} | |
added
apps/web/next-env.d.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +/// <reference types="next" /> | |
| 2 | +/// <reference types="next/image-types/global" /> | |
| 3 | +import "./.next/types/routes.d.ts"; | |
| 4 | +import "./.next/types/root-params.d.ts"; | |
| 5 | + | |
| 6 | +// NOTE: This file should not be edited | |
| 7 | +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. | |
added
apps/web/next.config.ts
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +import type { NextConfig } from 'next'; | |
| 2 | +import { existsSync } from 'node:fs'; | |
| 3 | +import path from 'node:path'; | |
| 4 | + | |
| 5 | +// Monorepo: the single `.env` lives at the repository root; Next only reads the app directory. | |
| 6 | +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) { | |
| 7 | + if (existsSync(candidate)) { | |
| 8 | + try { | |
| 9 | + process.loadEnvFile(candidate); | |
| 10 | + } catch { | |
| 11 | + /* ignore malformed env */ | |
| 12 | + } | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +const nextConfig: NextConfig = { | |
| 17 | + reactStrictMode: true, | |
| 18 | + poweredByHeader: false, | |
| 19 | + agentRules: false, | |
| 20 | + // Dev only: Next blocks client assets/HMR for origins other than the printed host (localhost). | |
| 21 | + // Allow 127.0.0.1 so curl/Playwright smoke tests hydrate in `next dev` as well. | |
| 22 | + allowedDevOrigins: ['127.0.0.1', 'localhost'], | |
| 23 | + // Workspace packages are consumed as TypeScript sources. | |
| 24 | + transpilePackages: ['@cancerindex/shared', '@cancerindex/database', '@cancerindex/ontology', '@cancerindex/ranking'], | |
| 25 | + serverExternalPackages: ['postgres', 'pino'], | |
| 26 | + // Public API: /api/v1/* is proxied to the Fastify service (apps/api) so one public host serves web + API. | |
| 27 | + async rewrites() { | |
| 28 | + const api = process.env.CI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8251}`; | |
| 29 | + return [{ source: '/api/v1/:path*', destination: `${api}/v1/:path*` }]; | |
| 30 | + }, | |
| 31 | + experimental: { | |
| 32 | + optimizePackageImports: ['lucide-react'], | |
| 33 | + // Workspace packages use NodeNext-style `./file.js` imports that resolve to .ts sources. | |
| 34 | + // Turbopack does not rewrite these outside the app root, so the app is built with webpack | |
| 35 | + // (`next dev/build --webpack`) where extensionAlias handles the mapping. | |
| 36 | + extensionAlias: { '.js': ['.ts', '.tsx', '.js'], '.mjs': ['.mts', '.mjs'] }, | |
| 37 | + }, | |
| 38 | + outputFileTracingRoot: path.resolve(__dirname, '../..'), | |
| 39 | + async headers() { | |
| 40 | + return [ | |
| 41 | + { | |
| 42 | + source: '/(.*)', | |
| 43 | + headers: [ | |
| 44 | + { key: 'X-Content-Type-Options', value: 'nosniff' }, | |
| 45 | + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, | |
| 46 | + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, | |
| 47 | + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, | |
| 48 | + ], | |
| 49 | + }, | |
| 50 | + ]; | |
| 51 | + }, | |
| 52 | +}; | |
| 53 | + | |
| 54 | +export default nextConfig; | |
added
apps/web/package.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@cancerindex/web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "scripts": { | |
| 6 | + "dev": "next dev --webpack -p 8250", | |
| 7 | + "build": "next build --webpack", | |
| 8 | + "start": "next start -p 8250", | |
| 9 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 10 | + "test": "vitest run --passWithNoTests" | |
| 11 | + }, | |
| 12 | + "dependencies": { | |
| 13 | + "@cancerindex/database": "workspace:*", | |
| 14 | + "@cancerindex/ontology": "workspace:*", | |
| 15 | + "@cancerindex/ranking": "workspace:*", | |
| 16 | + "@cancerindex/shared": "workspace:*", | |
| 17 | + "drizzle-orm": "^0.45.0", | |
| 18 | + "lucide-react": "^1.0.0", | |
| 19 | + "next": "16.3.4", | |
| 20 | + "postgres": "^3.4.7", | |
| 21 | + "react": "19.2.8", | |
| 22 | + "react-dom": "19.2.8", | |
| 23 | + "server-only": "^0.0.1" | |
| 24 | + }, | |
| 25 | + "devDependencies": { | |
| 26 | + "@tailwindcss/postcss": "^4", | |
| 27 | + "@types/node": "^24.0.0", | |
| 28 | + "@types/react": "^19", | |
| 29 | + "@types/react-dom": "^19", | |
| 30 | + "tailwindcss": "^4", | |
| 31 | + "typescript": "^5.9.3", | |
| 32 | + "vitest": "^3.2.0" | |
| 33 | + } | |
| 34 | +} | |
added
apps/web/postcss.config.mjs
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +const config = { | |
| 2 | + plugins: { | |
| 3 | + '@tailwindcss/postcss': {}, | |
| 4 | + }, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +export default config; | |
added
apps/web/src/app/about/page.tsx
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section } from '@/components/ui/section'; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: 'About', description: 'What CancerIndex is, who it is for, and what it refuses to do.' }; | |
| 6 | + | |
| 7 | +export default function AboutPage() { | |
| 8 | + return ( | |
| 9 | + <div className="ci-prose max-w-3xl"> | |
| 10 | + <PageHeader kicker="About" title="The global index of cancer" lede="CancerIndex is a provenance-first, continuously updated, transparently sourced index of every recognized malignant disease entity — not a list of thirty common cancers." /> | |
| 11 | + <Section id="what" kicker="Scope" title="What it connects"> | |
| 12 | + <p>Taxonomy (NCIt, OncoTree, ICD), epidemiology (registries), genomics (open cohorts), variants and curated evidence (CIViC, ClinVar), therapies and regulatory status (by jurisdiction), clinical trials (ClinicalTrials.gov), and literature (PubMed) — linked through stable public identifiers and explicit match types.</p> | |
| 13 | + </Section> | |
| 14 | + <Section id="who" kicker="Audience" title="Who it is for"> | |
| 15 | + <p>Researchers, clinicians, analysts, journalists, patient advocates and developers who need the number <em>and</em> where it came from. The public API mirrors the site so nothing on the page is unavailable programmatically.</p> | |
| 16 | + </Section> | |
| 17 | + <Section id="principles" kicker="Principles" title="What it refuses to do"> | |
| 18 | + <ul> | |
| 19 | + <li>No number without a source, a retrieval date and — when derived — a formula version.</li> | |
| 20 | + <li>No invented, estimated or placeholder statistics. Missing data is shown as missing.</li> | |
| 21 | + <li>No composite "worst cancer" score that hides its weights.</li> | |
| 22 | + <li>No medical advice: CancerIndex is not a physician, does not diagnose and does not recommend treatment.</li> | |
| 23 | + <li>No AI-generated synthesis in this phase; all text is sourced or fixed methodology.</li> | |
| 24 | + </ul> | |
| 25 | + </Section> | |
| 26 | + <Section id="status" kicker="Status" title="Phase 1"> | |
| 27 | + <p> | |
| 28 | + The taxonomy backbone is live; epidemiology, genomics, trial and literature connectors are being brought online one at a time, each after a license review. Follow progress on the <Link className="ci-link" href="/sources">source registry</Link> and read the <Link className="ci-link" href="/methodology">methodology</Link>. | |
| 29 | + </p> | |
| 30 | + </Section> | |
| 31 | + </div> | |
| 32 | + ); | |
| 33 | +} | |
added
apps/web/src/app/admin/connectors/page.tsx
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { Badge, StatusBadge } from '@/components/ui/badge'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { connectorHealth } from '@/lib/queries/admin'; | |
| 6 | +import { fmtDateTime, fmtInt, relativeTime } from '@/lib/format'; | |
| 7 | + | |
| 8 | +export default async function AdminConnectorsPage() { | |
| 9 | + const rows = await connectorHealth(); | |
| 10 | + return ( | |
| 11 | + <div className="space-y-6"> | |
| 12 | + <header className="pt-4"> | |
| 13 | + <h1 className="text-3xl">Connectors</h1> | |
| 14 | + <p className="mt-1 text-[13.5px] text-ink-2">Health, last success and attempt, record counts of the last run, drift signals and license status per connector.</p> | |
| 15 | + </header> | |
| 16 | + <Section id="connectors" title="All connectors" kicker={`${rows.length} registered`}> | |
| 17 | + {rows.length ? ( | |
| 18 | + <div className="ci-table-wrap"> | |
| 19 | + <table className="ci-table"> | |
| 20 | + <thead> | |
| 21 | + <tr> | |
| 22 | + <th>Connector</th> | |
| 23 | + <th>Status</th> | |
| 24 | + <th>License</th> | |
| 25 | + <th>Health</th> | |
| 26 | + <th>Last success</th> | |
| 27 | + <th>Last attempt</th> | |
| 28 | + <th>Last run</th> | |
| 29 | + <th className="num">Fetched</th> | |
| 30 | + <th className="num">Created</th> | |
| 31 | + <th className="num">Updated</th> | |
| 32 | + <th className="num">Rejected</th> | |
| 33 | + <th>Drift</th> | |
| 34 | + <th className="num">Runs</th> | |
| 35 | + </tr> | |
| 36 | + </thead> | |
| 37 | + <tbody> | |
| 38 | + {rows.map((r) => ( | |
| 39 | + <tr key={r.slug}> | |
| 40 | + <td> | |
| 41 | + <Link className="ci-link" href={`/source/${r.slug}`}> | |
| 42 | + {r.name} | |
| 43 | + </Link> | |
| 44 | + <span className="ci-mono block text-[10.5px] text-ink-4">{r.slug}</span> | |
| 45 | + </td> | |
| 46 | + <td> | |
| 47 | + <StatusBadge status={r.status} /> | |
| 48 | + </td> | |
| 49 | + <td> | |
| 50 | + <Badge tone={r.license_status === 'approved' ? 'ok' : 'warn'}>{r.license_status}</Badge> | |
| 51 | + </td> | |
| 52 | + <td> | |
| 53 | + <StatusBadge status={r.health ?? 'unknown'} /> | |
| 54 | + {r.paused ? <Badge tone="warn" className="ml-1">paused</Badge> : null} | |
| 55 | + {r.health_detail ? <span className="block max-w-[220px] text-[11.5px] text-ink-3">{r.health_detail}</span> : null} | |
| 56 | + </td> | |
| 57 | + <td className="whitespace-nowrap text-[12.5px]" title={r.last_success_at ? fmtDateTime(r.last_success_at) : ''}> | |
| 58 | + {r.last_success_at ? relativeTime(r.last_success_at) : '—'} | |
| 59 | + </td> | |
| 60 | + <td className="whitespace-nowrap text-[12.5px]" title={r.last_attempt_at ? fmtDateTime(r.last_attempt_at) : ''}> | |
| 61 | + {r.last_attempt_at ? relativeTime(r.last_attempt_at) : '—'} | |
| 62 | + </td> | |
| 63 | + <td> | |
| 64 | + {r.last_run_id ? ( | |
| 65 | + <> | |
| 66 | + <Link className="ci-mono ci-link text-[11.5px]" href={`/admin/runs/${r.last_run_id}`}> | |
| 67 | + {r.last_run_id} | |
| 68 | + </Link>{' '} | |
| 69 | + <StatusBadge status={r.last_run_status} /> | |
| 70 | + </> | |
| 71 | + ) : ( | |
| 72 | + <span className="text-ink-4">—</span> | |
| 73 | + )} | |
| 74 | + </td> | |
| 75 | + <td className="num">{r.records_fetched == null ? '—' : fmtInt(r.records_fetched)}</td> | |
| 76 | + <td className="num">{r.records_created == null ? '—' : fmtInt(r.records_created)}</td> | |
| 77 | + <td className="num">{r.records_updated == null ? '—' : fmtInt(r.records_updated)}</td> | |
| 78 | + <td className="num">{r.records_rejected == null ? '—' : fmtInt(r.records_rejected)}</td> | |
| 79 | + <td> | |
| 80 | + {r.schema_drift?.length ? <Badge tone="warn">{r.schema_drift.length} drift</Badge> : <span className="text-ink-4">none</span>} | |
| 81 | + {r.drift_fields ? <span className="block text-[11px] text-ink-3">{fmtInt(r.drift_fields)} fields tracked</span> : null} | |
| 82 | + </td> | |
| 83 | + <td className="num">{fmtInt(r.runs_total)}</td> | |
| 84 | + </tr> | |
| 85 | + ))} | |
| 86 | + </tbody> | |
| 87 | + </table> | |
| 88 | + </div> | |
| 89 | + ) : ( | |
| 90 | + <EmptyState compact>No connector registered.</EmptyState> | |
| 91 | + )} | |
| 92 | + </Section> | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} | |
added
apps/web/src/app/admin/layout.tsx
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { cookies } from 'next/headers'; | |
| 4 | +import { redirect } from 'next/navigation'; | |
| 5 | +import { ADMIN_COOKIE, adminConfigured, cookieValueFor, isAdmin, tokenMatches } from '@/lib/admin/auth'; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } }; | |
| 8 | +export const dynamic = 'force-dynamic'; | |
| 9 | + | |
| 10 | +const ADMIN_NAV = [ | |
| 11 | + { href: '/admin', label: 'Overview' }, | |
| 12 | + { href: '/admin/connectors', label: 'Connectors' }, | |
| 13 | + { href: '/admin/unresolved', label: 'Unresolved labels' }, | |
| 14 | + { href: '/admin/trace', label: 'Trace' }, | |
| 15 | + { href: '/admin/rankings', label: 'Rankings' }, | |
| 16 | +]; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Admin console (§140-141, §253). Token via `?token=` once → httpOnly cookie (sha256 of the token) | |
| 20 | + * compared to ADMIN_TOKEN. Wrong or missing token renders a login hint instead of the console. | |
| 21 | + */ | |
| 22 | +export default async function AdminLayout({ children }: { children: React.ReactNode }) { | |
| 23 | + if (!adminConfigured()) { | |
| 24 | + return ( | |
| 25 | + <div className="py-10"> | |
| 26 | + <p className="ci-kicker">Admin</p> | |
| 27 | + <h1 className="text-3xl">Admin console disabled</h1> | |
| 28 | + <p className="mt-2 text-[14px] text-ink-2">Set ADMIN_TOKEN in the environment to enable the console.</p> | |
| 29 | + </div> | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + // Token exchange (`?token=` → cookie + redirect) is handled by src/proxy.ts before this renders. | |
| 33 | + const authed = await isAdmin(); | |
| 34 | + if (!authed) { | |
| 35 | + return ( | |
| 36 | + <div className="py-10"> | |
| 37 | + <p className="ci-kicker">Admin</p> | |
| 38 | + <h1 className="text-3xl">Sign in</h1> | |
| 39 | + <p className="mt-2 max-w-xl text-[14px] text-ink-2">Append <code className="ci-mono">?token=<ADMIN_TOKEN></code> to an admin URL once; a session cookie (hash of the token, httpOnly) is then set and the token is removed from the URL.</p> | |
| 40 | + <form action={login} className="mt-4 flex max-w-md gap-2"> | |
| 41 | + <input name="token" type="password" placeholder="ADMIN_TOKEN" className="flex-1 border border-rule-strong bg-white px-3 py-1.5 text-[14px] outline-none focus:border-accent" aria-label="Admin token" /> | |
| 42 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-[14px] text-paper hover:bg-ink-2"> | |
| 43 | + Sign in | |
| 44 | + </button> | |
| 45 | + </form> | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | + } | |
| 49 | + return ( | |
| 50 | + <div className="pt-6"> | |
| 51 | + <div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-rule pb-2"> | |
| 52 | + <p className="ci-kicker">Admin console · not indexed</p> | |
| 53 | + <nav aria-label="Admin" className="flex flex-wrap gap-4 text-[13.5px]"> | |
| 54 | + {ADMIN_NAV.map((n) => ( | |
| 55 | + <Link key={n.href} href={n.href} className="text-ink-2 no-underline hover:text-accent"> | |
| 56 | + {n.label} | |
| 57 | + </Link> | |
| 58 | + ))} | |
| 59 | + <form action={logout}> | |
| 60 | + <button type="submit" className="text-ink-3 hover:text-danger"> | |
| 61 | + Sign out | |
| 62 | + </button> | |
| 63 | + </form> | |
| 64 | + </nav> | |
| 65 | + </div> | |
| 66 | + {children} | |
| 67 | + </div> | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +async function login(formData: FormData) { | |
| 72 | + 'use server'; | |
| 73 | + const token = String(formData.get('token') ?? ''); | |
| 74 | + if (tokenMatches(token)) { | |
| 75 | + (await cookies()).set(ADMIN_COOKIE, cookieValueFor(token), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 }); | |
| 76 | + } | |
| 77 | + redirect('/admin'); | |
| 78 | +} | |
| 79 | + | |
| 80 | +async function logout() { | |
| 81 | + 'use server'; | |
| 82 | + (await cookies()).delete(ADMIN_COOKIE); | |
| 83 | + redirect('/admin'); | |
| 84 | +} | |
added
apps/web/src/app/admin/page.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, KV } from '@/components/ui/section'; | |
| 3 | +import { StatusBadge } from '@/components/ui/badge'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { adminOverview } from '@/lib/queries/admin'; | |
| 6 | +import { listRuns } from '@/lib/queries/sources'; | |
| 7 | +import { fmtDateTime, fmtDuration, fmtInt } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export default async function AdminOverviewPage() { | |
| 10 | + const [o, runs] = await Promise.all([adminOverview(), listRuns({ limit: 15 })]); | |
| 11 | + return ( | |
| 12 | + <div className="space-y-8"> | |
| 13 | + <header className="pt-4"> | |
| 14 | + <h1 className="text-3xl">Overview</h1> | |
| 15 | + </header> | |
| 16 | + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> | |
| 17 | + {[ | |
| 18 | + { label: 'Runs, last 24 h', value: o.runs24h, href: '/admin/connectors' }, | |
| 19 | + { label: 'Failed runs, 7 d', value: o.failedRuns7d, href: '/admin/connectors' }, | |
| 20 | + { label: 'Open unresolved labels', value: o.openUnresolved, href: '/admin/unresolved' }, | |
| 21 | + { label: 'Current ranking snapshots', value: o.currentSnapshots, href: '/admin/rankings' }, | |
| 22 | + ].map((k) => ( | |
| 23 | + <Link key={k.label} href={k.href} className="border border-rule px-3 py-2 no-underline hover:border-accent"> | |
| 24 | + <span className="ci-num block font-display text-2xl">{fmtInt(k.value)}</span> | |
| 25 | + <span className="text-[12.5px] text-ink-2">{k.label}</span> | |
| 26 | + </Link> | |
| 27 | + ))} | |
| 28 | + </div> | |
| 29 | + | |
| 30 | + <div className="grid gap-8 lg:grid-cols-[1fr_1fr]"> | |
| 31 | + <Section id="tables" kicker="Database" title="Row counts"> | |
| 32 | + <div className="ci-table-wrap"> | |
| 33 | + <table className="ci-table"> | |
| 34 | + <thead> | |
| 35 | + <tr> | |
| 36 | + <th>Table</th> | |
| 37 | + <th className="num">Rows</th> | |
| 38 | + </tr> | |
| 39 | + </thead> | |
| 40 | + <tbody> | |
| 41 | + {o.tables.map((t) => ( | |
| 42 | + <tr key={t.table}> | |
| 43 | + <td className="ci-mono">{t.table}</td> | |
| 44 | + <td className={`num ${t.n === 0 ? 'text-ink-4' : ''}`}>{fmtInt(t.n)}</td> | |
| 45 | + </tr> | |
| 46 | + ))} | |
| 47 | + </tbody> | |
| 48 | + </table> | |
| 49 | + </div> | |
| 50 | + </Section> | |
| 51 | + <div className="space-y-8"> | |
| 52 | + <Section id="runs" kicker="Connectors" title="Last runs"> | |
| 53 | + {runs.length ? ( | |
| 54 | + <div className="ci-table-wrap"> | |
| 55 | + <table className="ci-table"> | |
| 56 | + <thead> | |
| 57 | + <tr> | |
| 58 | + <th>Run</th> | |
| 59 | + <th>Status</th> | |
| 60 | + <th>Started</th> | |
| 61 | + <th className="num">Duration</th> | |
| 62 | + <th className="num">Fetched</th> | |
| 63 | + </tr> | |
| 64 | + </thead> | |
| 65 | + <tbody> | |
| 66 | + {runs.map((r) => ( | |
| 67 | + <tr key={r.id}> | |
| 68 | + <td> | |
| 69 | + <Link className="ci-mono ci-link text-[11.5px]" href={`/admin/runs/${r.id}`}> | |
| 70 | + {r.id} | |
| 71 | + </Link> | |
| 72 | + </td> | |
| 73 | + <td> | |
| 74 | + <StatusBadge status={r.status} /> | |
| 75 | + </td> | |
| 76 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDateTime(r.started_at)}</td> | |
| 77 | + <td className="num">{fmtDuration(r.duration_ms)}</td> | |
| 78 | + <td className="num">{fmtInt(r.records_fetched)}</td> | |
| 79 | + </tr> | |
| 80 | + ))} | |
| 81 | + </tbody> | |
| 82 | + </table> | |
| 83 | + </div> | |
| 84 | + ) : ( | |
| 85 | + <EmptyState compact>No ingest run recorded.</EmptyState> | |
| 86 | + )} | |
| 87 | + </Section> | |
| 88 | + <Section id="audit" kicker="Audit" title="Recent audit log"> | |
| 89 | + {o.auditRecent.length ? ( | |
| 90 | + <ul className="divide-y divide-rule text-[13px]"> | |
| 91 | + {o.auditRecent.map((a) => ( | |
| 92 | + <li key={a.id} className="py-1.5"> | |
| 93 | + <span className="ci-mono">{a.action}</span> by {a.actor} | |
| 94 | + {a.entity_type ? ( | |
| 95 | + <> | |
| 96 | + {' '} | |
| 97 | + on {a.entity_type} <span className="ci-mono">{a.entity_id}</span> | |
| 98 | + </> | |
| 99 | + ) : null} | |
| 100 | + {a.reason ? <span className="text-ink-3"> — {a.reason}</span> : null} | |
| 101 | + <span className="block text-[11.5px] text-ink-3">{fmtDateTime(a.created_at)}</span> | |
| 102 | + </li> | |
| 103 | + ))} | |
| 104 | + </ul> | |
| 105 | + ) : ( | |
| 106 | + <EmptyState compact>No audit entry yet.</EmptyState> | |
| 107 | + )} | |
| 108 | + </Section> | |
| 109 | + <KV items={[{ k: 'Environment', v: process.env.NODE_ENV ?? 'development' }, { k: 'API proxy', v: <span className="ci-mono">{process.env.CI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8251}`}</span> }]} /> | |
| 110 | + </div> | |
| 111 | + </div> | |
| 112 | + </div> | |
| 113 | + ); | |
| 114 | +} | |
added
apps/web/src/app/admin/rankings/page.tsx
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { Badge } from '@/components/ui/badge'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { listSnapshots } from '@/lib/queries/rankings'; | |
| 6 | +import { fmtDateTime, fmtInt, scopeLabel } from '@/lib/format'; | |
| 7 | + | |
| 8 | +export default async function AdminRankingsPage() { | |
| 9 | + const snaps = await listSnapshots(300); | |
| 10 | + return ( | |
| 11 | + <div className="space-y-6"> | |
| 12 | + <header className="pt-4"> | |
| 13 | + <h1 className="text-3xl">Ranking snapshots</h1> | |
| 14 | + <p className="mt-1 text-[13.5px] text-ink-2">Every snapshot ever generated (current and superseded). Run `pnpm cix rank` to compute.</p> | |
| 15 | + </header> | |
| 16 | + <Section id="snapshots" title={`${fmtInt(snaps.length)} snapshots`} kicker="Newest first"> | |
| 17 | + {snaps.length ? ( | |
| 18 | + <div className="ci-table-wrap"> | |
| 19 | + <table className="ci-table"> | |
| 20 | + <thead> | |
| 21 | + <tr> | |
| 22 | + <th className="num">Id</th> | |
| 23 | + <th>Metric</th> | |
| 24 | + <th>Scope</th> | |
| 25 | + <th>Formula</th> | |
| 26 | + <th className="num">Eligible</th> | |
| 27 | + <th className="num">Rows</th> | |
| 28 | + <th>Current</th> | |
| 29 | + <th>Generated</th> | |
| 30 | + <th>Inputs hash</th> | |
| 31 | + </tr> | |
| 32 | + </thead> | |
| 33 | + <tbody> | |
| 34 | + {snaps.map((s) => ( | |
| 35 | + <tr key={s.id}> | |
| 36 | + <td className="num">{s.id}</td> | |
| 37 | + <td> | |
| 38 | + <Link className="ci-link" href={`/rankings/${s.metric_slug}?scope=${encodeURIComponent(s.scope_key)}`}> | |
| 39 | + {s.metric_name} | |
| 40 | + </Link> | |
| 41 | + </td> | |
| 42 | + <td className="text-[12.5px]">{scopeLabel(s.scope_key)}</td> | |
| 43 | + <td className="ci-mono text-[11.5px]">{s.formula_version}</td> | |
| 44 | + <td className="num">{fmtInt(s.eligible_entities)}</td> | |
| 45 | + <td className="num">{fmtInt(s.row_count)}</td> | |
| 46 | + <td>{s.is_current ? <Badge tone="ok">current</Badge> : <Badge tone="outline">superseded</Badge>}</td> | |
| 47 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDateTime(s.generated_at)}</td> | |
| 48 | + <td className="ci-mono text-[11px] text-ink-3">{s.inputs_hash}</td> | |
| 49 | + </tr> | |
| 50 | + ))} | |
| 51 | + </tbody> | |
| 52 | + </table> | |
| 53 | + </div> | |
| 54 | + ) : ( | |
| 55 | + <EmptyState compact title="No snapshot">The ranking engine has not produced any snapshot on this environment.</EmptyState> | |
| 56 | + )} | |
| 57 | + </Section> | |
| 58 | + </div> | |
| 59 | + ); | |
| 60 | +} | |
added
apps/web/src/app/admin/runs/[runId]/page.tsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { notFound } from 'next/navigation'; | |
| 3 | +import { Section, KV } from '@/components/ui/section'; | |
| 4 | +import { Badge, StatusBadge } from '@/components/ui/badge'; | |
| 5 | +import { JsonView } from '@/components/ui/json-view'; | |
| 6 | +import { getRun } from '@/lib/queries/sources'; | |
| 7 | +import { fmtDateTime, fmtDuration, fmtInt } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export default async function RunPage({ params }: { params: Promise<{ runId: string }> }) { | |
| 10 | + const { runId } = await params; | |
| 11 | + const r = await getRun(runId); | |
| 12 | + if (!r) notFound(); | |
| 13 | + return ( | |
| 14 | + <div className="space-y-8"> | |
| 15 | + <header className="pt-4"> | |
| 16 | + <p className="ci-kicker">Ingest run</p> | |
| 17 | + <h1 className="ci-mono text-2xl font-sans">{r.id}</h1> | |
| 18 | + <p className="mt-1 flex flex-wrap items-center gap-2 text-[13px]"> | |
| 19 | + <Link className="ci-link" href={`/source/${r.source_slug}`}> | |
| 20 | + {r.source_name ?? r.connector_id} | |
| 21 | + </Link> | |
| 22 | + <StatusBadge status={r.status} /> | |
| 23 | + <Badge tone="outline">{r.mode}</Badge> | |
| 24 | + {r.anomaly ? <Badge tone="danger">anomaly: {r.anomaly}</Badge> : null} | |
| 25 | + </p> | |
| 26 | + </header> | |
| 27 | + <div className="grid gap-8 lg:grid-cols-[1fr_1fr]"> | |
| 28 | + <Section id="counts" kicker="Counts" title="Records and requests"> | |
| 29 | + <KV | |
| 30 | + items={[ | |
| 31 | + { k: 'Started', v: fmtDateTime(r.started_at) }, | |
| 32 | + { k: 'Finished', v: r.finished_at ? fmtDateTime(r.finished_at) : 'running' }, | |
| 33 | + { k: 'Duration', v: fmtDuration(r.duration_ms) }, | |
| 34 | + { k: 'Fetched', v: <span className="ci-num">{fmtInt(r.records_fetched)}</span> }, | |
| 35 | + { k: 'Created', v: <span className="ci-num">{fmtInt(r.records_created)}</span> }, | |
| 36 | + { k: 'Updated', v: <span className="ci-num">{fmtInt(r.records_updated)}</span> }, | |
| 37 | + { k: 'Unchanged', v: <span className="ci-num">{fmtInt(r.records_unchanged)}</span> }, | |
| 38 | + { k: 'Rejected', v: <span className="ci-num">{fmtInt(r.records_rejected)}</span> }, | |
| 39 | + { k: 'Validation failures', v: <span className="ci-num">{fmtInt(r.validation_failures)}</span> }, | |
| 40 | + { k: 'HTTP requests / failures', v: <span className="ci-num">{fmtInt(r.http_requests)} / {fmtInt(r.http_failures)}</span> }, | |
| 41 | + { k: 'Rate-limit events', v: <span className="ci-num">{fmtInt(r.rate_limit_events)}</span> }, | |
| 42 | + { k: 'Dataset version', v: r.dataset_version ? <span className="ci-mono">{r.dataset_version}</span> : null }, | |
| 43 | + { k: 'Error', v: r.error ? <span className="text-danger">{r.error}</span> : null }, | |
| 44 | + ]} | |
| 45 | + /> | |
| 46 | + </Section> | |
| 47 | + <Section id="cursor" kicker="Restartability" title="Cursor before / after"> | |
| 48 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 49 | + <div> | |
| 50 | + <p className="ci-kicker mb-1">Before</p> | |
| 51 | + <JsonView data={r.cursor_before} /> | |
| 52 | + </div> | |
| 53 | + <div> | |
| 54 | + <p className="ci-kicker mb-1">After</p> | |
| 55 | + <JsonView data={r.cursor_after} /> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + </Section> | |
| 59 | + </div> | |
| 60 | + <Section id="drift" kicker="Schema drift" title={`Drift signals (${r.schema_drift?.length ?? 0})`}> | |
| 61 | + {r.schema_drift?.length ? <JsonView data={r.schema_drift} /> : <p className="text-[13px] text-ink-3">No schema drift detected in this run.</p>} | |
| 62 | + </Section> | |
| 63 | + <Section id="log" kicker="Log" title={`Log (${r.log?.length ?? 0} lines)`}> | |
| 64 | + {r.log?.length ? ( | |
| 65 | + <pre className="ci-code max-h-[600px] overflow-auto"> | |
| 66 | + {r.log.map((l, i) => ( | |
| 67 | + <div key={i} className={l.level === 'error' ? 'text-danger' : l.level === 'warn' ? 'text-warn' : ''}> | |
| 68 | + <span className="text-ink-3">{l.t}</span> <span className="uppercase">{l.level.padEnd(5)}</span> {l.msg} | |
| 69 | + </div> | |
| 70 | + ))} | |
| 71 | + </pre> | |
| 72 | + ) : ( | |
| 73 | + <p className="text-[13px] text-ink-3">Empty log.</p> | |
| 74 | + )} | |
| 75 | + </Section> | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | +} | |
added
apps/web/src/app/admin/trace/page.tsx
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +import { traceValue } from '@cancerindex/ranking'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { JsonView } from '@/components/ui/json-view'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { db } from '@/lib/db'; | |
| 6 | +import { str, type SP } from '@/lib/search-params'; | |
| 7 | + | |
| 8 | +const TABLES = ['rankings', 'epidemiology_observations', 'survival_observations', 'cancer_gene_frequencies', 'literature_counts']; | |
| 9 | + | |
| 10 | +/** Lineage trace tool (§252-253): value → inputs → observation → provenance → raw record. */ | |
| 11 | +export default async function TracePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 12 | + const sp = await searchParams; | |
| 13 | + const table = str(sp, 'table'); | |
| 14 | + const id = str(sp, 'id'); | |
| 15 | + let result: Record<string, unknown> | null = null; | |
| 16 | + let error: string | null = null; | |
| 17 | + if (table && id) { | |
| 18 | + if (!TABLES.includes(table)) error = 'unsupported table'; | |
| 19 | + else if (!/^\d{1,12}$/.test(id)) error = 'id must be a numeric row id'; | |
| 20 | + else { | |
| 21 | + try { | |
| 22 | + result = await traceValue(db(), table, id); | |
| 23 | + } catch (e) { | |
| 24 | + error = (e as Error).message; | |
| 25 | + } | |
| 26 | + } | |
| 27 | + } | |
| 28 | + return ( | |
| 29 | + <div className="space-y-6"> | |
| 30 | + <header className="pt-4"> | |
| 31 | + <h1 className="text-3xl">Trace</h1> | |
| 32 | + <p className="mt-1 text-[13.5px] text-ink-2">Follow any derived or observed value back to its provenance row and raw source record.</p> | |
| 33 | + </header> | |
| 34 | + <form method="get" action="/admin/trace" className="flex flex-wrap items-end gap-2 text-[13.5px]"> | |
| 35 | + <label className="flex flex-col gap-1"> | |
| 36 | + <span className="ci-kicker">Table</span> | |
| 37 | + <select name="table" defaultValue={table || 'rankings'} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 38 | + {TABLES.map((t) => ( | |
| 39 | + <option key={t} value={t}> | |
| 40 | + {t} | |
| 41 | + </option> | |
| 42 | + ))} | |
| 43 | + </select> | |
| 44 | + </label> | |
| 45 | + <label className="flex flex-col gap-1"> | |
| 46 | + <span className="ci-kicker">Row id</span> | |
| 47 | + <input name="id" defaultValue={id} className="ci-mono w-40 border border-rule-strong bg-white px-2 py-1.5" placeholder="123" /> | |
| 48 | + </label> | |
| 49 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 50 | + Trace | |
| 51 | + </button> | |
| 52 | + </form> | |
| 53 | + {error ? <p className="text-[13px] text-danger">{error}</p> : null} | |
| 54 | + {result ? ( | |
| 55 | + <Section id="result" kicker={`${table} / ${id}`} title="Lineage"> | |
| 56 | + {'error' in result ? <EmptyState compact title={String(result.error)} /> : <JsonView data={result} />} | |
| 57 | + </Section> | |
| 58 | + ) : !table ? ( | |
| 59 | + <EmptyState compact title="Enter a table and row id">Ranking rows link here from every "Why this rank?" panel.</EmptyState> | |
| 60 | + ) : null} | |
| 61 | + </div> | |
| 62 | + ); | |
| 63 | +} | |
added
apps/web/src/app/admin/unresolved/page.tsx
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { Badge, MatchBadge, StatusBadge } from '@/components/ui/badge'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Pagination } from '@/components/ui/pagination'; | |
| 6 | +import { JsonView } from '@/components/ui/json-view'; | |
| 7 | +import { listUnresolved } from '@/lib/queries/admin'; | |
| 8 | +import { resolveAction, rejectAction } from '@/lib/admin/actions'; | |
| 9 | +import { fmtInt, fmtDateTime } from '@/lib/format'; | |
| 10 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 11 | + | |
| 12 | +const PAGE_SIZE = 50; | |
| 13 | + | |
| 14 | +export default async function UnresolvedPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 15 | + const sp = await searchParams; | |
| 16 | + const entityKind = str(sp, 'kind'); | |
| 17 | + const status = str(sp, 'status', 'open'); | |
| 18 | + const page = int(sp, 'page', 1, 1, 10_000); | |
| 19 | + const msg = str(sp, 'msg'); | |
| 20 | + const ok = str(sp, 'ok'); | |
| 21 | + const { rows, total, kinds } = await listUnresolved({ entityKind, status, page, pageSize: PAGE_SIZE }); | |
| 22 | + const href = (o: Record<string, string | number | null | undefined>) => `/admin/unresolved${withParams({ kind: entityKind, status }, o)}`; | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <div className="space-y-6"> | |
| 26 | + <header className="pt-4"> | |
| 27 | + <h1 className="text-3xl">Unresolved labels</h1> | |
| 28 | + <p className="mt-1 text-[13.5px] text-ink-2">Source labels that no identifier, alias or normalized string could map. Resolving adds an alias to the target entity, marks the label mapped and writes the audit log.</p> | |
| 29 | + </header> | |
| 30 | + {msg ? ( | |
| 31 | + <p role="status" className={`border-l-2 pl-3 text-[13px] ${ok === '1' ? 'border-ok text-ok' : 'border-danger text-danger'}`}> | |
| 32 | + {msg} | |
| 33 | + </p> | |
| 34 | + ) : null} | |
| 35 | + <div className="flex flex-wrap gap-4 text-[12.5px]"> | |
| 36 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 37 | + <span className="ci-kicker mr-1">Kind</span> | |
| 38 | + <Link href={href({ kind: '', page: '' })} className={`border px-2 py-0.5 no-underline ${!entityKind ? 'border-accent bg-accent-soft' : 'border-rule'}`}> | |
| 39 | + All | |
| 40 | + </Link> | |
| 41 | + {kinds.map((k) => ( | |
| 42 | + <Link key={k.k} href={href({ kind: k.k, page: '' })} className={`border px-2 py-0.5 no-underline ${entityKind === k.k ? 'border-accent bg-accent-soft' : 'border-rule'}`}> | |
| 43 | + {k.k} <span className="ci-num text-ink-3">{fmtInt(k.n)}</span> | |
| 44 | + </Link> | |
| 45 | + ))} | |
| 46 | + </div> | |
| 47 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 48 | + <span className="ci-kicker mr-1">Status</span> | |
| 49 | + {['open', 'mapped', 'rejected', 'ignored', ''].map((s) => ( | |
| 50 | + <Link key={s || 'all'} href={href({ status: s, page: '' })} className={`border px-2 py-0.5 no-underline ${status === s ? 'border-accent bg-accent-soft' : 'border-rule'}`}> | |
| 51 | + {s || 'all'} | |
| 52 | + </Link> | |
| 53 | + ))} | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + <Section id="queue" title={`${fmtInt(total)} labels`} kicker="Queue · sorted by occurrence count"> | |
| 57 | + {rows.length === 0 ? ( | |
| 58 | + <EmptyState compact title="Queue empty">No unresolved label matches these filters. Labels are enqueued by connectors when reconciliation fails.</EmptyState> | |
| 59 | + ) : ( | |
| 60 | + <> | |
| 61 | + <div className="ci-table-wrap"> | |
| 62 | + <table className="ci-table"> | |
| 63 | + <thead> | |
| 64 | + <tr> | |
| 65 | + <th className="num">Count</th> | |
| 66 | + <th>Label</th> | |
| 67 | + <th>Kind · source</th> | |
| 68 | + <th>Suggestion</th> | |
| 69 | + <th>Status</th> | |
| 70 | + <th>Actions</th> | |
| 71 | + </tr> | |
| 72 | + </thead> | |
| 73 | + <tbody> | |
| 74 | + {rows.map((u) => ( | |
| 75 | + <tr key={u.id}> | |
| 76 | + <td className="num font-medium">{fmtInt(u.count)}</td> | |
| 77 | + <td className="min-w-[220px]"> | |
| 78 | + <span className="font-medium">{u.source_text}</span> | |
| 79 | + <span className="ci-mono block text-[11px] text-ink-3">{u.normalized}</span> | |
| 80 | + {Object.keys(u.context ?? {}).length ? ( | |
| 81 | + <details className="text-[11.5px]"> | |
| 82 | + <summary className="ci-link">context</summary> | |
| 83 | + <JsonView data={u.context} /> | |
| 84 | + </details> | |
| 85 | + ) : null} | |
| 86 | + <span className="block text-[11px] text-ink-4">#{u.id} · {fmtDateTime(u.updated_at)}</span> | |
| 87 | + </td> | |
| 88 | + <td> | |
| 89 | + <Badge>{u.entity_kind}</Badge> <span className="ci-mono text-[11.5px]">{u.source_slug}</span> | |
| 90 | + </td> | |
| 91 | + <td className="min-w-[200px] text-[12.5px]"> | |
| 92 | + {u.suggested_id ? ( | |
| 93 | + <> | |
| 94 | + <span className="ci-mono">{u.suggested_id}</span> {u.suggested_name ? <span>— {u.suggested_name}</span> : null} | |
| 95 | + <span className="mt-0.5 flex items-center gap-1"> | |
| 96 | + <MatchBadge matchType={u.suggested_match_type} /> | |
| 97 | + {u.suggested_score != null ? <span className="ci-num text-[11px] text-ink-3">score {u.suggested_score.toFixed(2)}</span> : null} | |
| 98 | + </span> | |
| 99 | + </> | |
| 100 | + ) : ( | |
| 101 | + <span className="text-ink-4">no suggestion</span> | |
| 102 | + )} | |
| 103 | + </td> | |
| 104 | + <td> | |
| 105 | + <StatusBadge status={u.status} /> | |
| 106 | + {u.resolved_id ? <span className="ci-mono block text-[11px]">{u.resolved_id}</span> : null} | |
| 107 | + </td> | |
| 108 | + <td className="min-w-[280px]"> | |
| 109 | + {u.status === 'open' ? ( | |
| 110 | + <div className="space-y-1.5 text-[12.5px]"> | |
| 111 | + <form action={resolveAction} className="flex flex-wrap gap-1"> | |
| 112 | + <input type="hidden" name="id" value={u.id} /> | |
| 113 | + <input name="targetId" defaultValue={u.suggested_id ?? ''} placeholder="CI-CAN-00000000" className="ci-mono w-40 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Target entity id" required /> | |
| 114 | + <input name="reason" placeholder="reason (optional)" className="w-36 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Reason" /> | |
| 115 | + <button type="submit" className="border border-accent bg-accent-soft px-2 py-0.5 text-accent-2 hover:bg-accent hover:text-white"> | |
| 116 | + Resolve | |
| 117 | + </button> | |
| 118 | + </form> | |
| 119 | + <form action={rejectAction} className="flex flex-wrap gap-1"> | |
| 120 | + <input type="hidden" name="id" value={u.id} /> | |
| 121 | + <select name="status" className="border border-rule-strong bg-white px-1 py-0.5 text-[12px]" aria-label="Rejection status"> | |
| 122 | + <option value="rejected">rejected (not a {u.entity_kind})</option> | |
| 123 | + <option value="ignored">ignored</option> | |
| 124 | + </select> | |
| 125 | + <input name="reason" placeholder="reason (optional)" className="w-36 border border-rule-strong bg-white px-1.5 py-0.5 text-[12px]" aria-label="Reason" /> | |
| 126 | + <button type="submit" className="border border-rule px-2 py-0.5 text-ink-2 hover:border-danger hover:text-danger"> | |
| 127 | + Reject | |
| 128 | + </button> | |
| 129 | + </form> | |
| 130 | + </div> | |
| 131 | + ) : ( | |
| 132 | + <span className="text-[12px] text-ink-3">by {u.resolved_by ?? '—'}</span> | |
| 133 | + )} | |
| 134 | + </td> | |
| 135 | + </tr> | |
| 136 | + ))} | |
| 137 | + </tbody> | |
| 138 | + </table> | |
| 139 | + </div> | |
| 140 | + <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 141 | + </> | |
| 142 | + )} | |
| 143 | + </Section> | |
| 144 | + </div> | |
| 145 | + ); | |
| 146 | +} | |
added
apps/web/src/app/api/export/rankings.csv/route.ts
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import { getMetric, getSnapshot, rankingRows } from '@/lib/queries/rankings'; | |
| 2 | +import { sourceInfoById } from '@/lib/queries/provenance'; | |
| 3 | +import { SITE_URL } from '@/lib/site'; | |
| 4 | +import { isoDate, toDate } from '@/lib/format'; | |
| 5 | + | |
| 6 | +export const dynamic = 'force-dynamic'; | |
| 7 | + | |
| 8 | +function csvCell(v: unknown): string { | |
| 9 | + const s = v == null ? '' : String(v); | |
| 10 | + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * GET /api/export/rankings.csv?metric=<slug>&scope=<scope_key> | |
| 15 | + * CancerIndex-derived ranking snapshot as CSV. Attribution header rows precede the data (§ /data). | |
| 16 | + */ | |
| 17 | +export async function GET(req: Request) { | |
| 18 | + const url = new URL(req.url); | |
| 19 | + const metricSlug = url.searchParams.get('metric') ?? ''; | |
| 20 | + const scope = url.searchParams.get('scope'); | |
| 21 | + if (!/^[a-z0-9_]{2,64}$/.test(metricSlug)) return new Response('invalid metric', { status: 400 }); | |
| 22 | + const metric = await getMetric(metricSlug); | |
| 23 | + if (!metric) return new Response('unknown metric', { status: 404 }); | |
| 24 | + const snap = await getSnapshot(metricSlug, scope); | |
| 25 | + if (!snap) return new Response('no snapshot for this metric/scope', { status: 404 }); | |
| 26 | + const rows = await rankingRows(snap.id, 100_000); | |
| 27 | + const src = await sourceInfoById(snap.source_ids); | |
| 28 | + const sources = snap.source_ids.map((id) => src.get(id)?.name ?? id).join('; '); | |
| 29 | + | |
| 30 | + const header = [ | |
| 31 | + `# CancerIndex ranking export — ${metric.name}`, | |
| 32 | + `# Source: CancerIndex (${SITE_URL}) — derived data, CC BY 4.0. Underlying observations remain under their providers' licenses: ${sources || 'see methodology'}.`, | |
| 33 | + `# Metric: ${metric.slug} · formula: ${metric.formula} · formula_version: ${snap.formula_version}`, | |
| 34 | + `# Scope: ${snap.scope_key} · eligible_entities: ${snap.eligible_entities} · generated_at: ${toDate(snap.generated_at)?.toISOString() ?? String(snap.generated_at)} · inputs_hash: ${snap.inputs_hash}`, | |
| 35 | + `# Unit: ${metric.unit} · ${metric.higher_is_worse == null ? 'neutral direction' : metric.higher_is_worse ? 'higher is worse' : 'higher is better'} · Ties share a rank. Population statistics do not predict individual outcomes.`, | |
| 36 | + ]; | |
| 37 | + const cols = ['rank', 'previous_rank', 'cancer_id', 'slug', 'canonical_name', 'value', 'unit', 'percentile', 'confidence', 'eligible_entities', 'inputs_json']; | |
| 38 | + const lines = rows.map((r) => [r.rank, r.previous_rank ?? '', r.cancer_id, r.slug, r.canonical_name, r.value, r.unit, r.percentile, r.confidence, r.eligible_entities, JSON.stringify(r.inputs)].map(csvCell).join(',')); | |
| 39 | + const body = [...header, cols.join(','), ...lines].join('\n') + '\n'; | |
| 40 | + const fname = `cancerindex-${metric.slug}-${snap.scope_key.replace(/[^a-z0-9]+/gi, '_')}-${isoDate(snap.generated_at)}.csv`; | |
| 41 | + return new Response(body, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${fname}"`, 'Cache-Control': 'public, max-age=300' } }); | |
| 42 | +} | |
added
apps/web/src/app/api/search/route.ts
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { searchEntities } from '@/lib/queries/search'; | |
| 3 | + | |
| 4 | +export const dynamic = 'force-dynamic'; | |
| 5 | + | |
| 6 | +/** GET /api/search?q=…&limit=… — entity search for the command palette and /search. */ | |
| 7 | +export async function GET(req: Request) { | |
| 8 | + const url = new URL(req.url); | |
| 9 | + const q = (url.searchParams.get('q') ?? '').slice(0, 200); | |
| 10 | + const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 20) || 20)); | |
| 11 | + if (q.trim().length < 2) return NextResponse.json({ data: [], query: q }); | |
| 12 | + const data = await searchEntities(q, limit); | |
| 13 | + return NextResponse.json({ data, query: q }, { headers: { 'Cache-Control': 'public, max-age=30, s-maxage=120' } }); | |
| 14 | +} | |
added
apps/web/src/app/api/taxonomy/children/route.ts
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { childrenOf } from '@/lib/queries/taxonomy'; | |
| 3 | + | |
| 4 | +export const dynamic = 'force-dynamic'; | |
| 5 | + | |
| 6 | +/** Lazily fetch children of a node for the tree browser. GET /api/taxonomy/children?id=CI-CAN-…&type=oncotree */ | |
| 7 | +export async function GET(req: Request) { | |
| 8 | + const url = new URL(req.url); | |
| 9 | + const id = url.searchParams.get('id') ?? ''; | |
| 10 | + const type = url.searchParams.get('type') ?? 'ncit'; | |
| 11 | + if (!/^CI-CAN-\d{8,}$/.test(id)) return NextResponse.json({ error: 'invalid id' }, { status: 400 }); | |
| 12 | + if (!/^[a-z_]{2,24}$/.test(type)) return NextResponse.json({ error: 'invalid type' }, { status: 400 }); | |
| 13 | + const data = await childrenOf(id, type); | |
| 14 | + return NextResponse.json({ data }, { headers: { 'Cache-Control': 'public, max-age=300, s-maxage=3600' } }); | |
| 15 | +} | |
added
apps/web/src/app/cancer/[slug]/[[...tab]]/page.tsx
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 3 | +import { loadCancer, TABS, type TabKey } from '@/components/cancer/load'; | |
| 4 | +import { CancerHeader } from '@/components/cancer/header'; | |
| 5 | +import { OverviewTab } from '@/components/cancer/tabs/overview'; | |
| 6 | +import { StatisticsTab } from '@/components/cancer/tabs/statistics'; | |
| 7 | +import { SurvivalTab } from '@/components/cancer/tabs/survival'; | |
| 8 | +import { GenomicsTab } from '@/components/cancer/tabs/genomics'; | |
| 9 | +import { EvidenceTab } from '@/components/cancer/tabs/evidence'; | |
| 10 | +import { DrugsTab } from '@/components/cancer/tabs/drugs'; | |
| 11 | +import { TrialsTab } from '@/components/cancer/tabs/trials'; | |
| 12 | +import { ResearchTab } from '@/components/cancer/tabs/research'; | |
| 13 | +import { RankingsTab } from '@/components/cancer/tabs/rankings'; | |
| 14 | +import { SourcesTab } from '@/components/cancer/tabs/sources'; | |
| 15 | +import { jsonLd, medicalConditionLd } from '@/lib/seo'; | |
| 16 | +import { str, int, type SP } from '@/lib/search-params'; | |
| 17 | +import { truncate } from '@/lib/format'; | |
| 18 | + | |
| 19 | +export const revalidate = 3600; | |
| 20 | +export const dynamicParams = true; | |
| 21 | + | |
| 22 | +type Params = { slug: string; tab?: string[] }; | |
| 23 | + | |
| 24 | +function tabKey(tab: string[] | undefined): TabKey | null { | |
| 25 | + const t = tab?.[0] ?? 'overview'; | |
| 26 | + if (tab && tab.length > 1) return null; | |
| 27 | + return TABS.some((x) => x.key === t) ? (t as TabKey) : null; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> { | |
| 31 | + const { slug, tab } = await params; | |
| 32 | + const b = await loadCancer(slug); | |
| 33 | + if (!b) return { title: 'Not found' }; | |
| 34 | + const key = tabKey(tab) ?? 'overview'; | |
| 35 | + const label = TABS.find((t) => t.key === key)?.label ?? ''; | |
| 36 | + const desc = b.cancer.description ? truncate(b.cancer.description, 160) : `${b.cancer.canonical_name} (${b.cancer.id}): taxonomy, statistics, genomics, evidence, drugs, trials, literature and rankings with full provenance.`; | |
| 37 | + return { | |
| 38 | + title: key === 'overview' ? b.cancer.canonical_name : `${b.cancer.canonical_name} — ${label}`, | |
| 39 | + description: desc, | |
| 40 | + alternates: { canonical: key === 'overview' ? `/cancer/${b.cancer.slug}` : `/cancer/${b.cancer.slug}/${key}` }, | |
| 41 | + }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export default async function CancerPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) { | |
| 45 | + const { slug, tab } = await params; | |
| 46 | + const key = tabKey(tab); | |
| 47 | + if (!key) notFound(); | |
| 48 | + const b = await loadCancer(slug); | |
| 49 | + if (!b) notFound(); | |
| 50 | + if (b.cancer.slug !== slug) permanentRedirect(key === 'overview' ? `/cancer/${b.cancer.slug}` : `/cancer/${b.cancer.slug}/${key}`); | |
| 51 | + | |
| 52 | + let body: React.ReactNode; | |
| 53 | + switch (key) { | |
| 54 | + case 'statistics': | |
| 55 | + body = <StatisticsTab b={b} />; | |
| 56 | + break; | |
| 57 | + case 'survival': | |
| 58 | + body = <SurvivalTab b={b} />; | |
| 59 | + break; | |
| 60 | + case 'genomics': { | |
| 61 | + const sp = await searchParams; | |
| 62 | + body = <GenomicsTab b={b} cohort={str(sp, 'cohort') || null} />; | |
| 63 | + break; | |
| 64 | + } | |
| 65 | + case 'evidence': | |
| 66 | + body = <EvidenceTab b={b} />; | |
| 67 | + break; | |
| 68 | + case 'drugs': { | |
| 69 | + const sp = await searchParams; | |
| 70 | + body = <DrugsTab b={b} jurisdiction={str(sp, 'jurisdiction') || null} />; | |
| 71 | + break; | |
| 72 | + } | |
| 73 | + case 'trials': { | |
| 74 | + const sp = await searchParams; | |
| 75 | + body = <TrialsTab b={b} status={str(sp, 'status')} phase={str(sp, 'phase')} page={int(sp, 'page', 1, 1, 1000)} />; | |
| 76 | + break; | |
| 77 | + } | |
| 78 | + case 'research': | |
| 79 | + body = <ResearchTab b={b} />; | |
| 80 | + break; | |
| 81 | + case 'rankings': | |
| 82 | + body = <RankingsTab b={b} />; | |
| 83 | + break; | |
| 84 | + case 'sources': | |
| 85 | + body = <SourcesTab b={b} />; | |
| 86 | + break; | |
| 87 | + default: | |
| 88 | + body = <OverviewTab b={b} />; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const ld = medicalConditionLd({ slug: b.cancer.slug, canonicalName: b.cancer.canonical_name, description: b.cancer.description, aliases: b.aliases.filter((a) => a.alias_type !== 'preferred').map((a) => a.alias), codes: b.codes }); | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + <article> | |
| 95 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(ld) }} /> | |
| 96 | + <CancerHeader b={b} tab={key} /> | |
| 97 | + <div className="pt-6">{body}</div> | |
| 98 | + </article> | |
| 99 | + ); | |
| 100 | +} | |
added
apps/web/src/app/cancers/page.tsx
+192 −0
@@ -0,0 +1,192 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader } from '@/components/ui/section'; | |
| 4 | +import { Badge, ConfidenceBadge } from '@/components/ui/badge'; | |
| 5 | +import { CompletenessDots } from '@/components/ui/completeness'; | |
| 6 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 7 | +import { Pagination } from '@/components/ui/pagination'; | |
| 8 | +import { explorerCount, explorerRows, listAnatomicalSites, ENTITY_TYPES, SORTS, type ExplorerFilters, type ExplorerRow } from '@/lib/queries/cancers'; | |
| 9 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 10 | +import { str, int, oneOf, bool, withParams, type SP } from '@/lib/search-params'; | |
| 11 | + | |
| 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'; | |
| 14 | + | |
| 15 | +const PAGE_SIZE = 50; | |
| 16 | + | |
| 17 | +function dataConfidence(r: ExplorerRow): string { | |
| 18 | + const domains = [r.epidemiology_obs_count, r.survival_obs_count, r.gene_count, r.evidence_count, r.drug_count, r.active_trial_count, r.publication_count_5y].filter((n) => (n ?? 0) > 0).length; | |
| 19 | + if (domains >= 5) return 'HIGH'; | |
| 20 | + if (domains >= 3) return 'MEDIUM'; | |
| 21 | + if (domains >= 1) return 'LOW'; | |
| 22 | + return 'INSUFFICIENT_DATA'; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default async function CancersPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 26 | + const sp = await searchParams; | |
| 27 | + const f: ExplorerFilters = { | |
| 28 | + q: str(sp, 'q'), | |
| 29 | + level: oneOf(sp, 'level', ['top', 'all'] as const, 'all'), | |
| 30 | + entityType: ENTITY_TYPES.includes(str(sp, 'entity_type') as (typeof ENTITY_TYPES)[number]) ? str(sp, 'entity_type') : '', | |
| 31 | + malignant: bool(sp, 'malignant'), | |
| 32 | + hematologic: bool(sp, 'hematologic'), | |
| 33 | + pediatric: bool(sp, 'pediatric'), | |
| 34 | + site: str(sp, 'site'), | |
| 35 | + sort: oneOf(sp, 'sort', SORTS, 'name'), | |
| 36 | + page: int(sp, 'page', 1, 1, 10_000), | |
| 37 | + pageSize: PAGE_SIZE, | |
| 38 | + }; | |
| 39 | + const [total, rows, sites] = await Promise.all([explorerCount(f), explorerRows(f), listAnatomicalSites()]); | |
| 40 | + const current = { q: f.q, level: f.level, entity_type: f.entityType, malignant: f.malignant == null ? '' : f.malignant ? '1' : '0', hematologic: f.hematologic == null ? '' : f.hematologic ? '1' : '0', pediatric: f.pediatric == null ? '' : f.pediatric ? '1' : '0', site: f.site, sort: f.sort }; | |
| 41 | + const href = (o: Record<string, string | number | null | undefined>) => `/cancers${withParams({ ...current, level: f.level === 'all' ? '' : f.level }, o)}`; | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + <div> | |
| 45 | + <PageHeader kicker="Explorer" title="Cancers" lede="Every indexed disease entity from the canonical taxonomy. Counters aggregate over each entity's descendants; completeness dots show which data domains hold at least one record." /> | |
| 46 | + | |
| 47 | + <form method="get" action="/cancers" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr_auto]"> | |
| 48 | + <label className="flex flex-col gap-1"> | |
| 49 | + <span className="ci-kicker">Name, alias or code</span> | |
| 50 | + <input name="q" defaultValue={f.q} placeholder="e.g. glioblastoma, NSCLC, C4914" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 51 | + </label> | |
| 52 | + <label className="flex flex-col gap-1"> | |
| 53 | + <span className="ci-kicker">Level</span> | |
| 54 | + <select name="level" defaultValue={f.level} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 55 | + <option value="all">All entities</option> | |
| 56 | + <option value="top">Top-level (ranking set)</option> | |
| 57 | + </select> | |
| 58 | + </label> | |
| 59 | + <label className="flex flex-col gap-1"> | |
| 60 | + <span className="ci-kicker">Entity type</span> | |
| 61 | + <select name="entity_type" defaultValue={f.entityType} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 62 | + <option value="">Any</option> | |
| 63 | + {ENTITY_TYPES.map((t) => ( | |
| 64 | + <option key={t} value={t}> | |
| 65 | + {humanize(t)} | |
| 66 | + </option> | |
| 67 | + ))} | |
| 68 | + </select> | |
| 69 | + </label> | |
| 70 | + <label className="flex flex-col gap-1"> | |
| 71 | + <span className="ci-kicker">Anatomical site</span> | |
| 72 | + <select name="site" defaultValue={f.site} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 73 | + <option value="">Any</option> | |
| 74 | + {sites.map((s) => ( | |
| 75 | + <option key={s.slug} value={s.slug}> | |
| 76 | + {s.name} ({s.n}) | |
| 77 | + </option> | |
| 78 | + ))} | |
| 79 | + </select> | |
| 80 | + </label> | |
| 81 | + <label className="flex flex-col gap-1"> | |
| 82 | + <span className="ci-kicker">Sort</span> | |
| 83 | + <select name="sort" defaultValue={f.sort} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 84 | + <option value="name">Name</option> | |
| 85 | + <option value="trials">Active trials</option> | |
| 86 | + <option value="publications">Publications (5y)</option> | |
| 87 | + <option value="evidence">Evidence items</option> | |
| 88 | + <option value="descendants">Descendants</option> | |
| 89 | + </select> | |
| 90 | + </label> | |
| 91 | + <div className="flex flex-col justify-end gap-1"> | |
| 92 | + <div className="flex flex-wrap gap-x-3 gap-y-1 text-[12.5px]"> | |
| 93 | + <label className="inline-flex items-center gap-1"> | |
| 94 | + <input type="checkbox" name="malignant" value="1" defaultChecked={f.malignant === true} /> Malignant | |
| 95 | + </label> | |
| 96 | + <label className="inline-flex items-center gap-1"> | |
| 97 | + <input type="checkbox" name="hematologic" value="1" defaultChecked={f.hematologic === true} /> Hematologic | |
| 98 | + </label> | |
| 99 | + <label className="inline-flex items-center gap-1"> | |
| 100 | + <input type="checkbox" name="pediatric" value="1" defaultChecked={f.pediatric === true} /> Pediatric | |
| 101 | + </label> | |
| 102 | + </div> | |
| 103 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 104 | + Apply | |
| 105 | + </button> | |
| 106 | + </div> | |
| 107 | + </form> | |
| 108 | + | |
| 109 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 110 | + <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> {total === 1 ? 'entity' : 'entities'} | |
| 111 | + {f.q ? ( | |
| 112 | + <> | |
| 113 | + {' '} | |
| 114 | + matching <q>{f.q}</q> | |
| 115 | + </> | |
| 116 | + ) : null} | |
| 117 | + {f.level === 'top' && total === 0 ? <span className="text-ink-3"> — the top-level flag is assigned when the NCIt anchor concepts are ingested; until then browse all entities.</span> : null} | |
| 118 | + </p> | |
| 119 | + | |
| 120 | + {rows.length === 0 ? ( | |
| 121 | + <div className="mt-3"> | |
| 122 | + <EmptyState title="No entity matches these filters" knows={[{ label: 'Browse all entities', href: '/cancers' }, { label: 'Taxonomy tree', href: '/taxonomy' }]} /> | |
| 123 | + </div> | |
| 124 | + ) : ( | |
| 125 | + <> | |
| 126 | + <div className="ci-table-wrap mt-3"> | |
| 127 | + <table className="ci-table"> | |
| 128 | + <thead> | |
| 129 | + <tr> | |
| 130 | + <th className="sticky-col">Cancer</th> | |
| 131 | + <th>Type</th> | |
| 132 | + <th>Parent(s)</th> | |
| 133 | + <th className="num">Active trials (count)</th> | |
| 134 | + <th className="num">Publications 5y (count)</th> | |
| 135 | + <th className="num">Evidence items (count)</th> | |
| 136 | + <th className="num">Children</th> | |
| 137 | + <th>Completeness</th> | |
| 138 | + <th>Data confidence</th> | |
| 139 | + </tr> | |
| 140 | + </thead> | |
| 141 | + <tbody> | |
| 142 | + {rows.map((r) => ( | |
| 143 | + <tr key={r.id}> | |
| 144 | + <td className="sticky-col min-w-[220px]"> | |
| 145 | + <Link href={`/cancer/${r.slug}`} className="ci-link font-medium"> | |
| 146 | + {r.canonical_name} | |
| 147 | + </Link> | |
| 148 | + <span className="ci-mono ml-2 text-[10.5px] text-ink-4">{r.id}</span> | |
| 149 | + <span className="mt-0.5 flex flex-wrap gap-1"> | |
| 150 | + {r.hematologic ? <Badge>Hematologic</Badge> : null} | |
| 151 | + {r.pediatric_relevant ? <Badge>Pediatric</Badge> : null} | |
| 152 | + {r.rare_cancer === true ? <Badge tone="accent">Rare</Badge> : null} | |
| 153 | + {!r.malignant ? <Badge tone="outline">Non-malignant / precursor</Badge> : null} | |
| 154 | + </span> | |
| 155 | + </td> | |
| 156 | + <td> | |
| 157 | + <Badge>{humanize(r.entity_type)}</Badge> | |
| 158 | + </td> | |
| 159 | + <td className="max-w-[260px] text-[12.5px] text-ink-2"> | |
| 160 | + {r.parents?.length | |
| 161 | + ? r.parents.map((p, i) => ( | |
| 162 | + <span key={p.slug}> | |
| 163 | + {i > 0 ? ', ' : ''} | |
| 164 | + <Link className="ci-link" href={`/cancer/${p.slug}`}> | |
| 165 | + {p.name} | |
| 166 | + </Link> | |
| 167 | + </span> | |
| 168 | + )) | |
| 169 | + : <span className="text-ink-4">root</span>} | |
| 170 | + </td> | |
| 171 | + <td className="num">{r.active_trial_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.active_trial_count)}</td> | |
| 172 | + <td className="num">{r.publication_count_5y == null ? <span className="text-ink-4">—</span> : fmtInt(r.publication_count_5y)}</td> | |
| 173 | + <td className="num">{r.evidence_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.evidence_count)}</td> | |
| 174 | + <td className="num">{fmtInt(r.child_count)}</td> | |
| 175 | + <td> | |
| 176 | + <CompletenessDots filled={{ taxonomy: true, epidemiology: r.epidemiology_obs_count ?? 0, survival: r.survival_obs_count ?? 0, genomics: r.gene_count ?? 0, evidence: r.evidence_count ?? 0, drugs: r.drug_count ?? 0, trials: r.active_trial_count ?? 0, literature: r.publication_count_5y ?? 0 }} /> | |
| 177 | + </td> | |
| 178 | + <td> | |
| 179 | + <ConfidenceBadge level={dataConfidence(r)} /> | |
| 180 | + </td> | |
| 181 | + </tr> | |
| 182 | + ))} | |
| 183 | + </tbody> | |
| 184 | + </table> | |
| 185 | + </div> | |
| 186 | + <Pagination page={f.page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 187 | + <p className="mt-2 text-[11.5px] text-ink-3">"—" means no counter has been computed for this entity yet (counters appear after the first connector of that domain runs). Data confidence summarizes how many domains hold data; it is not a clinical judgement.</p> | |
| 188 | + </> | |
| 189 | + )} | |
| 190 | + </div> | |
| 191 | + ); | |
| 192 | +} | |
added
apps/web/src/app/data/page.tsx
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Download } from 'lucide-react'; | |
| 4 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { Badge } from '@/components/ui/badge'; | |
| 7 | +import { listMetrics, snapshotsForMetric } from '@/lib/queries/rankings'; | |
| 8 | +import { listSources } from '@/lib/queries/sources'; | |
| 9 | +import { jsonLd, datasetLd } from '@/lib/seo'; | |
| 10 | +import { SITE_URL } from '@/lib/site'; | |
| 11 | +import { fmtDate, fmtInt, scopeLabel } from '@/lib/format'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Data downloads', description: 'Legally redistributable CancerIndex datasets: derived ranking snapshots as CSV with attribution.' }; | |
| 14 | +export const revalidate = 600; | |
| 15 | + | |
| 16 | +export default async function DataPage() { | |
| 17 | + const [metrics, sources] = await Promise.all([listMetrics(), listSources()]); | |
| 18 | + const withSnaps = metrics.filter((m) => m.snapshot_count > 0); | |
| 19 | + const snapshotLists = await Promise.all(withSnaps.map((m) => snapshotsForMetric(m.slug))); | |
| 20 | + const datasets = withSnaps.flatMap((m, i) => (snapshotLists[i] ?? []).map((s) => ({ metric: m, snap: s }))); | |
| 21 | + const redistributable = sources.filter((s) => s.redistribution === 'allowed' || s.redistribution === 'attribution'); | |
| 22 | + const restricted = sources.filter((s) => !(s.redistribution === 'allowed' || s.redistribution === 'attribution')); | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <div> | |
| 26 | + {datasets.slice(0, 20).map(({ metric, snap }) => ( | |
| 27 | + <script | |
| 28 | + key={snap.id} | |
| 29 | + type="application/ld+json" | |
| 30 | + dangerouslySetInnerHTML={{ | |
| 31 | + __html: jsonLd( | |
| 32 | + datasetLd({ | |
| 33 | + name: `CancerIndex ranking — ${metric.name} (${scopeLabel(snap.scope_key)})`, | |
| 34 | + description: `${metric.description} Formula ${snap.formula_version}; ${snap.eligible_entities} eligible entities.`, | |
| 35 | + url: `${SITE_URL}/rankings/${metric.slug}?scope=${encodeURIComponent(snap.scope_key)}`, | |
| 36 | + distributionUrl: `${SITE_URL}/api/export/rankings.csv?metric=${metric.slug}&scope=${encodeURIComponent(snap.scope_key)}`, | |
| 37 | + license: 'https://creativecommons.org/licenses/by/4.0/', | |
| 38 | + dateModified: snap.generated_at, | |
| 39 | + }), | |
| 40 | + ), | |
| 41 | + }} | |
| 42 | + /> | |
| 43 | + ))} | |
| 44 | + <PageHeader kicker="Data" title="Downloads" lede="Only datasets CancerIndex may legally redistribute are offered here. Derived ranking snapshots are CancerIndex's own work (CC BY 4.0) and carry attribution rows for the underlying providers. Source datasets under restrictive terms are linked, not mirrored." /> | |
| 45 | + | |
| 46 | + <Section id="rankings" kicker="CancerIndex-derived" title="Ranking snapshots (CSV)" description="One file per metric and scope; header rows carry attribution, formula version, scope, generation time and inputs hash."> | |
| 47 | + {datasets.length ? ( | |
| 48 | + <div className="ci-table-wrap"> | |
| 49 | + <table className="ci-table"> | |
| 50 | + <thead> | |
| 51 | + <tr> | |
| 52 | + <th>Metric</th> | |
| 53 | + <th>Scope</th> | |
| 54 | + <th>Formula</th> | |
| 55 | + <th className="num">Rows</th> | |
| 56 | + <th>Generated</th> | |
| 57 | + <th>Download</th> | |
| 58 | + </tr> | |
| 59 | + </thead> | |
| 60 | + <tbody> | |
| 61 | + {datasets.map(({ metric, snap }) => ( | |
| 62 | + <tr key={snap.id}> | |
| 63 | + <td> | |
| 64 | + <Link className="ci-link" href={`/rankings/${metric.slug}?scope=${encodeURIComponent(snap.scope_key)}`}> | |
| 65 | + {metric.name} | |
| 66 | + </Link> | |
| 67 | + </td> | |
| 68 | + <td className="text-[12.5px]">{scopeLabel(snap.scope_key)}</td> | |
| 69 | + <td className="ci-mono text-[11.5px]">{snap.formula_version}</td> | |
| 70 | + <td className="num">{fmtInt(snap.eligible_entities)}</td> | |
| 71 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDate(snap.generated_at)}</td> | |
| 72 | + <td> | |
| 73 | + <a className="inline-flex items-center gap-1 text-[13px] text-accent" href={`/api/export/rankings.csv?metric=${metric.slug}&scope=${encodeURIComponent(snap.scope_key)}`}> | |
| 74 | + <Download className="h-3.5 w-3.5" aria-hidden /> CSV | |
| 75 | + </a> | |
| 76 | + </td> | |
| 77 | + </tr> | |
| 78 | + ))} | |
| 79 | + </tbody> | |
| 80 | + </table> | |
| 81 | + </div> | |
| 82 | + ) : ( | |
| 83 | + <EmptyState title="No ranking snapshot to download yet" knows={[{ label: 'Metric catalog', href: '/methodology#metrics' }, { label: 'Public API', href: '/developers' }]}> | |
| 84 | + Downloads appear as soon as the ranking engine produces its first snapshot. The CSV endpoint is <code className="ci-mono">/api/export/rankings.csv?metric=<slug>&scope=<scope_key></code>. | |
| 85 | + </EmptyState> | |
| 86 | + )} | |
| 87 | + </Section> | |
| 88 | + | |
| 89 | + <Section id="sources" kicker="Upstream" title="Source datasets" description="Redistribution terms as reviewed. Restricted sources are linked to their official download pages instead."> | |
| 90 | + <div className="grid gap-6 md:grid-cols-2"> | |
| 91 | + <div> | |
| 92 | + <p className="ci-kicker mb-1">Redistributable with attribution</p> | |
| 93 | + {redistributable.length ? ( | |
| 94 | + <ul className="divide-y divide-rule text-[13.5px]"> | |
| 95 | + {redistributable.map((s) => ( | |
| 96 | + <li key={s.slug} className="flex items-center justify-between gap-2 py-1.5"> | |
| 97 | + <Link className="ci-link" href={`/source/${s.slug}`}> | |
| 98 | + {s.name} | |
| 99 | + </Link> | |
| 100 | + <Badge tone="ok">{s.redistribution}</Badge> | |
| 101 | + </li> | |
| 102 | + ))} | |
| 103 | + </ul> | |
| 104 | + ) : ( | |
| 105 | + <p className="text-[13px] text-ink-3">None reviewed as redistributable yet.</p> | |
| 106 | + )} | |
| 107 | + </div> | |
| 108 | + <div> | |
| 109 | + <p className="ci-kicker mb-1">Restricted or under review</p> | |
| 110 | + {restricted.length ? ( | |
| 111 | + <ul className="divide-y divide-rule text-[13.5px]"> | |
| 112 | + {restricted.map((s) => ( | |
| 113 | + <li key={s.slug} className="flex items-center justify-between gap-2 py-1.5"> | |
| 114 | + <span> | |
| 115 | + <Link className="ci-link" href={`/source/${s.slug}`}> | |
| 116 | + {s.name} | |
| 117 | + </Link> | |
| 118 | + {s.homepage ? ( | |
| 119 | + <a className="ml-2 text-[12px] text-ink-3 hover:text-accent" href={s.homepage} target="_blank" rel="noopener noreferrer"> | |
| 120 | + official site | |
| 121 | + </a> | |
| 122 | + ) : null} | |
| 123 | + </span> | |
| 124 | + <Badge tone="warn">{s.redistribution}</Badge> | |
| 125 | + </li> | |
| 126 | + ))} | |
| 127 | + </ul> | |
| 128 | + ) : null} | |
| 129 | + </div> | |
| 130 | + </div> | |
| 131 | + </Section> | |
| 132 | + <Note>Attribution for derived files: "CancerIndex (cancerindex.io), CC BY 4.0; underlying data © respective providers." The public API at /api/v1 returns the same data with a sources array in every envelope.</Note> | |
| 133 | + </div> | |
| 134 | + ); | |
| 135 | +} | |
added
apps/web/src/app/developers/page.tsx
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 4 | +import { SITE_URL } from '@/lib/site'; | |
| 5 | + | |
| 6 | +export const metadata: Metadata = { title: 'Developers', description: 'CancerIndex public API: envelope, rate limits, examples and OpenAPI docs.' }; | |
| 7 | + | |
| 8 | +export default function DevelopersPage() { | |
| 9 | + return ( | |
| 10 | + <div className="ci-prose max-w-3xl"> | |
| 11 | + <PageHeader kicker="Developers" title="Public API" lede="Everything on the site is available as JSON under /api/v1, served by the CancerIndex API service and proxied through this host. Identifiers are stable; every response names its sources." /> | |
| 12 | + <Section id="docs" kicker="Reference" title="OpenAPI documentation"> | |
| 13 | + <p> | |
| 14 | + Interactive documentation is served by the API at{' '} | |
| 15 | + <a className="ci-link" href="/api/v1/docs"> | |
| 16 | + /api/v1/docs | |
| 17 | + </a> | |
| 18 | + . Base URL: <code>{SITE_URL}/api/v1</code>. | |
| 19 | + </p> | |
| 20 | + </Section> | |
| 21 | + <Section id="envelope" kicker="Format" title="Response envelope"> | |
| 22 | + <p>Every successful response wraps its payload with the sources that contributed and the data release, so attribution travels with the data:</p> | |
| 23 | + <pre className="ci-code">{`{ | |
| 24 | + "data": { ... } | [ ... ], | |
| 25 | + "sources": [ { "slug": "oncotree", "name": "OncoTree", "license": "CC BY 4.0", "retrievedAt": "2026-09-08T09:07:07Z" } ], | |
| 26 | + "dataRelease": "2026-09-08", | |
| 27 | + "meta": { "page": 1, "pageSize": 50, "total": 865 } // on paginated endpoints | |
| 28 | +}`}</pre> | |
| 29 | + <p>Errors return <code>{`{ "error": { "code": "not_found", "message": "…" } }`}</code> with the matching HTTP status.</p> | |
| 30 | + </Section> | |
| 31 | + <Section id="limits" kicker="Fair use" title="Rate limits"> | |
| 32 | + <ul> | |
| 33 | + <li>Anonymous: 60 requests per minute per IP.</li> | |
| 34 | + <li> | |
| 35 | + With an API key (<code>Authorization: Bearer …</code>): per-key limit according to tier (free 60/min, research and institutional tiers on request). | |
| 36 | + </li> | |
| 37 | + <li> | |
| 38 | + Responses include <code>X-RateLimit-Limit</code>, <code>X-RateLimit-Remaining</code> and <code>Retry-After</code> on 429. | |
| 39 | + </li> | |
| 40 | + </ul> | |
| 41 | + </Section> | |
| 42 | + <Section id="examples" kicker="Examples" title="curl"> | |
| 43 | + <pre className="ci-code">{`# One cancer entity with codes, aliases and counters | |
| 44 | +curl -s ${SITE_URL}/api/v1/cancers/diffuse-glioma | jq . | |
| 45 | + | |
| 46 | +# Search across entities | |
| 47 | +curl -s "${SITE_URL}/api/v1/search?q=glioma" | jq '.data[] | {type, title}' | |
| 48 | + | |
| 49 | +# Current ranking snapshot for a metric and scope | |
| 50 | +curl -s "${SITE_URL}/api/v1/rankings/active_trials?scope=geo=WORLD|sex=all|age=all|year=latest|level=top" | jq . | |
| 51 | + | |
| 52 | +# Same ranking as CSV with attribution rows (served by the web app) | |
| 53 | +curl -sL "${SITE_URL}/api/export/rankings.csv?metric=active_trials" -o active_trials.csv`}</pre> | |
| 54 | + </Section> | |
| 55 | + <Section id="identifiers" kicker="Identifiers" title="Stable identifiers"> | |
| 56 | + <p> | |
| 57 | + Public IDs look like <code>CI-CAN-00000123</code> (cancer), <code>CI-GENE-…</code>, <code>CI-VAR-…</code>, <code>CI-DRUG-…</code>, <code>CI-TRIAL-…</code>, <code>CI-PUB-…</code>, <code>CI-SOURCE-…</code>. They are minted once and never reused; merged entities redirect to the surviving record. Upstream identifiers (NCIt, OncoTree, ICD-10, DOID, HGNC, NCT, PMID) are returned alongside with their match type. | |
| 58 | + </p> | |
| 59 | + </Section> | |
| 60 | + <Note> | |
| 61 | + Terms: attribution required (see <Link className="ci-link" href="/data">Data</Link>); no medical advice; respect the upstream licenses listed on each <Link className="ci-link" href="/sources">source</Link>. | |
| 62 | + </Note> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | +} | |
added
apps/web/src/app/drug/[slug]/page.tsx
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { ApprovalsTable } from '@/components/data/approvals-table'; | |
| 10 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 11 | +import { TrialTable } from '@/components/data/trial-list'; | |
| 12 | +import { getDrugBySlug, approvalsForDrug } from '@/lib/queries/drugs'; | |
| 13 | +import { evidenceForDrug } from '@/lib/queries/evidence'; | |
| 14 | +import { trialsForDrug } from '@/lib/queries/trials'; | |
| 15 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 16 | +import { jsonLd, drugLd } from '@/lib/seo'; | |
| 17 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 18 | +import { str, type SP } from '@/lib/search-params'; | |
| 19 | + | |
| 20 | +export const revalidate = 3600; | |
| 21 | + | |
| 22 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 23 | + 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' }; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export default async function DrugPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) { | |
| 28 | + const { slug } = await params; | |
| 29 | + const d = await getDrugBySlug(slug); | |
| 30 | + if (!d) notFound(); | |
| 31 | + const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evidenceForDrug(d.id), trialsForDrug(d.id)]); | |
| 32 | + const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]); | |
| 33 | + const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); | |
| 34 | + const wanted = approvals.length ? str(await searchParams, 'jurisdiction') : ''; | |
| 35 | + const selected = jurisdictions.includes(wanted) ? wanted : null; | |
| 36 | + const shown = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals; | |
| 37 | + const aliases = d.aliases ?? []; | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <article> | |
| 41 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(drugLd({ slug: d.slug, name: d.name, description: d.description, mechanism: d.mechanism, aliases })) }} /> | |
| 42 | + <PageHeader kicker={`Drug${d.kind ? ` · ${humanize(d.kind)}` : ''}`} title={d.name} lede={d.description ?? undefined}> | |
| 43 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 44 | + <span className="ci-mono text-ink-3">{d.id}</span> | |
| 45 | + {d.ncit_code ? ( | |
| 46 | + <a className="ci-link inline-flex items-center gap-1" href={`https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${d.ncit_code}`} target="_blank" rel="noopener noreferrer"> | |
| 47 | + NCIt {d.ncit_code} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 48 | + </a> | |
| 49 | + ) : null} | |
| 50 | + {d.chembl_id ? ( | |
| 51 | + <a className="ci-link inline-flex items-center gap-1" href={`https://www.ebi.ac.uk/chembl/compound_report_card/${d.chembl_id}/`} target="_blank" rel="noopener noreferrer"> | |
| 52 | + {d.chembl_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 53 | + </a> | |
| 54 | + ) : null} | |
| 55 | + {d.drugbank_id ? ( | |
| 56 | + <a className="ci-link inline-flex items-center gap-1" href={`https://go.drugbank.com/drugs/${d.drugbank_id}`} target="_blank" rel="noopener noreferrer"> | |
| 57 | + {d.drugbank_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 58 | + </a> | |
| 59 | + ) : null} | |
| 60 | + {d.civic_therapy_id ? ( | |
| 61 | + <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/therapies/${d.civic_therapy_id}/summary`} target="_blank" rel="noopener noreferrer"> | |
| 62 | + CIViC <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 63 | + </a> | |
| 64 | + ) : null} | |
| 65 | + {d.development_status ? <Badge tone="outline">{humanize(d.development_status)}</Badge> : null} | |
| 66 | + </p> | |
| 67 | + </PageHeader> | |
| 68 | + | |
| 69 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 70 | + <div className="space-y-8"> | |
| 71 | + <Section id="approvals" kicker="Regulatory" title={`Approvals (${fmtInt(approvals.length)})`} description="Each record names the authority, jurisdiction, indication text and status. A drug approved in one jurisdiction for one indication is not 'approved' in general."> | |
| 72 | + {approvals.length ? ( | |
| 73 | + <> | |
| 74 | + <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'}`}> | |
| 76 | + All | |
| 77 | + </Link> | |
| 78 | + {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'}`}> | |
| 80 | + {j} | |
| 81 | + </Link> | |
| 82 | + ))} | |
| 83 | + </nav> | |
| 84 | + <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)} /> | |
| 86 | + </> | |
| 87 | + ) : ( | |
| 88 | + <EmptyState compact knows={[{ label: 'Curated evidence below' }, { label: 'Sources', href: '/sources' }]}> | |
| 89 | + No regulatory approval recorded. Absence here is not evidence of absence: only ingested jurisdictions are covered. | |
| 90 | + </EmptyState> | |
| 91 | + )} | |
| 92 | + </Section> | |
| 93 | + | |
| 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>} | |
| 96 | + </Section> | |
| 97 | + | |
| 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>} | |
| 100 | + </Section> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <aside className="space-y-8"> | |
| 104 | + <Section id="record" kicker="Molecule" title="Record" level={3}> | |
| 105 | + <KV | |
| 106 | + items={[ | |
| 107 | + { k: 'Kind', v: d.kind ? humanize(d.kind) : null }, | |
| 108 | + { k: 'Mechanism', v: d.mechanism }, | |
| 109 | + { k: 'Targets', v: d.target_gene_ids.length ? `${d.target_gene_ids.length} gene${d.target_gene_ids.length === 1 ? '' : 's'}` : null }, | |
| 110 | + { k: 'UNII', v: d.unii ? <span className="ci-mono">{d.unii}</span> : null }, | |
| 111 | + { k: 'PubChem CID', v: d.pubchem_cid ? <a className="ci-link ci-mono" href={`https://pubchem.ncbi.nlm.nih.gov/compound/${d.pubchem_cid}`} target="_blank" rel="noopener noreferrer">{d.pubchem_cid}</a> : null }, | |
| 112 | + { k: 'Aliases', v: aliases.length ? aliases.slice(0, 30).join(', ') + (aliases.length > 30 ? ` … (+${aliases.length - 30})` : '') : null }, | |
| 113 | + ]} | |
| 114 | + /> | |
| 115 | + <Freshness dataUpdatedAt={d.updated_at} /> | |
| 116 | + </Section> | |
| 117 | + <Note tone="warn">Regulatory status is jurisdiction-specific and time-bound. Nothing on this page is a treatment recommendation.</Note> | |
| 118 | + </aside> | |
| 119 | + </div> | |
| 120 | + </article> | |
| 121 | + ); | |
| 122 | +} | |
added
apps/web/src/app/drugs/page.tsx
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader } from '@/components/ui/section'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Badge } from '@/components/ui/badge'; | |
| 6 | +import { Pagination } from '@/components/ui/pagination'; | |
| 7 | +import { listDrugs } from '@/lib/queries/drugs'; | |
| 8 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 9 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 10 | + | |
| 11 | +export const metadata: Metadata = { title: 'Drugs', description: 'Oncology drugs with jurisdiction-aware approvals, curated evidence and trials.' }; | |
| 12 | +export const dynamic = 'force-dynamic'; | |
| 13 | +const PAGE_SIZE = 50; | |
| 14 | + | |
| 15 | +export default async function DrugsPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 16 | + const sp = await searchParams; | |
| 17 | + const q = str(sp, 'q'); | |
| 18 | + const kind = str(sp, 'kind'); | |
| 19 | + const page = int(sp, 'page', 1, 1, 100_000); | |
| 20 | + const { rows, total, kinds } = await listDrugs({ q, kind, page, pageSize: PAGE_SIZE }); | |
| 21 | + const href = (o: Record<string, string | number | null | undefined>) => `/drugs${withParams({ q, kind }, o)}`; | |
| 22 | + return ( | |
| 23 | + <div> | |
| 24 | + <PageHeader kicker="Drugs" title="Drugs" lede="One entity per molecule (INN preferred); brand names and development codes are aliases. Approvals are always shown with jurisdiction and indication." /> | |
| 25 | + <form method="get" action="/drugs" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]"> | |
| 26 | + <label className="flex flex-col gap-1"> | |
| 27 | + <span className="ci-kicker">Name or alias</span> | |
| 28 | + <input name="q" defaultValue={q} placeholder="e.g. osimertinib, Keytruda, AZD9291" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 29 | + </label> | |
| 30 | + <label className="flex flex-col gap-1"> | |
| 31 | + <span className="ci-kicker">Kind</span> | |
| 32 | + <select name="kind" defaultValue={kind} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 33 | + <option value="">Any</option> | |
| 34 | + {kinds.map((k) => ( | |
| 35 | + <option key={k.kind} value={k.kind}> | |
| 36 | + {humanize(k.kind)} ({k.n}) | |
| 37 | + </option> | |
| 38 | + ))} | |
| 39 | + </select> | |
| 40 | + </label> | |
| 41 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 42 | + Apply | |
| 43 | + </button> | |
| 44 | + </form> | |
| 45 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 46 | + <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> drugs | |
| 47 | + </p> | |
| 48 | + {rows.length === 0 ? ( | |
| 49 | + <div className="mt-3"> | |
| 50 | + <EmptyState title={total === 0 && !q && !kind ? 'Drugs not yet available' : 'No drug matches'} knows={[{ label: 'Cancers explorer', href: '/cancers' }, { label: 'Sources', href: '/sources' }]}> | |
| 51 | + {total === 0 && !q && !kind ? 'Drug entities are created by the CIViC therapy, NCIt and regulatory connectors; none has run on this environment yet.' : 'Try another name, brand or development code.'} | |
| 52 | + </EmptyState> | |
| 53 | + </div> | |
| 54 | + ) : ( | |
| 55 | + <> | |
| 56 | + <div className="ci-table-wrap mt-3"> | |
| 57 | + <table className="ci-table"> | |
| 58 | + <thead> | |
| 59 | + <tr> | |
| 60 | + <th>Drug</th> | |
| 61 | + <th>Kind</th> | |
| 62 | + <th>Mechanism</th> | |
| 63 | + <th className="num">Approvals</th> | |
| 64 | + <th className="num">Evidence items</th> | |
| 65 | + <th className="num">Trials</th> | |
| 66 | + <th>Identifiers</th> | |
| 67 | + </tr> | |
| 68 | + </thead> | |
| 69 | + <tbody> | |
| 70 | + {rows.map((d) => ( | |
| 71 | + <tr key={d.id}> | |
| 72 | + <td className="min-w-[180px]"> | |
| 73 | + <Link className="ci-link font-medium" href={`/drug/${d.slug}`}> | |
| 74 | + {d.name} | |
| 75 | + </Link> | |
| 76 | + </td> | |
| 77 | + <td>{d.kind ? <Badge>{humanize(d.kind)}</Badge> : '—'}</td> | |
| 78 | + <td className="max-w-[320px] text-[12.5px] text-ink-2">{d.mechanism ?? '—'}</td> | |
| 79 | + <td className="num">{fmtInt(d.approval_count ?? 0)}</td> | |
| 80 | + <td className="num">{fmtInt(d.evidence_count ?? 0)}</td> | |
| 81 | + <td className="num">{fmtInt(d.trial_count ?? 0)}</td> | |
| 82 | + <td className="ci-mono text-[11px] text-ink-3">{[d.ncit_code, d.chembl_id, d.drugbank_id].filter(Boolean).join(' · ') || '—'}</td> | |
| 83 | + </tr> | |
| 84 | + ))} | |
| 85 | + </tbody> | |
| 86 | + </table> | |
| 87 | + </div> | |
| 88 | + <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 89 | + </> | |
| 90 | + )} | |
| 91 | + </div> | |
| 92 | + ); | |
| 93 | +} | |
added
apps/web/src/app/error.tsx
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { | |
| 4 | + return ( | |
| 5 | + <div className="py-10"> | |
| 6 | + <p className="ci-kicker mb-2">Error</p> | |
| 7 | + <h1 className="text-3xl">Something went wrong rendering this page</h1> | |
| 8 | + <p className="mt-2 max-w-2xl text-[14px] text-ink-2">The database may be unreachable or the query failed. Nothing was invented to fill the gap; please retry.</p> | |
| 9 | + {error.digest ? <p className="ci-mono mt-2 text-[12px] text-ink-3">digest {error.digest}</p> : null} | |
| 10 | + <button type="button" onClick={reset} className="mt-4 border border-rule-strong px-3 py-1.5 text-[14px] hover:border-accent hover:text-accent"> | |
| 11 | + Retry | |
| 12 | + </button> | |
| 13 | + </div> | |
| 14 | + ); | |
| 15 | +} | |
added
apps/web/src/app/gene/[symbol]/page.tsx
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 10 | +import { FrequencyTables } from '@/components/data/frequency-table'; | |
| 11 | +import { getGeneBySymbol, variantsForGene, frequenciesForGene } from '@/lib/queries/genomics'; | |
| 12 | +import { evidenceForGene } from '@/lib/queries/evidence'; | |
| 13 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 14 | +import { recentPublicationsFor } from '@/lib/queries/publications'; | |
| 15 | +import { PublicationList } from '@/components/data/publication-list'; | |
| 16 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 17 | + | |
| 18 | +export const revalidate = 3600; | |
| 19 | + | |
| 20 | +export async function generateMetadata({ params }: { params: Promise<{ symbol: string }> }): Promise<Metadata> { | |
| 21 | + 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' }; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default async function GenePage({ params }: { params: Promise<{ symbol: string }> }) { | |
| 26 | + const { symbol } = await params; | |
| 27 | + const g = await getGeneBySymbol(symbol); | |
| 28 | + if (!g) notFound(); | |
| 29 | + 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)]); | |
| 31 | + 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 }); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <article> | |
| 37 | + <PageHeader kicker="Gene" title={<span className="ci-mono font-sans">{g.symbol}</span>} lede={g.name ?? undefined}> | |
| 38 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 39 | + <span className="ci-mono text-ink-3">{g.id}</span> | |
| 40 | + {g.hgnc_id ? ( | |
| 41 | + <a className="ci-link inline-flex items-center gap-1" href={`https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/${g.hgnc_id}`} target="_blank" rel="noopener noreferrer"> | |
| 42 | + {g.hgnc_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 43 | + </a> | |
| 44 | + ) : null} | |
| 45 | + {g.ensembl_gene_id ? ( | |
| 46 | + <a className="ci-link inline-flex items-center gap-1" href={`https://www.ensembl.org/Homo_sapiens/Gene/Summary?g=${g.ensembl_gene_id}`} target="_blank" rel="noopener noreferrer"> | |
| 47 | + {g.ensembl_gene_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 48 | + </a> | |
| 49 | + ) : null} | |
| 50 | + {g.ncbi_gene_id ? ( | |
| 51 | + <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/gene/${g.ncbi_gene_id}`} target="_blank" rel="noopener noreferrer"> | |
| 52 | + NCBI {g.ncbi_gene_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 53 | + </a> | |
| 54 | + ) : null} | |
| 55 | + {g.civic_gene_id ? ( | |
| 56 | + <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/genes/${g.civic_gene_id}/summary`} target="_blank" rel="noopener noreferrer"> | |
| 57 | + CIViC <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 58 | + </a> | |
| 59 | + ) : null} | |
| 60 | + {g.is_cancer_gene ? <Badge tone="accent" title="Has at least one curated cancer edge (derived flag)">Cancer gene</Badge> : null} | |
| 61 | + <Badge tone="outline">{g.status}</Badge> | |
| 62 | + </p> | |
| 63 | + </PageHeader> | |
| 64 | + | |
| 65 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 66 | + <div className="space-y-8"> | |
| 67 | + {g.description ? ( | |
| 68 | + <Section id="summary" kicker="Summary" title="Description"> | |
| 69 | + <p className="max-w-3xl text-[15px] leading-relaxed">{g.description}</p> | |
| 70 | + </Section> | |
| 71 | + ) : null} | |
| 72 | + | |
| 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 ? ( | |
| 75 | + <> | |
| 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> | |
| 85 | + ))} | |
| 86 | + </p> | |
| 87 | + ) : null} | |
| 88 | + <EvidenceTable items={evidence} prov={prov} showCancer /> | |
| 89 | + </> | |
| 90 | + ) : ( | |
| 91 | + <EmptyState compact>No curated evidence item involves this gene yet.</EmptyState> | |
| 92 | + )} | |
| 93 | + </Section> | |
| 94 | + | |
| 95 | + <Section id="frequencies" kicker="Cohorts" title={`Alteration frequency by cohort (${fmtInt(freqs.length)})`} description="Frequency = cases affected / cases profiled within one cohort. Cohorts are never pooled."> | |
| 96 | + {freqs.length ? <FrequencyTables rows={freqs} prov={prov} showCancer /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>} | |
| 97 | + </Section> | |
| 98 | + | |
| 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>} | |
| 101 | + </Section> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <aside className="space-y-8"> | |
| 105 | + <Section id="identity" kicker="HGNC" title="Record" level={3}> | |
| 106 | + <KV | |
| 107 | + items={[ | |
| 108 | + { k: 'Location', v: g.location ? <span className="ci-mono">{g.location}</span> : null }, | |
| 109 | + { k: 'Chromosome', v: g.chromosome }, | |
| 110 | + { k: 'Locus type', v: g.locus_type }, | |
| 111 | + { k: 'Locus group', v: g.locus_group }, | |
| 112 | + { k: 'Previous symbols', v: g.prev_symbols.length ? g.prev_symbols.join(', ') : null }, | |
| 113 | + { k: 'Alias symbols', v: g.alias_symbols.length ? g.alias_symbols.join(', ') : null }, | |
| 114 | + { k: 'Gene families', v: g.gene_families.length ? g.gene_families.join('; ') : null }, | |
| 115 | + { k: 'UniProt', v: g.uniprot_ids.length ? g.uniprot_ids.join(', ') : null }, | |
| 116 | + { k: 'OMIM', v: g.omim_ids.length ? g.omim_ids.join(', ') : null }, | |
| 117 | + { k: 'RefSeq', v: g.refseq_accession }, | |
| 118 | + ]} | |
| 119 | + /> | |
| 120 | + <Freshness dataUpdatedAt={g.updated_at} extra="source: hgnc" /> | |
| 121 | + </Section> | |
| 122 | + <Section id="variants" kicker="Variants" title={`Variants (${fmtInt(variants.length)})`} level={3}> | |
| 123 | + {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> | |
| 137 | + ) : ( | |
| 138 | + <p className="text-[13px] text-ink-3">No variant entity recorded for this gene.</p> | |
| 139 | + )} | |
| 140 | + </Section> | |
| 141 | + <Note>Gene-level pages aggregate curated evidence across cancers; the cancer context of each item is shown in the table and must not be generalized.</Note> | |
| 142 | + </aside> | |
| 143 | + </div> | |
| 144 | + </article> | |
| 145 | + ); | |
| 146 | +} | |
added
apps/web/src/app/genes/page.tsx
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader } from '@/components/ui/section'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Badge } from '@/components/ui/badge'; | |
| 6 | +import { Pagination } from '@/components/ui/pagination'; | |
| 7 | +import { listGenes } from '@/lib/queries/genomics'; | |
| 8 | +import { fmtInt } from '@/lib/format'; | |
| 9 | +import { str, int, bool, withParams, type SP } from '@/lib/search-params'; | |
| 10 | + | |
| 11 | +export const metadata: Metadata = { title: 'Genes', description: 'HGNC genes with curated cancer evidence, variants and cohort frequencies.' }; | |
| 12 | +export const dynamic = 'force-dynamic'; | |
| 13 | +const PAGE_SIZE = 50; | |
| 14 | + | |
| 15 | +export default async function GenesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 16 | + const sp = await searchParams; | |
| 17 | + const q = str(sp, 'q'); | |
| 18 | + const cancerOnly = bool(sp, 'cancer') ?? false; | |
| 19 | + const page = int(sp, 'page', 1, 1, 100_000); | |
| 20 | + const { rows, total } = await listGenes({ q, cancerOnly, page, pageSize: PAGE_SIZE }); | |
| 21 | + const href = (o: Record<string, string | number | null | undefined>) => `/genes${withParams({ q, cancer: cancerOnly ? '1' : '' }, o)}`; | |
| 22 | + return ( | |
| 23 | + <div> | |
| 24 | + <PageHeader kicker="Genes" title="Genes" lede="Gene symbols and names follow HGNC. 'Cancer gene' means the gene has at least one curated cancer edge — a derived flag, not a biological verdict." /> | |
| 25 | + <form method="get" action="/genes" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]"> | |
| 26 | + <label className="flex flex-col gap-1"> | |
| 27 | + <span className="ci-kicker">Symbol, alias or name</span> | |
| 28 | + <input name="q" defaultValue={q} placeholder="e.g. KRAS, HER2, tumor protein" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 29 | + </label> | |
| 30 | + <label className="inline-flex items-center gap-1 pb-2"> | |
| 31 | + <input type="checkbox" name="cancer" value="1" defaultChecked={cancerOnly} /> Cancer genes only | |
| 32 | + </label> | |
| 33 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 34 | + Apply | |
| 35 | + </button> | |
| 36 | + </form> | |
| 37 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 38 | + <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> genes | |
| 39 | + </p> | |
| 40 | + {rows.length === 0 ? ( | |
| 41 | + <div className="mt-3"> | |
| 42 | + <EmptyState title={total === 0 && !q ? 'Genes not yet available' : 'No gene matches'} knows={[{ label: 'Cancers explorer', href: '/cancers' }, { label: 'Sources', href: '/sources' }]}> | |
| 43 | + {total === 0 && !q ? 'The HGNC connector has not run on this environment. Genes appear here once ingested, with CIViC evidence and GDC cohort frequencies attached.' : 'Try a different symbol or alias.'} | |
| 44 | + </EmptyState> | |
| 45 | + </div> | |
| 46 | + ) : ( | |
| 47 | + <> | |
| 48 | + <div className="ci-table-wrap mt-3"> | |
| 49 | + <table className="ci-table"> | |
| 50 | + <thead> | |
| 51 | + <tr> | |
| 52 | + <th>Symbol</th> | |
| 53 | + <th>Name</th> | |
| 54 | + <th>Location</th> | |
| 55 | + <th>Locus type</th> | |
| 56 | + <th className="num">Evidence items</th> | |
| 57 | + <th className="num">Variants</th> | |
| 58 | + <th>Flags</th> | |
| 59 | + </tr> | |
| 60 | + </thead> | |
| 61 | + <tbody> | |
| 62 | + {rows.map((g) => ( | |
| 63 | + <tr key={g.id}> | |
| 64 | + <td> | |
| 65 | + <Link className="ci-mono ci-link font-medium" href={`/gene/${g.symbol}`}> | |
| 66 | + {g.symbol} | |
| 67 | + </Link> | |
| 68 | + </td> | |
| 69 | + <td className="min-w-[220px]">{g.name ?? '—'}</td> | |
| 70 | + <td className="ci-mono text-[12px]">{g.location ?? '—'}</td> | |
| 71 | + <td className="text-[12.5px]">{g.locus_type ?? '—'}</td> | |
| 72 | + <td className="num">{fmtInt(g.evidence_count ?? 0)}</td> | |
| 73 | + <td className="num">{fmtInt(g.variant_count ?? 0)}</td> | |
| 74 | + <td>{g.is_cancer_gene ? <Badge tone="accent">Cancer gene</Badge> : null}</td> | |
| 75 | + </tr> | |
| 76 | + ))} | |
| 77 | + </tbody> | |
| 78 | + </table> | |
| 79 | + </div> | |
| 80 | + <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 81 | + </> | |
| 82 | + )} | |
| 83 | + </div> | |
| 84 | + ); | |
| 85 | +} | |
added
apps/web/src/app/globals.css
+324 −0
@@ -0,0 +1,324 @@ | ||
| 1 | +@import 'tailwindcss'; | |
| 2 | + | |
| 3 | +/* | |
| 4 | + CancerIndex design language (CLAUDE.md UI rules): scientific / editorial / institutional / dense. | |
| 5 | + Off-white paper, deep charcoal ink, muted neutrals, ONE restrained accent (deep teal) for links and | |
| 6 | + active states, thin rules, tabular numerals. No gradients, no card grids, no decorative colour. | |
| 7 | + Colour is never the sole carrier of meaning: every badge also carries text. | |
| 8 | +*/ | |
| 9 | + | |
| 10 | +@theme { | |
| 11 | + --font-sans: var(--font-ui), ui-sans-serif, system-ui, sans-serif; | |
| 12 | + --font-serif: var(--font-display), 'Iowan Old Style', Georgia, serif; | |
| 13 | + --font-mono: var(--font-code), ui-monospace, 'SF Mono', Menlo, monospace; | |
| 14 | + | |
| 15 | + --color-paper: #fafaf7; | |
| 16 | + --color-paper-2: #f3f3ee; | |
| 17 | + --color-paper-3: #ebebe4; | |
| 18 | + --color-ink: #1c1c1a; | |
| 19 | + --color-ink-2: #4a4a46; | |
| 20 | + --color-ink-3: #7b7b75; | |
| 21 | + --color-ink-4: #a8a8a1; | |
| 22 | + --color-rule: #dcdcd4; | |
| 23 | + --color-rule-strong: #b8b8ae; | |
| 24 | + --color-accent: #0f5f63; | |
| 25 | + --color-accent-2: #0b4a4d; | |
| 26 | + --color-accent-soft: #e2eeee; | |
| 27 | + --color-warn: #8a4b0a; | |
| 28 | + --color-warn-soft: #f6ead8; | |
| 29 | + --color-danger: #8b1e2d; | |
| 30 | + --color-danger-soft: #f4e1e4; | |
| 31 | + --color-ok: #2d5f2e; | |
| 32 | + --color-ok-soft: #e1ecdf; | |
| 33 | + | |
| 34 | + --radius-sm: 2px; | |
| 35 | + --radius-md: 3px; | |
| 36 | +} | |
| 37 | + | |
| 38 | +:root { | |
| 39 | + color-scheme: light; | |
| 40 | + --ci-header-h: 56px; | |
| 41 | +} | |
| 42 | + | |
| 43 | +html { | |
| 44 | + background: var(--color-paper); | |
| 45 | + color: var(--color-ink); | |
| 46 | + font-family: var(--font-sans); | |
| 47 | + font-size: 15px; | |
| 48 | + line-height: 1.5; | |
| 49 | + -webkit-text-size-adjust: 100%; | |
| 50 | + scroll-padding-top: calc(var(--ci-header-h) + 56px); | |
| 51 | +} | |
| 52 | + | |
| 53 | +body { | |
| 54 | + font-variant-numeric: tabular-nums; | |
| 55 | + font-feature-settings: 'tnum' 1, 'cv11' 1; | |
| 56 | + text-rendering: optimizeLegibility; | |
| 57 | +} | |
| 58 | + | |
| 59 | +h1, | |
| 60 | +h2, | |
| 61 | +h3, | |
| 62 | +.font-display { | |
| 63 | + font-family: var(--font-serif); | |
| 64 | + font-weight: 500; | |
| 65 | + letter-spacing: -0.005em; | |
| 66 | + text-wrap: balance; | |
| 67 | +} | |
| 68 | + | |
| 69 | +a { | |
| 70 | + text-underline-offset: 2px; | |
| 71 | +} | |
| 72 | + | |
| 73 | +:focus-visible { | |
| 74 | + outline: 2px solid var(--color-accent); | |
| 75 | + outline-offset: 2px; | |
| 76 | +} | |
| 77 | + | |
| 78 | +::selection { | |
| 79 | + background: var(--color-accent-soft); | |
| 80 | +} | |
| 81 | + | |
| 82 | +/* | |
| 83 | + Grid children default to min-width:auto, so a wide SVG, a long identifier or a mono formula | |
| 84 | + would stretch a column past the viewport on mobile. Let columns shrink; inner tables scroll | |
| 85 | + inside .ci-table-wrap and long strings wrap through overflow-wrap. | |
| 86 | +*/ | |
| 87 | +.grid > * { | |
| 88 | + min-width: 0; | |
| 89 | +} | |
| 90 | +code, | |
| 91 | +kbd, | |
| 92 | +.ci-mono { | |
| 93 | + overflow-wrap: anywhere; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/* --- Utility classes shared by components (kept minimal; Tailwind does the rest) --- */ | |
| 97 | + | |
| 98 | +.ci-link { | |
| 99 | + color: var(--color-accent); | |
| 100 | + text-decoration: underline; | |
| 101 | + text-decoration-color: color-mix(in oklab, var(--color-accent) 35%, transparent); | |
| 102 | +} | |
| 103 | +.ci-link:hover { | |
| 104 | + text-decoration-color: var(--color-accent); | |
| 105 | +} | |
| 106 | + | |
| 107 | +.ci-rule { | |
| 108 | + border-top: 1px solid var(--color-rule); | |
| 109 | +} | |
| 110 | + | |
| 111 | +.ci-kicker { | |
| 112 | + font-family: var(--font-sans); | |
| 113 | + font-size: 11px; | |
| 114 | + font-weight: 600; | |
| 115 | + letter-spacing: 0.08em; | |
| 116 | + text-transform: uppercase; | |
| 117 | + color: var(--color-ink-3); | |
| 118 | +} | |
| 119 | + | |
| 120 | +.ci-mono { | |
| 121 | + font-family: var(--font-mono); | |
| 122 | + font-size: 0.86em; | |
| 123 | + letter-spacing: 0; | |
| 124 | +} | |
| 125 | + | |
| 126 | +.ci-num { | |
| 127 | + font-variant-numeric: tabular-nums; | |
| 128 | + text-align: right; | |
| 129 | + white-space: nowrap; | |
| 130 | +} | |
| 131 | + | |
| 132 | +/* Dense data table */ | |
| 133 | +.ci-table-wrap { | |
| 134 | + overflow-x: auto; | |
| 135 | + -webkit-overflow-scrolling: touch; | |
| 136 | + border-top: 1px solid var(--color-rule-strong); | |
| 137 | + border-bottom: 1px solid var(--color-rule); | |
| 138 | +} | |
| 139 | +.ci-table { | |
| 140 | + width: 100%; | |
| 141 | + min-width: 100%; | |
| 142 | + border-collapse: separate; | |
| 143 | + border-spacing: 0; | |
| 144 | + font-size: 13.5px; | |
| 145 | +} | |
| 146 | +.ci-table thead th { | |
| 147 | + position: sticky; | |
| 148 | + top: 0; | |
| 149 | + z-index: 1; | |
| 150 | + background: var(--color-paper); | |
| 151 | + border-bottom: 1px solid var(--color-rule-strong); | |
| 152 | + padding: 8px 10px; | |
| 153 | + text-align: left; | |
| 154 | + font-family: var(--font-sans); | |
| 155 | + font-size: 11px; | |
| 156 | + font-weight: 600; | |
| 157 | + letter-spacing: 0.06em; | |
| 158 | + text-transform: uppercase; | |
| 159 | + color: var(--color-ink-3); | |
| 160 | + white-space: nowrap; | |
| 161 | +} | |
| 162 | +.ci-table thead th.num, | |
| 163 | +.ci-table td.num { | |
| 164 | + text-align: right; | |
| 165 | +} | |
| 166 | +.ci-table tbody td { | |
| 167 | + padding: 7px 10px; | |
| 168 | + border-bottom: 1px solid var(--color-rule); | |
| 169 | + vertical-align: top; | |
| 170 | +} | |
| 171 | +.ci-table tbody tr:hover td { | |
| 172 | + background: var(--color-paper-2); | |
| 173 | +} | |
| 174 | +.ci-table th.sticky-col, | |
| 175 | +.ci-table td.sticky-col { | |
| 176 | + position: sticky; | |
| 177 | + left: 0; | |
| 178 | + background: var(--color-paper); | |
| 179 | + z-index: 2; | |
| 180 | +} | |
| 181 | + | |
| 182 | +/* Sticky in-page tabs */ | |
| 183 | +.ci-tabs { | |
| 184 | + position: sticky; | |
| 185 | + top: var(--ci-header-h); | |
| 186 | + z-index: 20; | |
| 187 | + background: color-mix(in oklab, var(--color-paper) 94%, transparent); | |
| 188 | + backdrop-filter: saturate(120%) blur(6px); | |
| 189 | + border-bottom: 1px solid var(--color-rule); | |
| 190 | + overflow-x: auto; | |
| 191 | + scrollbar-width: none; | |
| 192 | +} | |
| 193 | +.ci-tabs::-webkit-scrollbar { | |
| 194 | + display: none; | |
| 195 | +} | |
| 196 | +.ci-tab { | |
| 197 | + display: inline-block; | |
| 198 | + padding: 10px 2px; | |
| 199 | + margin-right: 18px; | |
| 200 | + font-size: 13.5px; | |
| 201 | + color: var(--color-ink-2); | |
| 202 | + border-bottom: 2px solid transparent; | |
| 203 | + white-space: nowrap; | |
| 204 | +} | |
| 205 | +.ci-tab:hover { | |
| 206 | + color: var(--color-ink); | |
| 207 | +} | |
| 208 | +.ci-tab[aria-current='page'] { | |
| 209 | + color: var(--color-accent); | |
| 210 | + border-bottom-color: var(--color-accent); | |
| 211 | + font-weight: 500; | |
| 212 | +} | |
| 213 | + | |
| 214 | +/* Popover used for provenance (CSS-only, hover + focus) */ | |
| 215 | +.ci-pop { | |
| 216 | + position: relative; | |
| 217 | + display: inline-block; | |
| 218 | +} | |
| 219 | +.ci-pop > .ci-pop-panel { | |
| 220 | + display: none; | |
| 221 | + position: absolute; | |
| 222 | + z-index: 40; | |
| 223 | + left: 0; | |
| 224 | + top: calc(100% + 4px); | |
| 225 | + min-width: 260px; | |
| 226 | + max-width: 360px; | |
| 227 | + background: #fff; | |
| 228 | + border: 1px solid var(--color-rule-strong); | |
| 229 | + box-shadow: 0 6px 24px -10px rgba(0, 0, 0, 0.25); | |
| 230 | + padding: 10px 12px; | |
| 231 | + font-size: 12.5px; | |
| 232 | + line-height: 1.45; | |
| 233 | + text-align: left; | |
| 234 | + white-space: normal; | |
| 235 | + font-weight: 400; | |
| 236 | + text-transform: none; | |
| 237 | + letter-spacing: 0; | |
| 238 | + color: var(--color-ink); | |
| 239 | +} | |
| 240 | +.ci-pop:hover > .ci-pop-panel, | |
| 241 | +.ci-pop:focus-within > .ci-pop-panel { | |
| 242 | + display: block; | |
| 243 | +} | |
| 244 | +@media (max-width: 640px) { | |
| 245 | + .ci-pop > .ci-pop-panel { | |
| 246 | + position: fixed; | |
| 247 | + left: 12px; | |
| 248 | + right: 12px; | |
| 249 | + top: auto; | |
| 250 | + bottom: 12px; | |
| 251 | + max-width: none; | |
| 252 | + } | |
| 253 | +} | |
| 254 | + | |
| 255 | +/* Completeness dots */ | |
| 256 | +.ci-dots { | |
| 257 | + display: inline-flex; | |
| 258 | + gap: 3px; | |
| 259 | + vertical-align: middle; | |
| 260 | +} | |
| 261 | +.ci-dot { | |
| 262 | + width: 7px; | |
| 263 | + height: 7px; | |
| 264 | + border-radius: 50%; | |
| 265 | + border: 1px solid var(--color-ink-4); | |
| 266 | + background: transparent; | |
| 267 | +} | |
| 268 | +.ci-dot.on { | |
| 269 | + background: var(--color-accent); | |
| 270 | + border-color: var(--color-accent); | |
| 271 | +} | |
| 272 | + | |
| 273 | +details > summary { | |
| 274 | + cursor: pointer; | |
| 275 | + list-style: none; | |
| 276 | +} | |
| 277 | +details > summary::-webkit-details-marker { | |
| 278 | + display: none; | |
| 279 | +} | |
| 280 | + | |
| 281 | +.ci-prose p + p { | |
| 282 | + margin-top: 0.75em; | |
| 283 | +} | |
| 284 | +.ci-prose h2 { | |
| 285 | + font-size: 1.35rem; | |
| 286 | + margin-top: 2rem; | |
| 287 | + margin-bottom: 0.5rem; | |
| 288 | +} | |
| 289 | +.ci-prose h3 { | |
| 290 | + font-size: 1.1rem; | |
| 291 | + margin-top: 1.4rem; | |
| 292 | + margin-bottom: 0.35rem; | |
| 293 | +} | |
| 294 | +.ci-prose ul { | |
| 295 | + list-style: disc; | |
| 296 | + padding-left: 1.25rem; | |
| 297 | + margin: 0.5rem 0; | |
| 298 | +} | |
| 299 | +.ci-prose li + li { | |
| 300 | + margin-top: 0.25rem; | |
| 301 | +} | |
| 302 | +.ci-prose code { | |
| 303 | + font-family: var(--font-mono); | |
| 304 | + font-size: 0.85em; | |
| 305 | + background: var(--color-paper-3); | |
| 306 | + padding: 0 4px; | |
| 307 | +} | |
| 308 | + | |
| 309 | +pre.ci-code { | |
| 310 | + font-family: var(--font-mono); | |
| 311 | + font-size: 12.5px; | |
| 312 | + line-height: 1.5; | |
| 313 | + background: var(--color-paper-2); | |
| 314 | + border: 1px solid var(--color-rule); | |
| 315 | + padding: 10px 12px; | |
| 316 | + overflow-x: auto; | |
| 317 | + white-space: pre; | |
| 318 | +} | |
| 319 | + | |
| 320 | +@media print { | |
| 321 | + .no-print { | |
| 322 | + display: none !important; | |
| 323 | + } | |
| 324 | +} | |
added
apps/web/src/app/healthz/route.ts
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { db, sql } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export const dynamic = 'force-dynamic'; | |
| 5 | + | |
| 6 | +export async function GET() { | |
| 7 | + let dbOk = false; | |
| 8 | + try { | |
| 9 | + await db().execute(sql`SELECT 1`); | |
| 10 | + dbOk = true; | |
| 11 | + } catch { | |
| 12 | + dbOk = false; | |
| 13 | + } | |
| 14 | + return NextResponse.json({ ok: dbOk, db: dbOk, service: 'cancerindex-web', time: new Date().toISOString() }, { status: dbOk ? 200 : 503, headers: { 'Cache-Control': 'no-store' } }); | |
| 15 | +} | |
added
apps/web/src/app/layout.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +import type { Metadata, Viewport } from 'next'; | |
| 2 | +import { Newsreader, Inter, IBM_Plex_Mono } from 'next/font/google'; | |
| 3 | +import './globals.css'; | |
| 4 | +import { SiteHeader } from '@/components/layout/site-header'; | |
| 5 | +import { SiteFooter } from '@/components/layout/site-footer'; | |
| 6 | +import { SITE_URL, SITE_NAME, TAGLINE } from '@/lib/site'; | |
| 7 | + | |
| 8 | +const display = Newsreader({ variable: '--font-display', subsets: ['latin'], display: 'swap', weight: ['400', '500', '600'], style: ['normal', 'italic'] }); | |
| 9 | +const ui = Inter({ variable: '--font-ui', subsets: ['latin'], display: 'swap' }); | |
| 10 | +const code = IBM_Plex_Mono({ variable: '--font-code', subsets: ['latin'], display: 'swap', weight: ['400', '500'] }); | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { | |
| 13 | + metadataBase: new URL(SITE_URL), | |
| 14 | + title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s · ${SITE_NAME}` }, | |
| 15 | + description: 'A provenance-first index of every recognized cancer entity: taxonomy, epidemiology, genomics, variants, therapies, clinical trials, literature and transparent rankings.', | |
| 16 | + applicationName: SITE_NAME, | |
| 17 | + robots: { index: true, follow: true }, | |
| 18 | + openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: 'Provenance-first oncology intelligence: every number linked to its source.' }, | |
| 19 | + twitter: { card: 'summary', title: SITE_NAME, description: TAGLINE }, | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export const viewport: Viewport = { | |
| 23 | + width: 'device-width', | |
| 24 | + initialScale: 1, | |
| 25 | + themeColor: '#fafaf7', | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export default function RootLayout({ children }: { children: React.ReactNode }) { | |
| 29 | + return ( | |
| 30 | + <html lang="en" className={`${display.variable} ${ui.variable} ${code.variable} h-full antialiased`}> | |
| 31 | + <body className="flex min-h-full flex-col"> | |
| 32 | + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:border focus:border-accent focus:bg-paper focus:px-3 focus:py-2 focus:text-sm"> | |
| 33 | + Skip to content | |
| 34 | + </a> | |
| 35 | + <SiteHeader /> | |
| 36 | + <main id="main" className="mx-auto w-full max-w-[1400px] flex-1 px-4 sm:px-6"> | |
| 37 | + {children} | |
| 38 | + </main> | |
| 39 | + <SiteFooter /> | |
| 40 | + </body> | |
| 41 | + </html> | |
| 42 | + ); | |
| 43 | +} | |
added
apps/web/src/app/methodology/page.tsx
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section } from '@/components/ui/section'; | |
| 4 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { JsonView } from '@/components/ui/json-view'; | |
| 7 | +import { listMetrics } from '@/lib/queries/rankings'; | |
| 8 | +import { humanize, unitLabel } from '@/lib/format'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: 'Methodology', description: 'How CancerIndex normalizes, reconciles, aggregates and ranks: every metric with its formula and version.' }; | |
| 11 | +export const revalidate = 3600; | |
| 12 | + | |
| 13 | +const CATEGORY_LABEL: Record<string, string> = { burden: 'Burden', lethality: 'Lethality', trials: 'Clinical research', research: 'Research activity', trend: 'Trends', molecular: 'Molecular knowledge', unmet_need: 'Gap indexes', rarity: 'Rarity', treatment: 'Treatment', composite: 'Composite' }; | |
| 14 | + | |
| 15 | +export default async function MethodologyPage() { | |
| 16 | + const metrics = await listMetrics(); | |
| 17 | + const cats = [...new Set(metrics.map((m) => m.category))]; | |
| 18 | + return ( | |
| 19 | + <div className="ci-prose max-w-4xl"> | |
| 20 | + <PageHeader kicker="Methodology" title="How the index is built" lede="CancerIndex separates layers — raw, normalized, canonical, derived, ranked — and keeps them separable. This page documents the rules applied at each step and lists every metric with its exact formula and version." /> | |
| 21 | + | |
| 22 | + <nav aria-label="On this page" className="flex flex-wrap gap-x-4 gap-y-1 border-y border-rule py-2 text-[13px]"> | |
| 23 | + {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'not-computed', 'limitations'].map((id) => ( | |
| 24 | + <a key={id} href={`#${id}`} className="ci-link"> | |
| 25 | + {humanize(id)} | |
| 26 | + </a> | |
| 27 | + ))} | |
| 28 | + </nav> | |
| 29 | + | |
| 30 | + <Section id="layers" kicker="§2" title="Data layers and claim types"> | |
| 31 | + <p> | |
| 32 | + Every value belongs to one layer: <strong>raw</strong> (the payload as published, kept in the data lake), <strong>normalized</strong> (units, labels and identifiers harmonized; values unchanged), <strong>canonical</strong> (attached to a CancerIndex entity), <strong>derived</strong> (computed by a versioned formula) and <strong>ranked</strong>. AI-generated synthesis is a sixth layer that is <em>not enabled</em> in this phase. | |
| 33 | + </p> | |
| 34 | + <p className="mt-2 flex flex-wrap items-center gap-1.5"> | |
| 35 | + Claims are labelled by kind and never merged: <ClaimBadge kind="observed" /> <ClaimBadge kind="published" /> <ClaimBadge kind="curated" /> <ClaimBadge kind="regulatory" /> <ClaimBadge kind="guideline" /> <ClaimBadge kind="computed" /> <ClaimBadge kind="ai" /> | |
| 36 | + </p> | |
| 37 | + </Section> | |
| 38 | + | |
| 39 | + <Section id="normalization" kicker="§69" title="Normalization"> | |
| 40 | + <ul> | |
| 41 | + <li>Labels: Unicode NFKD, diacritics stripped, lower-case, punctuation to space, Greek letters spelled out (α → alpha), British/American spellings and plurals folded (tumour → tumor, carcinomas → carcinoma), "NOS" removed. Digits are kept (G12C, HER2).</li> | |
| 42 | + <li>MeSH inverted forms are un-inverted ("Carcinoma, Non-Small-Cell Lung" → "Non-Small-Cell Lung Carcinoma").</li> | |
| 43 | + <li>Rates keep the source's standard population; rates standardized to different populations are never placed in one ranking.</li> | |
| 44 | + <li>Observations are time-aware: a value for year X never overwrites year Y. Multi-year aggregates keep both bounds.</li> | |
| 45 | + <li>Variants keep original and normalized nomenclature, and every coordinate carries its assembly (GRCh37 / GRCh38).</li> | |
| 46 | + </ul> | |
| 47 | + </Section> | |
| 48 | + | |
| 49 | + <Section id="reconciliation" kicker="§221" title="Reconciliation and match types"> | |
| 50 | + <p>Source labels are mapped to entities in a fixed order: shared identifiers, then curated aliases, then normalized strings. Language models are never the mapper; at most they propose candidates that a curator reviews. Unmatched labels go to a review queue and are never dropped. Every mapping stores one of these match types:</p> | |
| 51 | + <ul> | |
| 52 | + <li> | |
| 53 | + <Badge mono>EXACT_IDENTIFIER</Badge> shared code (NCIt, DOID, OncoTree…) | |
| 54 | + </li> | |
| 55 | + <li> | |
| 56 | + <Badge mono>CURATED_EXACT</Badge> one-to-one curated mapping · <Badge mono>ONTOLOGY_EXACT</Badge> exact via ontology cross-reference | |
| 57 | + </li> | |
| 58 | + <li> | |
| 59 | + <Badge mono>CURATED_BROADER</Badge> / <Badge mono>CURATED_NARROWER</Badge> curated mapping to a broader or narrower concept | |
| 60 | + </li> | |
| 61 | + <li> | |
| 62 | + <Badge mono>ALIAS</Badge> known alias after normalization · <Badge mono>PROBABILISTIC</Badge> string similarity, shown with caution · <Badge mono>UNRESOLVED</Badge> not mapped | |
| 63 | + </li> | |
| 64 | + </ul> | |
| 65 | + </Section> | |
| 66 | + | |
| 67 | + <Section id="hierarchy" kicker="§246-247" title="Hierarchy and the double-counting rule"> | |
| 68 | + <p>Counts of trials, evidence items, cohorts and genes aggregate over an entity's descendants across all hierarchy types (a trial mapped to "Lung Adenocarcinoma" counts for "Lung Cancer"). Because descendants overlap between branches, these counts are only compared within one entity level. Global burden rankings use a mutually exclusive top-level set of 36 registry site groups (GLOBOCAN / ICD-10 ranges anchored to NCIt concepts), so that no case is counted twice.</p> | |
| 69 | + </Section> | |
| 70 | + | |
| 71 | + <Section id="uncertainty" kicker="§3" title="Uncertainty and confidence"> | |
| 72 | + <ul> | |
| 73 | + <li>Confidence intervals are shown exactly as published; CancerIndex does not compute new intervals.</li> | |
| 74 | + <li> | |
| 75 | + Per-row confidence: <Badge tone="ok">High</Badge> observed registry data with complete coverage · <Badge>Medium</Badge> estimated or modelled · <Badge tone="warn">Low</Badge> small denominators or indirect mapping · <Badge tone="outline">Insufficient data</Badge>. | |
| 76 | + </li> | |
| 77 | + <li>Estimate type (observed / estimated / projected) is displayed on every observation.</li> | |
| 78 | + <li>Population survival statistics never predict individual outcomes and are shown with that note.</li> | |
| 79 | + </ul> | |
| 80 | + </Section> | |
| 81 | + | |
| 82 | + <Section id="metrics" kicker="§250-252" title="Metric catalog" description="Rendered live from the metric_definitions table. Each derived number on the site references one of these by slug."> | |
| 83 | + {metrics.length === 0 ? ( | |
| 84 | + <EmptyState title="Metric catalog not seeded" /> | |
| 85 | + ) : ( | |
| 86 | + <div className="space-y-6"> | |
| 87 | + {cats.map((cat) => ( | |
| 88 | + <div key={cat}> | |
| 89 | + <h3 id={`cat-${cat}`}>{CATEGORY_LABEL[cat] ?? humanize(cat)}</h3> | |
| 90 | + <div className="space-y-4"> | |
| 91 | + {metrics | |
| 92 | + .filter((m) => m.category === cat) | |
| 93 | + .map((m) => ( | |
| 94 | + <article key={m.slug} id={m.slug} className="border-l-2 border-rule pl-4"> | |
| 95 | + <h4 className="font-sans text-[15px] font-medium"> | |
| 96 | + {m.name} <span className="ci-mono text-[12px] text-ink-3">{m.slug}</span> | |
| 97 | + {m.experimental ? <Badge tone="warn" className="ml-2">experimental</Badge> : null} | |
| 98 | + {m.snapshot_count > 0 ? ( | |
| 99 | + <Link href={`/rankings/${m.slug}`} className="ci-link ml-2 text-[12.5px] font-normal"> | |
| 100 | + view ranking | |
| 101 | + </Link> | |
| 102 | + ) : ( | |
| 103 | + <span className="ml-2 text-[12px] font-normal text-ink-3">not yet computed</span> | |
| 104 | + )} | |
| 105 | + </h4> | |
| 106 | + <p className="mt-1 text-[13.5px]">{m.description}</p> | |
| 107 | + <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12.5px]"> | |
| 108 | + <dt className="text-ink-3">Formula</dt> | |
| 109 | + <dd> | |
| 110 | + <code>{m.formula}</code> | |
| 111 | + </dd> | |
| 112 | + <dt className="text-ink-3">Version</dt> | |
| 113 | + <dd className="ci-mono">{m.formula_version}</dd> | |
| 114 | + <dt className="text-ink-3">Unit</dt> | |
| 115 | + <dd>{unitLabel(m.unit)}</dd> | |
| 116 | + <dt className="text-ink-3">Direction</dt> | |
| 117 | + <dd>{m.higher_is_worse == null ? 'neutral' : m.higher_is_worse ? 'higher is worse' : 'higher is better'}</dd> | |
| 118 | + <dt className="text-ink-3">Aggregation</dt> | |
| 119 | + <dd>{m.aggregation ?? 'none'}</dd> | |
| 120 | + <dt className="text-ink-3">Dimensions</dt> | |
| 121 | + <dd>{m.valid_dimensions.join(', ')}</dd> | |
| 122 | + <dt className="text-ink-3">Sources</dt> | |
| 123 | + <dd> | |
| 124 | + {m.source_slugs.map((s) => ( | |
| 125 | + <Link key={s} className="ci-mono mr-1.5 text-ink-2 hover:text-accent" href={`/source/${s}`}> | |
| 126 | + {s} | |
| 127 | + </Link> | |
| 128 | + ))} | |
| 129 | + </dd> | |
| 130 | + {Object.keys(m.eligibility ?? {}).length ? ( | |
| 131 | + <> | |
| 132 | + <dt className="text-ink-3">Eligibility</dt> | |
| 133 | + <dd> | |
| 134 | + <JsonView data={m.eligibility} /> | |
| 135 | + </dd> | |
| 136 | + </> | |
| 137 | + ) : null} | |
| 138 | + </dl> | |
| 139 | + </article> | |
| 140 | + ))} | |
| 141 | + </div> | |
| 142 | + </div> | |
| 143 | + ))} | |
| 144 | + </div> | |
| 145 | + )} | |
| 146 | + </Section> | |
| 147 | + | |
| 148 | + <Section id="versioning" kicker="§184" title="Versioning and reproducibility"> | |
| 149 | + <p>A ranking snapshot is one metric × one scope × one formula version at one time. It stores the number of eligible entities, the list of source ids and a hash of its inputs; each row stores its lineage (observation ids, counters, formula inputs). Re-running the engine with identical inputs yields identical ranks. Changing a formula creates a new version; old snapshots remain queryable. Public identifiers (CI-CAN-…, CI-GENE-…) are minted once and never reused.</p> | |
| 150 | + </Section> | |
| 151 | + | |
| 152 | + <Section id="not-computed" kicker="Phase 1" title="What is not yet computed"> | |
| 153 | + <ul> | |
| 154 | + <li>Burden and lethality rankings (annual cases, deaths, age-standardized rates, mortality-to-incidence ratio, 5-year survival) require licensed registry observations. IARC / GLOBOCAN is under license review and SEER awaits credentials; nothing is displayed until observations exist.</li> | |
| 155 | + <li>Gap indexes (trial gap, research gap) depend on burden data and therefore wait as well.</li> | |
| 156 | + <li>Composite scores (an overall "impact" index) are deliberately absent: they would hide the choice of weights.</li> | |
| 157 | + <li>AI-generated summaries are not enabled; all text on entity pages is sourced from terminologies or written as fixed methodology.</li> | |
| 158 | + </ul> | |
| 159 | + </Section> | |
| 160 | + | |
| 161 | + <Section id="limitations" kicker="Caveats" title="Limitations"> | |
| 162 | + <ul> | |
| 163 | + <li>Registries differ in site definitions (e.g. non-melanoma skin cancer inclusion) and in completeness; site definitions are shown per observation.</li> | |
| 164 | + <li>Trial conditions and publication links rely on reconciliation; probabilistic matches are labelled and candidate links are not counted as validated.</li> | |
| 165 | + <li>Literature counts measure indexed records returned by a stored query, not scientific quality or clinical impact.</li> | |
| 166 | + <li>Counts aggregate over descendants; entities at different hierarchy depths are not directly comparable.</li> | |
| 167 | + <li>Approval records reflect the jurisdictions ingested; absence of an approval on this site is not evidence of absence.</li> | |
| 168 | + </ul> | |
| 169 | + <p className="mt-3 text-[13px] text-ink-3"> | |
| 170 | + Corrections: <a className="ci-link" href="mailto:corrections@cancerindex.io">corrections@cancerindex.io</a>. See also <Link className="ci-link" href="/trust">Trust & policies</Link>. | |
| 171 | + </p> | |
| 172 | + </Section> | |
| 173 | + </div> | |
| 174 | + ); | |
| 175 | +} | |
added
apps/web/src/app/not-found.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { PageHeader } from '@/components/ui/section'; | |
| 3 | + | |
| 4 | +export default function NotFound() { | |
| 5 | + return ( | |
| 6 | + <div className="py-10"> | |
| 7 | + <PageHeader kicker="404" title="This page does not exist" lede="The entity may not be indexed yet, or the identifier may have been merged or deprecated. Try the search, or browse the taxonomy." /> | |
| 8 | + <div className="flex gap-4 text-[14px]"> | |
| 9 | + <Link href="/cancers" className="ci-link"> | |
| 10 | + Browse cancers | |
| 11 | + </Link> | |
| 12 | + <Link href="/taxonomy" className="ci-link"> | |
| 13 | + Taxonomy | |
| 14 | + </Link> | |
| 15 | + <Link href="/search" className="ci-link"> | |
| 16 | + Search | |
| 17 | + </Link> | |
| 18 | + </div> | |
| 19 | + </div> | |
| 20 | + ); | |
| 21 | +} | |
added
apps/web/src/app/page.tsx
+320 −0
@@ -0,0 +1,320 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { TOP_LEVEL_CANCERS } from '@cancerindex/ontology'; | |
| 3 | +import { CommandPalette } from '@/components/layout/command-palette'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Freshness } from '@/components/ui/freshness'; | |
| 6 | +import { Section } from '@/components/ui/section'; | |
| 7 | +import { Badge, ClaimBadge, ConfidenceBadge, StatusBadge } from '@/components/ui/badge'; | |
| 8 | +import { getSiteCounts, getRareSpotlight } from '@/lib/queries/stats'; | |
| 9 | +import { previewSnapshot } from '@/lib/queries/rankings'; | |
| 10 | +import { mostActiveResearch, mostCuratedEvidence } from '@/lib/queries/trials'; | |
| 11 | +import { resolveTopLevel } from '@/lib/queries/cancers'; | |
| 12 | +import { listSources } from '@/lib/queries/sources'; | |
| 13 | +import { fmtInt, fmtValue, fmtDate, scopeLabel, unitLabel, humanize } from '@/lib/format'; | |
| 14 | + | |
| 15 | +export const revalidate = 900; | |
| 16 | + | |
| 17 | +export default async function HomePage() { | |
| 18 | + const [counts, spotlight, preview, active, curated, sources] = await Promise.all([ | |
| 19 | + getSiteCounts(), | |
| 20 | + getRareSpotlight(), | |
| 21 | + previewSnapshot(['mortality_count', 'incidence_count', 'as_mortality_rate', 'active_trials', 'publications_5y', 'curated_evidence_items']), | |
| 22 | + mostActiveResearch(8), | |
| 23 | + mostCuratedEvidence(8), | |
| 24 | + listSources(), | |
| 25 | + ]); | |
| 26 | + const topLevel = await resolveTopLevel(TOP_LEVEL_CANCERS.map((t) => t.ncit)); | |
| 27 | + const topMatched = TOP_LEVEL_CANCERS.filter((t) => topLevel.has(t.ncit)).length; | |
| 28 | + | |
| 29 | + const ticker: Array<{ label: string; value: number; note: string; href: string }> = [ | |
| 30 | + { label: 'Cancers indexed', value: counts.cancers, note: 'active malignant disease entities', href: '/cancers?malignant=1' }, | |
| 31 | + { label: 'Subtypes', value: counts.subtypes, note: 'histologic and molecular subtypes', href: '/cancers?entity_type=subtype' }, | |
| 32 | + { label: 'Genes', value: counts.genes, note: 'HGNC gene records', href: '/genes' }, | |
| 33 | + { label: 'Variants', value: counts.variants, note: 'distinct variant entities', href: '/genes' }, | |
| 34 | + { label: 'Clinical trials', value: counts.trials, note: 'ClinicalTrials.gov studies ingested', href: '/trials' }, | |
| 35 | + { label: 'Publications', value: counts.publications, note: 'PubMed records linked', href: '/search' }, | |
| 36 | + { label: 'Sources', value: counts.sources, note: `${counts.activeSources} active connector${counts.activeSources === 1 ? '' : 's'}`, href: '/sources' }, | |
| 37 | + ]; | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <div className="pb-8"> | |
| 41 | + {/* Hero */} | |
| 42 | + <section className="pt-10 sm:pt-16" aria-labelledby="hero-title"> | |
| 43 | + <p className="ci-kicker mb-3">Provenance-first oncology intelligence</p> | |
| 44 | + <h1 id="hero-title" className="max-w-4xl text-4xl leading-[1.05] sm:text-6xl"> | |
| 45 | + CancerIndex — <span className="italic text-ink-2">The global index of cancer.</span> | |
| 46 | + </h1> | |
| 47 | + <p className="mt-4 max-w-2xl text-[16px] leading-relaxed text-ink-2"> | |
| 48 | + Every recognized malignant disease entity — taxonomy, epidemiology, genomics, variants, therapies, trials, literature and transparent rankings — with a source, a date and a formula version on every number. | |
| 49 | + </p> | |
| 50 | + <div className="mt-6 max-w-2xl"> | |
| 51 | + <CommandPalette variant="hero" /> | |
| 52 | + <p className="mt-2 text-[12.5px] text-ink-3"> | |
| 53 | + Try <Link className="ci-link" href="/search?q=glioma">glioma</Link>, <Link className="ci-link" href="/search?q=KRAS">KRAS</Link>, <Link className="ci-link" href="/search?q=osimertinib">osimertinib</Link> or an NCT number. | |
| 54 | + </p> | |
| 55 | + </div> | |
| 56 | + </section> | |
| 57 | + | |
| 58 | + {/* Ticker */} | |
| 59 | + <section aria-label="Index counts" className="ci-rule mt-10 pt-4"> | |
| 60 | + <ul className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4 lg:grid-cols-7"> | |
| 61 | + {ticker.map((t) => ( | |
| 62 | + <li key={t.label}> | |
| 63 | + <Link href={t.href} className="block no-underline"> | |
| 64 | + <span className="ci-num block font-display text-2xl text-ink sm:text-3xl">{fmtInt(t.value)}</span> | |
| 65 | + <span className="block text-[12.5px] font-medium text-ink-2">{t.label}</span> | |
| 66 | + <span className="block text-[11.5px] text-ink-3">{t.note}</span> | |
| 67 | + </Link> | |
| 68 | + </li> | |
| 69 | + ))} | |
| 70 | + </ul> | |
| 71 | + <Freshness dataUpdatedAt={counts.lastIngestAt} extra="Counts are live database counts, not estimates" /> | |
| 72 | + </section> | |
| 73 | + | |
| 74 | + <div className="mt-10 grid gap-10 lg:grid-cols-[1.35fr_1fr]"> | |
| 75 | + <div className="space-y-10"> | |
| 76 | + {/* Rankings preview */} | |
| 77 | + <Section | |
| 78 | + id="rankings" | |
| 79 | + kicker="Global rankings" | |
| 80 | + title={preview ? preview.metric.name : 'Global rankings'} | |
| 81 | + description={preview ? `${scopeLabel(preview.snapshot.scope_key)} · formula ${preview.snapshot.formula_version} · generated ${fmtDate(preview.snapshot.generated_at)} · ${fmtInt(preview.snapshot.eligible_entities)} eligible entities` : undefined} | |
| 82 | + actions={ | |
| 83 | + <Link href="/rankings" className="ci-link"> | |
| 84 | + All rankings → | |
| 85 | + </Link> | |
| 86 | + } | |
| 87 | + > | |
| 88 | + {preview ? ( | |
| 89 | + <> | |
| 90 | + {!preview.metric.slug.match(/incidence|mortality|survival/) ? ( | |
| 91 | + <p className="mb-2 text-[12.5px] text-ink-3">No epidemiology-based ranking has been computed yet (awaiting licensed burden data). Showing the research-activity ranking instead — it measures registered activity, not disease burden.</p> | |
| 92 | + ) : null} | |
| 93 | + <div className="ci-table-wrap"> | |
| 94 | + <table className="ci-table"> | |
| 95 | + <thead> | |
| 96 | + <tr> | |
| 97 | + <th className="num">#</th> | |
| 98 | + <th>Cancer</th> | |
| 99 | + <th className="num"> | |
| 100 | + {preview.metric.name} ({unitLabel(preview.metric.unit)}) | |
| 101 | + </th> | |
| 102 | + <th>Confidence</th> | |
| 103 | + </tr> | |
| 104 | + </thead> | |
| 105 | + <tbody> | |
| 106 | + {preview.rows.map((r) => ( | |
| 107 | + <tr key={r.id}> | |
| 108 | + <td className="num">{r.rank}</td> | |
| 109 | + <td> | |
| 110 | + <Link className="ci-link" href={`/cancer/${r.slug}`}> | |
| 111 | + {r.canonical_name} | |
| 112 | + </Link> | |
| 113 | + </td> | |
| 114 | + <td className="num">{fmtValue(r.value, r.unit)}</td> | |
| 115 | + <td> | |
| 116 | + <ConfidenceBadge level={r.confidence} /> | |
| 117 | + </td> | |
| 118 | + </tr> | |
| 119 | + ))} | |
| 120 | + </tbody> | |
| 121 | + </table> | |
| 122 | + </div> | |
| 123 | + <div className="mt-2 flex items-center gap-2"> | |
| 124 | + <ClaimBadge kind="computed" /> | |
| 125 | + <Link href={`/rankings/${preview.metric.slug}?scope=${encodeURIComponent(preview.snapshot.scope_key)}`} className="ci-link text-[13px]"> | |
| 126 | + Full table, methodology and "Why this rank?" → | |
| 127 | + </Link> | |
| 128 | + </div> | |
| 129 | + <Freshness dataUpdatedAt={preview.snapshot.generated_at} extra={`sources: ${preview.snapshot.source_ids.join(', ') || 'see methodology'}`} /> | |
| 130 | + </> | |
| 131 | + ) : ( | |
| 132 | + <EmptyState title="Rankings not yet computed"> | |
| 133 | + Ranking snapshots are produced by the ranking engine once counters and licensed epidemiology observations are available. Nothing is shown until a snapshot exists with a scope, a formula version and an inputs hash. | |
| 134 | + <div className="mt-1"> | |
| 135 | + <Link href="/methodology" className="ci-link"> | |
| 136 | + Read how rankings are computed | |
| 137 | + </Link> | |
| 138 | + </div> | |
| 139 | + </EmptyState> | |
| 140 | + )} | |
| 141 | + </Section> | |
| 142 | + | |
| 143 | + {/* Active research */} | |
| 144 | + <Section id="active-research" kicker="Clinical research" title="Most active clinical research" description="Cancers with the most interventional trials in an active status (recruiting, not yet recruiting, enrolling by invitation, active not recruiting), counted over the entity and its descendants."> | |
| 145 | + {active.length ? ( | |
| 146 | + <> | |
| 147 | + <div className="ci-table-wrap"> | |
| 148 | + <table className="ci-table"> | |
| 149 | + <thead> | |
| 150 | + <tr> | |
| 151 | + <th>Cancer</th> | |
| 152 | + <th className="num">Active trials (count)</th> | |
| 153 | + <th className="num">Recruiting (count)</th> | |
| 154 | + </tr> | |
| 155 | + </thead> | |
| 156 | + <tbody> | |
| 157 | + {active.map((r) => ( | |
| 158 | + <tr key={r.slug}> | |
| 159 | + <td> | |
| 160 | + <Link className="ci-link" href={`/cancer/${r.slug}/trials`}> | |
| 161 | + {r.canonical_name} | |
| 162 | + </Link> | |
| 163 | + </td> | |
| 164 | + <td className="num">{fmtInt(r.active_trial_count)}</td> | |
| 165 | + <td className="num">{fmtInt(r.recruiting_trial_count)}</td> | |
| 166 | + </tr> | |
| 167 | + ))} | |
| 168 | + </tbody> | |
| 169 | + </table> | |
| 170 | + </div> | |
| 171 | + <Freshness dataUpdatedAt={active[0]?.computed_at} extra="source: clinicaltrials · counters refreshed deterministically" /> | |
| 172 | + </> | |
| 173 | + ) : ( | |
| 174 | + <EmptyState> | |
| 175 | + No clinical trials have been ingested yet; trial counters are computed from ClinicalTrials.gov conditions mapped to the taxonomy. | |
| 176 | + <div className="mt-1"> | |
| 177 | + <Link href="/trials" className="ci-link"> | |
| 178 | + Trials explorer | |
| 179 | + </Link> | |
| 180 | + </div> | |
| 181 | + </EmptyState> | |
| 182 | + )} | |
| 183 | + </Section> | |
| 184 | + | |
| 185 | + {/* Curated evidence */} | |
| 186 | + <Section id="curated" kicker="Molecular knowledge" title="Most curated molecular evidence" description="Accepted CIViC evidence items whose disease maps to the cancer or its descendants."> | |
| 187 | + {curated.length ? ( | |
| 188 | + <> | |
| 189 | + <div className="ci-table-wrap"> | |
| 190 | + <table className="ci-table"> | |
| 191 | + <thead> | |
| 192 | + <tr> | |
| 193 | + <th>Cancer</th> | |
| 194 | + <th className="num">Evidence items (count)</th> | |
| 195 | + <th className="num">Genes (count)</th> | |
| 196 | + </tr> | |
| 197 | + </thead> | |
| 198 | + <tbody> | |
| 199 | + {curated.map((r) => ( | |
| 200 | + <tr key={r.slug}> | |
| 201 | + <td> | |
| 202 | + <Link className="ci-link" href={`/cancer/${r.slug}/evidence`}> | |
| 203 | + {r.canonical_name} | |
| 204 | + </Link> | |
| 205 | + </td> | |
| 206 | + <td className="num">{fmtInt(r.evidence_count)}</td> | |
| 207 | + <td className="num">{fmtInt(r.gene_count)}</td> | |
| 208 | + </tr> | |
| 209 | + ))} | |
| 210 | + </tbody> | |
| 211 | + </table> | |
| 212 | + </div> | |
| 213 | + <Freshness dataUpdatedAt={curated[0]?.computed_at} extra="source: civic" /> | |
| 214 | + </> | |
| 215 | + ) : ( | |
| 216 | + <EmptyState>No curated evidence items have been ingested yet. CIViC evidence will appear here grouped by cancer, with native evidence levels and directions.</EmptyState> | |
| 217 | + )} | |
| 218 | + </Section> | |
| 219 | + </div> | |
| 220 | + | |
| 221 | + <aside className="space-y-10"> | |
| 222 | + {/* Taxonomy at a glance */} | |
| 223 | + <Section | |
| 224 | + id="taxonomy" | |
| 225 | + kicker="Taxonomy at a glance" | |
| 226 | + title="Top-level cancers" | |
| 227 | + description={`${topMatched} of ${TOP_LEVEL_CANCERS.length} registry site groups are currently resolved to an indexed entity through their NCIt anchor code.`} | |
| 228 | + actions={ | |
| 229 | + <Link href="/taxonomy" className="ci-link"> | |
| 230 | + Tree browser → | |
| 231 | + </Link> | |
| 232 | + } | |
| 233 | + > | |
| 234 | + <ul className="columns-1 gap-6 text-[13.5px] sm:columns-2"> | |
| 235 | + {TOP_LEVEL_CANCERS.map((t) => { | |
| 236 | + const m = topLevel.get(t.ncit); | |
| 237 | + return ( | |
| 238 | + <li key={t.key} className="flex items-baseline justify-between gap-2 border-b border-rule py-1"> | |
| 239 | + {m ? ( | |
| 240 | + <Link href={`/cancer/${m.slug}`} className="ci-link truncate"> | |
| 241 | + {t.name} | |
| 242 | + </Link> | |
| 243 | + ) : ( | |
| 244 | + <span className="truncate text-ink-3" title={`NCIt ${t.ncit} not yet indexed`}> | |
| 245 | + {t.name} | |
| 246 | + </span> | |
| 247 | + )} | |
| 248 | + <span className="ci-num shrink-0 text-[11.5px] text-ink-3">{m ? (m.descendant_count != null ? `${fmtInt(m.descendant_count)} desc.` : <span className="ci-mono">{t.ncit}</span>) : 'pending'}</span> | |
| 249 | + </li> | |
| 250 | + ); | |
| 251 | + })} | |
| 252 | + </ul> | |
| 253 | + <p className="mt-2 text-[11.5px] text-ink-3">Site groups follow GLOBOCAN / ICD-10 ranges so burden estimates attach without double counting. "pending" = NCIt concept not yet ingested.</p> | |
| 254 | + </Section> | |
| 255 | + | |
| 256 | + {/* Rare spotlight */} | |
| 257 | + <Section id="spotlight" kicker="Rare cancer spotlight" title={spotlight ? spotlight.canonicalName : 'Spotlight'} description="A deterministic daily pick among indexed malignant entities, chosen to surface the long tail of the taxonomy rather than the thirty most common cancers."> | |
| 258 | + {spotlight ? ( | |
| 259 | + <div className="text-[13.5px]"> | |
| 260 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 261 | + <Badge>{humanize(spotlight.entityType)}</Badge> | |
| 262 | + {spotlight.primaryOncotreeCode ? <Badge mono>OncoTree {spotlight.primaryOncotreeCode}</Badge> : null} | |
| 263 | + {spotlight.rareCancer === true ? <Badge tone="accent">Rare</Badge> : null} | |
| 264 | + </div> | |
| 265 | + {spotlight.parentName ? ( | |
| 266 | + <p className="mt-2 text-ink-2"> | |
| 267 | + Classified under{' '} | |
| 268 | + <Link className="ci-link" href={`/cancer/${spotlight.parentSlug}`}> | |
| 269 | + {spotlight.parentName} | |
| 270 | + </Link> | |
| 271 | + . | |
| 272 | + </p> | |
| 273 | + ) : null} | |
| 274 | + <p className="mt-1 text-ink-3">{spotlight.rareCancer == null ? 'Rarity status unknown: no incidence observation is attached yet.' : null}</p> | |
| 275 | + <Link href={`/cancer/${spotlight.slug}`} className="ci-link mt-2 inline-block"> | |
| 276 | + Open entity → | |
| 277 | + </Link> | |
| 278 | + </div> | |
| 279 | + ) : ( | |
| 280 | + <EmptyState compact>No malignant entity indexed yet.</EmptyState> | |
| 281 | + )} | |
| 282 | + </Section> | |
| 283 | + | |
| 284 | + {/* Sources */} | |
| 285 | + <Section | |
| 286 | + id="sources" | |
| 287 | + kicker="Sources" | |
| 288 | + title="Source registry" | |
| 289 | + actions={ | |
| 290 | + <Link href="/sources" className="ci-link"> | |
| 291 | + Coverage matrix → | |
| 292 | + </Link> | |
| 293 | + } | |
| 294 | + > | |
| 295 | + {sources.length ? ( | |
| 296 | + <ul className="divide-y divide-rule text-[13.5px]"> | |
| 297 | + {sources.map((s) => ( | |
| 298 | + <li key={s.slug} className="flex flex-wrap items-center justify-between gap-2 py-1.5"> | |
| 299 | + <Link className="ci-link" href={`/source/${s.slug}`}> | |
| 300 | + {s.name} | |
| 301 | + </Link> | |
| 302 | + <span className="flex items-center gap-1.5"> | |
| 303 | + <StatusBadge status={s.status} /> | |
| 304 | + <Badge tone={s.license_status === 'approved' ? 'ok' : s.license_status === 'blocked' ? 'danger' : 'warn'} title="License review status"> | |
| 305 | + license: {s.license_status} | |
| 306 | + </Badge> | |
| 307 | + </span> | |
| 308 | + </li> | |
| 309 | + ))} | |
| 310 | + </ul> | |
| 311 | + ) : ( | |
| 312 | + <EmptyState compact>No source registered.</EmptyState> | |
| 313 | + )} | |
| 314 | + <Freshness dataUpdatedAt={sources.reduce<Date | null>((m, s) => (s.last_run_finished_at && (!m || s.last_run_finished_at > m) ? s.last_run_finished_at : m), null)} extra="License status is reviewed before any connector goes live" /> | |
| 315 | + </Section> | |
| 316 | + </aside> | |
| 317 | + </div> | |
| 318 | + </div> | |
| 319 | + ); | |
| 320 | +} | |
added
apps/web/src/app/publication/[pmid]/page.tsx
+160 −0
@@ -0,0 +1,160 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ExternalLink, AlertTriangle } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 10 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 11 | +import { getPublicationByPmid, publicationEntities } from '@/lib/queries/publications'; | |
| 12 | +import { evidenceForPublication } from '@/lib/queries/evidence'; | |
| 13 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 14 | +import { fmtDate, fmtInt, humanize, truncate } from '@/lib/format'; | |
| 15 | + | |
| 16 | +export const revalidate = 3600; | |
| 17 | + | |
| 18 | +export async function generateMetadata({ params }: { params: Promise<{ pmid: string }> }): Promise<Metadata> { | |
| 19 | + const p = await getPublicationByPmid((await params).pmid); | |
| 20 | + return p ? { title: `${truncate(p.title, 80)} — PMID ${p.pmid}`, description: truncate(p.abstract ?? p.title, 160), robots: p.retracted ? { index: false } : undefined } : { title: 'Publication' }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export default async function PublicationPage({ params }: { params: Promise<{ pmid: string }> }) { | |
| 24 | + const { pmid } = await params; | |
| 25 | + if (!/^\d{1,10}$/.test(pmid)) notFound(); | |
| 26 | + const p = await getPublicationByPmid(pmid); | |
| 27 | + if (!p) notFound(); | |
| 28 | + const [edges, evidence] = await Promise.all([publicationEntities(p.id), evidenceForPublication(pmid)]); | |
| 29 | + const prov = await loadProvenance(evidence.map((e) => e.provenance_id)); | |
| 30 | + const validated = edges.filter((e) => e.status === 'validated'); | |
| 31 | + const candidates = edges.filter((e) => e.status === 'candidate'); | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <article> | |
| 35 | + {p.retracted ? ( | |
| 36 | + <div role="alert" className="mt-4 flex items-start gap-2 border border-danger bg-danger-soft px-4 py-3 text-[13.5px] text-danger"> | |
| 37 | + <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden /> | |
| 38 | + <div> | |
| 39 | + <p className="font-medium">This publication has been retracted.</p> | |
| 40 | + {p.retraction_notice ? <p className="mt-0.5">{p.retraction_notice}</p> : null} | |
| 41 | + <p className="mt-0.5">Evidence relying on it is flagged; treat all findings below as withdrawn.</p> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + ) : null} | |
| 45 | + <PageHeader kicker={`Publication${p.is_preprint ? ' · preprint' : ''}`} title={p.title}> | |
| 46 | + <p className="mt-2 text-[13.5px] text-ink-2"> | |
| 47 | + {p.authors.map((a) => a.name).join(', ') || 'Authors not recorded'} | |
| 48 | + </p> | |
| 49 | + <p className="mt-1 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3"> | |
| 50 | + {p.journal ? <span className="italic">{p.journal}</span> : null} | |
| 51 | + {p.pub_date ? <span>{fmtDate(p.pub_date)}</span> : p.pub_year ? <span>{p.pub_year}</span> : null} | |
| 52 | + <span className="ci-mono">PMID {p.pmid}</span> | |
| 53 | + {p.doi ? ( | |
| 54 | + <a className="ci-link ci-mono inline-flex items-center gap-1" href={`https://doi.org/${p.doi}`} target="_blank" rel="noopener noreferrer"> | |
| 55 | + doi:{p.doi} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 56 | + </a> | |
| 57 | + ) : null} | |
| 58 | + {p.pmcid ? <span className="ci-mono">{p.pmcid}</span> : null} | |
| 59 | + {p.publication_types.map((t) => ( | |
| 60 | + <Badge key={t} tone="outline"> | |
| 61 | + {t} | |
| 62 | + </Badge> | |
| 63 | + ))} | |
| 64 | + <SourceBadge p={{ sourceSlug: 'pubmed', sourceName: 'PubMed', retrievedAt: p.updated_at, ingestRunId: p.ingest_run_id }} /> | |
| 65 | + <ClaimBadge kind="published" /> | |
| 66 | + </p> | |
| 67 | + </PageHeader> | |
| 68 | + | |
| 69 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 70 | + <div className="space-y-8"> | |
| 71 | + <Section id="abstract" kicker="Abstract" title="Abstract (excerpt)" description="Only the opening of the abstract is shown; abstract text may carry publisher copyright."> | |
| 72 | + {p.abstract ? ( | |
| 73 | + <> | |
| 74 | + <p className="max-w-3xl text-[14.5px] leading-relaxed">{truncate(p.abstract, 300)}</p> | |
| 75 | + <a className="ci-link mt-2 inline-flex items-center gap-1 text-[13.5px]" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer"> | |
| 76 | + Read on PubMed <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 77 | + </a> | |
| 78 | + </> | |
| 79 | + ) : ( | |
| 80 | + <EmptyState compact> | |
| 81 | + No abstract stored.{' '} | |
| 82 | + <a className="ci-link" href={`https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/`} target="_blank" rel="noopener noreferrer"> | |
| 83 | + Read on PubMed | |
| 84 | + </a> | |
| 85 | + </EmptyState> | |
| 86 | + )} | |
| 87 | + </Section> | |
| 88 | + | |
| 89 | + <Section id="entities" kicker="Linked entities" title={`Linked entities (${fmtInt(edges.length)})`} description="How each link was made (MeSH, dictionary, registry reference, curation…) and whether it has been validated. Candidate links are not counted in entity statistics."> | |
| 90 | + {edges.length ? ( | |
| 91 | + <div className="space-y-4"> | |
| 92 | + {[ | |
| 93 | + { label: 'Validated', rows: validated, tone: 'ok' as const }, | |
| 94 | + { label: 'Candidate', rows: candidates, tone: 'warn' as const }, | |
| 95 | + ] | |
| 96 | + .filter((g) => g.rows.length) | |
| 97 | + .map((g) => ( | |
| 98 | + <div key={g.label}> | |
| 99 | + <p className="mb-1 flex items-center gap-2"> | |
| 100 | + <Badge tone={g.tone}>{g.label}</Badge> <span className="ci-num text-[12px] text-ink-3">{g.rows.length}</span> | |
| 101 | + </p> | |
| 102 | + <ul className="flex flex-wrap gap-1.5 text-[13.5px]"> | |
| 103 | + {g.rows.map((e) => ( | |
| 104 | + <li key={`${e.entity_type}-${e.entity_id}-${e.method}`} className="inline-flex items-center gap-1.5 border border-rule px-2 py-0.5"> | |
| 105 | + <span className="ci-kicker">{e.entity_type}</span> | |
| 106 | + {e.href ? ( | |
| 107 | + <Link className="ci-link" href={e.href}> | |
| 108 | + {e.label ?? e.entity_id} | |
| 109 | + </Link> | |
| 110 | + ) : ( | |
| 111 | + <span>{e.label ?? e.entity_id}</span> | |
| 112 | + )} | |
| 113 | + <span className="ci-mono text-[10.5px] text-ink-3">{e.method}</span> | |
| 114 | + {e.confidence != null ? <span className="ci-num text-[10.5px] text-ink-3">{e.confidence.toFixed(2)}</span> : null} | |
| 115 | + </li> | |
| 116 | + ))} | |
| 117 | + </ul> | |
| 118 | + </div> | |
| 119 | + ))} | |
| 120 | + </div> | |
| 121 | + ) : ( | |
| 122 | + <EmptyState compact>No entity link recorded for this publication.</EmptyState> | |
| 123 | + )} | |
| 124 | + {p.nct_ids.length ? ( | |
| 125 | + <p className="mt-3 text-[13px]"> | |
| 126 | + <span className="ci-kicker mr-2">Registered trials</span> | |
| 127 | + {p.nct_ids.map((n) => ( | |
| 128 | + <Link key={n} className="ci-mono ci-link mr-2" href={`/trial/${n}`}> | |
| 129 | + {n} | |
| 130 | + </Link> | |
| 131 | + ))} | |
| 132 | + </p> | |
| 133 | + ) : null} | |
| 134 | + </Section> | |
| 135 | + | |
| 136 | + <Section id="evidence" kicker="Curated evidence" title={`Evidence citing this paper (${fmtInt(evidence.length)})`}> | |
| 137 | + {evidence.length ? <EvidenceTable items={evidence} prov={prov} showCancer /> : <EmptyState compact>No curated evidence item cites this publication.</EmptyState>} | |
| 138 | + </Section> | |
| 139 | + </div> | |
| 140 | + | |
| 141 | + <aside className="space-y-8"> | |
| 142 | + <Section id="record" kicker="Record" title="Metadata" level={3}> | |
| 143 | + <KV | |
| 144 | + items={[ | |
| 145 | + { k: 'CancerIndex ID', v: <span className="ci-mono">{p.id}</span> }, | |
| 146 | + { k: 'Language', v: p.language }, | |
| 147 | + { k: 'Cited by', v: p.cited_by_count != null ? <span className="ci-num">{fmtInt(p.cited_by_count)}</span> : null }, | |
| 148 | + { k: 'MeSH major', v: p.mesh_terms.filter((m) => m.major).map((m) => m.descriptor).join('; ') || null }, | |
| 149 | + { k: 'MeSH other', v: p.mesh_terms.filter((m) => !m.major).slice(0, 15).map((m) => m.descriptor).join('; ') || null }, | |
| 150 | + ]} | |
| 151 | + /> | |
| 152 | + <Freshness dataUpdatedAt={p.updated_at} sourceUpdatedAt={p.pub_date} extra="source: pubmed" /> | |
| 153 | + </Section> | |
| 154 | + <Note>Bibliographic data from PubMed (NLM). CancerIndex stores abstracts for indexing only and displays a short excerpt with a link to the record.</Note> | |
| 155 | + <p className="text-[12px] text-ink-3">Publication type: {p.publication_types.map(humanize).join(', ') || 'not recorded'}.</p> | |
| 156 | + </aside> | |
| 157 | + </div> | |
| 158 | + </article> | |
| 159 | + ); | |
| 160 | +} | |
added
apps/web/src/app/rankings/[metric]/page.tsx
+205 −0
@@ -0,0 +1,205 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { Download } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { JsonView } from '@/components/ui/json-view'; | |
| 10 | +import { BarChart } from '@/components/charts/bar-chart'; | |
| 11 | +import { getMetric, snapshotsForMetric, rankingRows, type Snapshot } from '@/lib/queries/rankings'; | |
| 12 | +import { sourceInfoById } from '@/lib/queries/provenance'; | |
| 13 | +import { fmtDate, fmtDateTime, fmtInt, fmtValue, scopeLabel, unitLabel, humanize } from '@/lib/format'; | |
| 14 | +import { str, type SP } from '@/lib/search-params'; | |
| 15 | + | |
| 16 | +export const dynamic = 'force-dynamic'; | |
| 17 | + | |
| 18 | +export async function generateMetadata({ params }: { params: Promise<{ metric: string }> }): Promise<Metadata> { | |
| 19 | + const m = await getMetric((await params).metric); | |
| 20 | + return m ? { title: `${m.name} — ranking`, description: m.description } : { title: 'Ranking' }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function Delta({ rank, prev }: { rank: number; prev: number | null }) { | |
| 24 | + if (prev == null) return <span className="text-ink-4">new</span>; | |
| 25 | + const d = prev - rank; | |
| 26 | + if (d === 0) return <span className="text-ink-3">=</span>; | |
| 27 | + return ( | |
| 28 | + <span className={d > 0 ? 'text-ok' : 'text-danger'} title={`Previous rank ${prev}`}> | |
| 29 | + {d > 0 ? '▲' : '▼'} | |
| 30 | + {Math.abs(d)} | |
| 31 | + </span> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export default async function RankingPage({ params, searchParams }: { params: Promise<{ metric: string }>; searchParams: Promise<SP> }) { | |
| 36 | + const { metric: slug } = await params; | |
| 37 | + const sp = await searchParams; | |
| 38 | + const metric = await getMetric(slug); | |
| 39 | + if (!metric) notFound(); | |
| 40 | + const snapshots = await snapshotsForMetric(slug); | |
| 41 | + const wanted = str(sp, 'scope'); | |
| 42 | + const snap: Snapshot | null = snapshots.find((s) => s.scope_key === wanted) ?? snapshots[0] ?? null; | |
| 43 | + const rows = snap ? await rankingRows(snap.id) : []; | |
| 44 | + const srcInfo = snap ? await sourceInfoById(snap.source_ids) : new Map(); | |
| 45 | + const eligibility = Object.entries(metric.eligibility ?? {}); | |
| 46 | + const isBurden = metric.category === 'burden' || metric.category === 'lethality' || metric.category === 'unmet_need'; | |
| 47 | + | |
| 48 | + return ( | |
| 49 | + <div> | |
| 50 | + <PageHeader kicker={`Ranking · ${humanize(metric.category)}`} title={metric.name} lede={metric.description}> | |
| 51 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3"> | |
| 52 | + <ClaimBadge kind="computed" /> | |
| 53 | + <span className="ci-mono">{metric.formula_version}</span> | |
| 54 | + <span>unit: {unitLabel(metric.unit)}</span> | |
| 55 | + <span>{metric.higher_is_worse == null ? 'higher is first (neutral)' : metric.higher_is_worse ? 'higher = worse' : 'higher = better'}</span> | |
| 56 | + <Link href="/rankings" className="ci-link"> | |
| 57 | + All metrics | |
| 58 | + </Link> | |
| 59 | + </p> | |
| 60 | + </PageHeader> | |
| 61 | + | |
| 62 | + {snapshots.length === 0 ? ( | |
| 63 | + <EmptyState title="Ranking not yet computed" knows={[{ label: 'Metric definition and formula', href: `/methodology#${metric.slug}` }, { label: 'Source registry', href: '/sources' }, { label: 'Other rankings', href: '/rankings' }]}> | |
| 64 | + {isBurden | |
| 65 | + ? 'This metric depends on epidemiology observations. Global burden data (IARC / GLOBOCAN) is awaiting license review and SEER awaits credentials; no snapshot is produced until licensed observations exist for at least one scope with ten or more top-level cancers.' | |
| 66 | + : 'This metric depends on counters that are refreshed after the relevant connector runs (ClinicalTrials.gov, PubMed, CIViC, GDC). No snapshot exists yet on this environment.'} | |
| 67 | + <div className="mt-2"> | |
| 68 | + <span className="ci-kicker mr-2">Formula</span> | |
| 69 | + <code className="ci-mono text-[12px]">{metric.formula}</code> | |
| 70 | + </div> | |
| 71 | + </EmptyState> | |
| 72 | + ) : ( | |
| 73 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 74 | + <div> | |
| 75 | + <nav aria-label="Scope" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]"> | |
| 76 | + {snapshots.map((s) => ( | |
| 77 | + <Link key={s.id} href={`/rankings/${slug}?scope=${encodeURIComponent(s.scope_key)}`} aria-current={snap?.id === s.id ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${snap?.id === s.id ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 78 | + {scopeLabel(s.scope_key)} | |
| 79 | + </Link> | |
| 80 | + ))} | |
| 81 | + </nav> | |
| 82 | + {snap && rows.length ? ( | |
| 83 | + <> | |
| 84 | + <BarChart ariaLabel={`Top ${Math.min(15, rows.length)} by ${metric.name}`} unit={metric.unit} data={rows.slice(0, 15).map((r) => ({ label: r.canonical_name, value: r.value, muted: r.confidence === 'LOW' || r.confidence === 'INSUFFICIENT_DATA' }))} /> | |
| 85 | + <div className="ci-table-wrap mt-4"> | |
| 86 | + <table className="ci-table"> | |
| 87 | + <thead> | |
| 88 | + <tr> | |
| 89 | + <th className="num">Rank</th> | |
| 90 | + <th className="num">Δ</th> | |
| 91 | + <th>Cancer</th> | |
| 92 | + <th className="num"> | |
| 93 | + {metric.name} ({unitLabel(metric.unit)}) | |
| 94 | + </th> | |
| 95 | + <th className="num">Percentile</th> | |
| 96 | + <th>Confidence</th> | |
| 97 | + <th>Sources</th> | |
| 98 | + <th>Why #n?</th> | |
| 99 | + </tr> | |
| 100 | + </thead> | |
| 101 | + <tbody> | |
| 102 | + {rows.map((r) => ( | |
| 103 | + <tr key={r.id}> | |
| 104 | + <td className="num font-medium">{r.rank}</td> | |
| 105 | + <td className="num"> | |
| 106 | + <Delta rank={r.rank} prev={r.previous_rank} /> | |
| 107 | + </td> | |
| 108 | + <td className="min-w-[220px]"> | |
| 109 | + <Link className="ci-link" href={`/cancer/${r.slug}/rankings`}> | |
| 110 | + {r.canonical_name} | |
| 111 | + </Link> | |
| 112 | + </td> | |
| 113 | + <td className="num">{fmtValue(r.value, r.unit)}</td> | |
| 114 | + <td className="num text-ink-3">{r.percentile}</td> | |
| 115 | + <td> | |
| 116 | + <ConfidenceBadge level={r.confidence} /> | |
| 117 | + </td> | |
| 118 | + <td className="text-[11.5px]"> | |
| 119 | + {snap.source_ids.map((id) => { | |
| 120 | + const s = srcInfo.get(id); | |
| 121 | + return ( | |
| 122 | + <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-mono mr-1 border border-rule px-1 text-ink-2 no-underline hover:border-accent"> | |
| 123 | + {s?.slug ?? id} | |
| 124 | + </Link> | |
| 125 | + ); | |
| 126 | + })} | |
| 127 | + </td> | |
| 128 | + <td> | |
| 129 | + <details> | |
| 130 | + <summary className="ci-link text-[12.5px]">inputs</summary> | |
| 131 | + <div className="mt-1 max-w-[380px]"> | |
| 132 | + <JsonView data={r.inputs} /> | |
| 133 | + <p className="ci-mono mt-1 text-[10.5px] text-ink-4">ranking row {r.id}</p> | |
| 134 | + </div> | |
| 135 | + </details> | |
| 136 | + </td> | |
| 137 | + </tr> | |
| 138 | + ))} | |
| 139 | + </tbody> | |
| 140 | + </table> | |
| 141 | + </div> | |
| 142 | + <Freshness dataUpdatedAt={snap.generated_at} extra={`${fmtInt(snap.eligible_entities)} eligible entities · inputs hash ${snap.inputs_hash}`} /> | |
| 143 | + </> | |
| 144 | + ) : ( | |
| 145 | + <EmptyState compact>This snapshot has no rows.</EmptyState> | |
| 146 | + )} | |
| 147 | + </div> | |
| 148 | + | |
| 149 | + <aside className="space-y-6"> | |
| 150 | + {snap ? ( | |
| 151 | + <Section id="methodology" kicker="Methodology" title="How this table was built" level={3}> | |
| 152 | + <KV | |
| 153 | + items={[ | |
| 154 | + { k: 'Formula', v: <code className="ci-mono text-[12px]">{metric.formula}</code> }, | |
| 155 | + { k: 'Version', v: <span className="ci-mono">{snap.formula_version}</span> }, | |
| 156 | + { k: 'Scope', v: scopeLabel(snap.scope_key) }, | |
| 157 | + { k: 'Scope key', v: <span className="ci-mono break-all text-[11.5px]">{snap.scope_key}</span> }, | |
| 158 | + { k: 'Eligible entities', v: <span className="ci-num">{fmtInt(snap.eligible_entities)}</span> }, | |
| 159 | + { k: 'Generated', v: fmtDateTime(snap.generated_at) }, | |
| 160 | + { k: 'Inputs hash', v: <span className="ci-mono break-all text-[11.5px]">{snap.inputs_hash}</span> }, | |
| 161 | + { | |
| 162 | + k: 'Sources', | |
| 163 | + v: snap.source_ids.length | |
| 164 | + ? snap.source_ids.map((id) => { | |
| 165 | + const s = srcInfo.get(id); | |
| 166 | + return ( | |
| 167 | + <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-link mr-2"> | |
| 168 | + {s?.name ?? id} | |
| 169 | + </Link> | |
| 170 | + ); | |
| 171 | + }) | |
| 172 | + : '—', | |
| 173 | + }, | |
| 174 | + { k: 'Aggregation', v: metric.aggregation ?? 'none' }, | |
| 175 | + ]} | |
| 176 | + /> | |
| 177 | + {eligibility.length ? ( | |
| 178 | + <> | |
| 179 | + <p className="ci-kicker mt-3">Eligibility</p> | |
| 180 | + <JsonView data={metric.eligibility} /> | |
| 181 | + </> | |
| 182 | + ) : null} | |
| 183 | + <p className="mt-3 text-[12px] text-ink-3"> | |
| 184 | + <Link href={`/methodology#${metric.slug}`} className="ci-link"> | |
| 185 | + Full methodology | |
| 186 | + </Link>{' '} | |
| 187 | + · ties share a rank (1, 2, 2, 4) · previous rank refers to the last snapshot of the same scope. | |
| 188 | + </p> | |
| 189 | + <a href={`/api/export/rankings.csv?metric=${slug}&scope=${encodeURIComponent(snap.scope_key)}`} className="mt-3 inline-flex items-center gap-1.5 border border-rule px-2.5 py-1 text-[13px] text-ink no-underline hover:border-accent hover:text-accent"> | |
| 190 | + <Download className="h-3.5 w-3.5" aria-hidden /> Download CSV (with attribution) | |
| 191 | + </a> | |
| 192 | + </Section> | |
| 193 | + ) : null} | |
| 194 | + {isBurden ? <Note tone="warn">Burden and lethality metrics depend on registry definitions (site groups, standard population, estimate type). Compare only within one table.</Note> : <Note>Counts of trials, publications and evidence measure registered activity, not disease burden or treatment quality.</Note>} | |
| 195 | + {metric.slug.includes('mortality') || metric.slug.includes('survival') ? ( | |
| 196 | + <p className="text-[12px] text-ink-3"> | |
| 197 | + <Badge tone="outline">Deadliness</Badge> annual deaths ≠ age-standardized mortality ≠ mortality-to-incidence ratio ≠ 5-year survival. Each answers a different question. | |
| 198 | + </p> | |
| 199 | + ) : null} | |
| 200 | + </aside> | |
| 201 | + </div> | |
| 202 | + )} | |
| 203 | + </div> | |
| 204 | + ); | |
| 205 | +} | |
added
apps/web/src/app/rankings/page.tsx
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 4 | +import { Badge } from '@/components/ui/badge'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { listMetrics } from '@/lib/queries/rankings'; | |
| 7 | +import { humanize, unitLabel } from '@/lib/format'; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { title: 'Rankings', description: 'Transparent cancer rankings: one metric, one scope, one formula version per table, with lineage for every rank.' }; | |
| 10 | +export const dynamic = 'force-dynamic'; | |
| 11 | + | |
| 12 | +const CATEGORY_ORDER = ['burden', 'lethality', 'trials', 'research', 'trend', 'molecular', 'unmet_need', 'rarity', 'treatment', 'composite']; | |
| 13 | +const CATEGORY_LABEL: Record<string, string> = { burden: 'Burden', lethality: 'Lethality', trials: 'Clinical research', research: 'Research activity', trend: 'Trends', molecular: 'Molecular knowledge', unmet_need: 'Gap indexes', rarity: 'Rarity', treatment: 'Treatment', composite: 'Composite' }; | |
| 14 | + | |
| 15 | +export default async function RankingsIndex() { | |
| 16 | + const metrics = await listMetrics(); | |
| 17 | + const byCat = new Map<string, typeof metrics>(); | |
| 18 | + for (const m of metrics) byCat.set(m.category, [...(byCat.get(m.category) ?? []), m]); | |
| 19 | + const cats = [...byCat.keys()].sort((a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)); | |
| 20 | + const computed = metrics.filter((m) => m.snapshot_count > 0).length; | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <div> | |
| 24 | + <PageHeader kicker="Rankings" title="Rankings" lede="Every ranking is one metric, in one scope (geography · sex · age · year · entity level), under one formula version. No composite 'worst cancer' score is published in this phase."> | |
| 25 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 26 | + <span className="ci-num font-medium">{computed}</span> of <span className="ci-num">{metrics.length}</span> metrics currently have a computed snapshot. | |
| 27 | + </p> | |
| 28 | + </PageHeader> | |
| 29 | + | |
| 30 | + <Note tone="warn">"Deadliest cancer" has no single answer. Annual deaths, age-standardized mortality rate, mortality-to-incidence ratio and 5-year survival each rank cancers differently and are published as separate metrics. Pick the question before reading the table.</Note> | |
| 31 | + | |
| 32 | + {metrics.length === 0 ? ( | |
| 33 | + <div className="mt-6"> | |
| 34 | + <EmptyState title="Metric catalog not seeded">The metric definitions table is empty on this environment. Run the database seed.</EmptyState> | |
| 35 | + </div> | |
| 36 | + ) : ( | |
| 37 | + <div className="mt-6 space-y-8"> | |
| 38 | + {cats.map((cat) => ( | |
| 39 | + <Section key={cat} id={cat} kicker="Category" title={CATEGORY_LABEL[cat] ?? humanize(cat)}> | |
| 40 | + <div className="ci-table-wrap"> | |
| 41 | + <table className="ci-table"> | |
| 42 | + <thead> | |
| 43 | + <tr> | |
| 44 | + <th>Metric</th> | |
| 45 | + <th>Unit</th> | |
| 46 | + <th>Direction</th> | |
| 47 | + <th>Scopes</th> | |
| 48 | + <th className="num">Snapshots</th> | |
| 49 | + <th>Sources</th> | |
| 50 | + <th>Formula version</th> | |
| 51 | + </tr> | |
| 52 | + </thead> | |
| 53 | + <tbody> | |
| 54 | + {(byCat.get(cat) ?? []).map((m) => ( | |
| 55 | + <tr key={m.slug}> | |
| 56 | + <td className="min-w-[220px]"> | |
| 57 | + <Link className="ci-link font-medium" href={`/rankings/${m.slug}`}> | |
| 58 | + {m.name} | |
| 59 | + </Link> | |
| 60 | + {m.experimental ? <Badge tone="warn" className="ml-1">experimental</Badge> : null} | |
| 61 | + <p className="mt-0.5 max-w-[520px] text-[12px] text-ink-3">{m.description}</p> | |
| 62 | + </td> | |
| 63 | + <td>{unitLabel(m.unit)}</td> | |
| 64 | + <td className="text-[12.5px]">{m.higher_is_worse == null ? 'neutral' : m.higher_is_worse ? 'higher = worse' : 'higher = better'}</td> | |
| 65 | + <td className="text-[12px] text-ink-3">{m.valid_dimensions.join(', ')}</td> | |
| 66 | + <td className="num">{m.snapshot_count > 0 ? m.snapshot_count : <span className="text-ink-4" title="No snapshot computed yet">—</span>}</td> | |
| 67 | + <td className="text-[12px]"> | |
| 68 | + {m.source_slugs.map((s) => ( | |
| 69 | + <Link key={s} href={`/source/${s}`} className="ci-mono mr-1 text-ink-2 hover:text-accent"> | |
| 70 | + {s} | |
| 71 | + </Link> | |
| 72 | + ))} | |
| 73 | + </td> | |
| 74 | + <td> | |
| 75 | + <span className="ci-mono text-[11.5px]">{m.formula_version}</span> | |
| 76 | + </td> | |
| 77 | + </tr> | |
| 78 | + ))} | |
| 79 | + </tbody> | |
| 80 | + </table> | |
| 81 | + </div> | |
| 82 | + </Section> | |
| 83 | + ))} | |
| 84 | + </div> | |
| 85 | + )} | |
| 86 | + </div> | |
| 87 | + ); | |
| 88 | +} | |
added
apps/web/src/app/robots.ts
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +import type { MetadataRoute } from 'next'; | |
| 2 | +import { SITE_URL } from '@/lib/site'; | |
| 3 | + | |
| 4 | +export default function robots(): MetadataRoute.Robots { | |
| 5 | + return { | |
| 6 | + rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api/', '/search?', '/healthz'] }], | |
| 7 | + sitemap: [`${SITE_URL}/sitemap.xml`], | |
| 8 | + host: SITE_URL, | |
| 9 | + }; | |
| 10 | +} | |
added
apps/web/src/app/search/page.tsx
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader } from '@/components/ui/section'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Badge } from '@/components/ui/badge'; | |
| 6 | +import { searchEntities } from '@/lib/queries/search'; | |
| 7 | +import { str, type SP } from '@/lib/search-params'; | |
| 8 | +import { fmtInt } from '@/lib/format'; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { title: 'Search', robots: { index: false } }; | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | + | |
| 13 | +const TYPE_LABEL: Record<string, string> = { cancer: 'Cancers', gene: 'Genes', variant: 'Variants', drug: 'Drugs', trial: 'Clinical trials', publication: 'Publications', source: 'Sources' }; | |
| 14 | +const TYPE_ORDER = ['cancer', 'gene', 'variant', 'drug', 'trial', 'publication', 'source']; | |
| 15 | + | |
| 16 | +export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 17 | + const sp = await searchParams; | |
| 18 | + const q = str(sp, 'q').slice(0, 200); | |
| 19 | + const hits = q.length >= 2 ? await searchEntities(q, 50) : []; | |
| 20 | + const groups = new Map<string, typeof hits>(); | |
| 21 | + for (const h of hits) groups.set(h.type, [...(groups.get(h.type) ?? []), h]); | |
| 22 | + const types = [...groups.keys()].sort((a, b) => TYPE_ORDER.indexOf(a) - TYPE_ORDER.indexOf(b)); | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <div> | |
| 26 | + <PageHeader kicker="Search" title={q ? <>Results for <q className="italic">{q}</q></> : 'Search'} lede="Typed results across cancers, genes, variants, drugs, trials, publications and sources. Ordering: exact name, then alias or identifier, then prefix, then fuzzy match." /> | |
| 27 | + <form method="get" action="/search" className="flex max-w-2xl gap-2"> | |
| 28 | + <input name="q" defaultValue={q} placeholder="glioblastoma, EGFR, L858R, osimertinib, NCT04487080, 31562796…" className="flex-1 border border-rule-strong bg-white px-3 py-2 text-[15px] outline-none focus:border-accent" aria-label="Search query" autoFocus={!q} /> | |
| 29 | + <button type="submit" className="border border-ink bg-ink px-4 py-2 text-[14px] text-paper hover:bg-ink-2"> | |
| 30 | + Search | |
| 31 | + </button> | |
| 32 | + </form> | |
| 33 | + | |
| 34 | + {q.length > 0 && q.length < 2 ? <p className="mt-4 text-[13.5px] text-ink-3">Type at least two characters.</p> : null} | |
| 35 | + {q.length >= 2 && hits.length === 0 ? ( | |
| 36 | + <div className="mt-6"> | |
| 37 | + <EmptyState title="No entity matches" knows={[{ label: 'Browse cancers', href: '/cancers' }, { label: 'Taxonomy', href: '/taxonomy' }, { label: 'Genes', href: '/genes' }, { label: 'Drugs', href: '/drugs' }]}> | |
| 38 | + No cancer, gene, variant, drug, trial or publication matched <q>{q}</q>. Only indexed entities are searchable; some entity types are not yet ingested on this environment. | |
| 39 | + </EmptyState> | |
| 40 | + </div> | |
| 41 | + ) : null} | |
| 42 | + {hits.length ? ( | |
| 43 | + <div className="mt-6 space-y-8"> | |
| 44 | + <p className="text-[13px] text-ink-3"> | |
| 45 | + <span className="ci-num font-medium text-ink">{fmtInt(hits.length)}</span> results in {types.length} {types.length === 1 ? 'type' : 'types'} | |
| 46 | + </p> | |
| 47 | + {types.map((t) => ( | |
| 48 | + <section key={t} aria-labelledby={`type-${t}`}> | |
| 49 | + <h2 id={`type-${t}`} className="mb-2 text-xl"> | |
| 50 | + {TYPE_LABEL[t] ?? t} <span className="ci-num text-[13px] text-ink-3">{groups.get(t)!.length}</span> | |
| 51 | + </h2> | |
| 52 | + <ul className="divide-y divide-rule"> | |
| 53 | + {groups.get(t)!.map((h) => ( | |
| 54 | + <li key={`${h.type}-${h.id}`} className="flex flex-wrap items-baseline gap-x-3 py-2 text-[14px]"> | |
| 55 | + <Link href={h.href} className="ci-link font-medium"> | |
| 56 | + {h.title} | |
| 57 | + </Link> | |
| 58 | + {h.subtitle ? <span className="text-[12.5px] text-ink-3">{h.subtitle}</span> : null} | |
| 59 | + <span className="ml-auto flex items-center gap-1.5"> | |
| 60 | + <span className="ci-mono text-[10.5px] text-ink-4">{h.id}</span> | |
| 61 | + <Badge tone="outline">{h.match}</Badge> | |
| 62 | + </span> | |
| 63 | + </li> | |
| 64 | + ))} | |
| 65 | + </ul> | |
| 66 | + </section> | |
| 67 | + ))} | |
| 68 | + </div> | |
| 69 | + ) : null} | |
| 70 | + </div> | |
| 71 | + ); | |
| 72 | +} | |
added
apps/web/src/app/sitemap.xml/route.ts
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +import { indexXml, sitemapChunks } from '@/lib/sitemap'; | |
| 2 | + | |
| 3 | +export const dynamic = 'force-dynamic'; | |
| 4 | + | |
| 5 | +/** Sitemap index → chunked sitemaps at /sitemap/<id> (cancers, genes, drugs, trials, sources). */ | |
| 6 | +export async function GET() { | |
| 7 | + const chunks = await sitemapChunks(); | |
| 8 | + return new Response(indexXml([0, ...chunks.map((c) => c.id)]), { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } }); | |
| 9 | +} | |
added
apps/web/src/app/sitemap/[id]/route.ts
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +import { sitemapEntries, urlsetXml } from '@/lib/sitemap'; | |
| 2 | + | |
| 3 | +export const dynamic = 'force-dynamic'; | |
| 4 | + | |
| 5 | +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) { | |
| 6 | + const raw = (await ctx.params).id.replace(/\.xml$/, ''); | |
| 7 | + if (!/^\d{1,4}$/.test(raw)) return new Response('not found', { status: 404 }); | |
| 8 | + const entries = await sitemapEntries(Number(raw)); | |
| 9 | + if (entries.length === 0 && Number(raw) !== 0) return new Response('not found', { status: 404 }); | |
| 10 | + return new Response(urlsetXml(entries), { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } }); | |
| 11 | +} | |
added
apps/web/src/app/source/[slug]/page.tsx
+189 −0
@@ -0,0 +1,189 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, StatusBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { getSourceBySlug, listRuns, recordCountsByKind, licenseMeaning, sourceDomains } from '@/lib/queries/sources'; | |
| 10 | +import { fmtDate, fmtDateTime, fmtDuration, fmtInt, humanize, relativeTime } from '@/lib/format'; | |
| 11 | + | |
| 12 | +export const revalidate = 300; | |
| 13 | + | |
| 14 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 15 | + const s = await getSourceBySlug((await params).slug); | |
| 16 | + return s ? { title: `${s.name} — source`, description: s.description ?? `License, access, connector health and record counts for ${s.name}.` } : { title: 'Source' }; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export default async function SourcePage({ params }: { params: Promise<{ slug: string }> }) { | |
| 20 | + const { slug } = await params; | |
| 21 | + const s = await getSourceBySlug(slug); | |
| 22 | + if (!s) notFound(); | |
| 23 | + const [runs, kinds] = await Promise.all([listRuns({ sourceId: s.id, limit: 30 }), recordCountsByKind(s.id)]); | |
| 24 | + const domains = [...sourceDomains(s)]; | |
| 25 | + | |
| 26 | + return ( | |
| 27 | + <div> | |
| 28 | + <PageHeader kicker={`Source · ${humanize(s.category)}`} title={s.name} lede={s.description ?? undefined}> | |
| 29 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 30 | + <span className="ci-mono text-ink-3">{s.id}</span> | |
| 31 | + <span className="ci-mono text-ink-3">connector: {s.slug}</span> | |
| 32 | + <StatusBadge status={s.status} /> | |
| 33 | + <StatusBadge status={s.health ?? 'unknown'} /> | |
| 34 | + {s.homepage ? ( | |
| 35 | + <a className="ci-link inline-flex items-center gap-1" href={s.homepage} target="_blank" rel="noopener noreferrer"> | |
| 36 | + Homepage <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 37 | + </a> | |
| 38 | + ) : null} | |
| 39 | + {s.docs_url ? ( | |
| 40 | + <a className="ci-link inline-flex items-center gap-1" href={s.docs_url} target="_blank" rel="noopener noreferrer"> | |
| 41 | + API docs <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 42 | + </a> | |
| 43 | + ) : null} | |
| 44 | + {s.terms_url ? ( | |
| 45 | + <a className="ci-link inline-flex items-center gap-1" href={s.terms_url} target="_blank" rel="noopener noreferrer"> | |
| 46 | + Terms <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 47 | + </a> | |
| 48 | + ) : null} | |
| 49 | + </p> | |
| 50 | + </PageHeader> | |
| 51 | + | |
| 52 | + <div className="grid gap-8 lg:grid-cols-[1fr_360px]"> | |
| 53 | + <div className="space-y-8"> | |
| 54 | + <Section id="license" kicker="License" title="License and attribution"> | |
| 55 | + <KV | |
| 56 | + items={[ | |
| 57 | + { k: 'Status', v: <Badge tone={s.license_status === 'approved' ? 'ok' : s.license_status === 'blocked' ? 'danger' : 'warn'}>{s.license_status}</Badge> }, | |
| 58 | + { k: 'License', v: s.license ?? <span className="text-ink-3">not recorded</span> }, | |
| 59 | + { k: 'Commercial use', v: humanize(s.commercial_use) }, | |
| 60 | + { k: 'Redistribution', v: humanize(s.redistribution) }, | |
| 61 | + { k: 'Reviewed', v: s.license_reviewed_at ? fmtDate(s.license_reviewed_at) : <span className="text-ink-3">not yet reviewed</span> }, | |
| 62 | + { k: 'Approved for production', v: s.approved_for_production ? 'Yes' : 'No' }, | |
| 63 | + { k: 'Attribution', v: s.attribution ? <span className="italic">{s.attribution}</span> : <span className="text-ink-3">—</span> }, | |
| 64 | + ]} | |
| 65 | + /> | |
| 66 | + <Note>{licenseMeaning(s.license_status, s.redistribution, s.commercial_use)}</Note> | |
| 67 | + </Section> | |
| 68 | + | |
| 69 | + <Section id="runs" kicker="Connector" title="Ingest runs" description="Every execution of the connector: mode, status, counts, duration and dataset version. Runs never mass-delete on a shrunken response (anomaly guard)."> | |
| 70 | + {runs.length ? ( | |
| 71 | + <div className="ci-table-wrap"> | |
| 72 | + <table className="ci-table"> | |
| 73 | + <thead> | |
| 74 | + <tr> | |
| 75 | + <th>Run</th> | |
| 76 | + <th>Mode</th> | |
| 77 | + <th>Status</th> | |
| 78 | + <th>Started</th> | |
| 79 | + <th className="num">Duration</th> | |
| 80 | + <th className="num">Fetched</th> | |
| 81 | + <th className="num">Created</th> | |
| 82 | + <th className="num">Updated</th> | |
| 83 | + <th className="num">Rejected</th> | |
| 84 | + <th className="num">HTTP fail</th> | |
| 85 | + <th>Dataset version</th> | |
| 86 | + </tr> | |
| 87 | + </thead> | |
| 88 | + <tbody> | |
| 89 | + {runs.map((r) => ( | |
| 90 | + <tr key={r.id}> | |
| 91 | + <td> | |
| 92 | + <Link href={`/admin/runs/${r.id}`} className="ci-mono ci-link text-[11.5px]"> | |
| 93 | + {r.id} | |
| 94 | + </Link> | |
| 95 | + {r.anomaly ? <Badge tone="danger" className="ml-1">anomaly</Badge> : null} | |
| 96 | + {r.schema_drift?.length ? <Badge tone="warn" className="ml-1">drift {r.schema_drift.length}</Badge> : null} | |
| 97 | + </td> | |
| 98 | + <td>{r.mode}</td> | |
| 99 | + <td> | |
| 100 | + <StatusBadge status={r.status} /> | |
| 101 | + </td> | |
| 102 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDateTime(r.started_at)}</td> | |
| 103 | + <td className="num">{fmtDuration(r.duration_ms)}</td> | |
| 104 | + <td className="num">{fmtInt(r.records_fetched)}</td> | |
| 105 | + <td className="num">{fmtInt(r.records_created)}</td> | |
| 106 | + <td className="num">{fmtInt(r.records_updated)}</td> | |
| 107 | + <td className="num">{fmtInt(r.records_rejected)}</td> | |
| 108 | + <td className="num"> | |
| 109 | + {fmtInt(r.http_failures)}/{fmtInt(r.http_requests)} | |
| 110 | + </td> | |
| 111 | + <td className="ci-mono text-[11.5px]">{r.dataset_version ?? '—'}</td> | |
| 112 | + </tr> | |
| 113 | + ))} | |
| 114 | + </tbody> | |
| 115 | + </table> | |
| 116 | + </div> | |
| 117 | + ) : ( | |
| 118 | + <EmptyState compact title="No run yet"> | |
| 119 | + {s.status === 'awaiting_credentials' ? 'The connector is waiting for API credentials.' : s.status === 'review' ? 'The connector is blocked until the license review completes.' : 'This connector has not been executed on this environment.'} | |
| 120 | + </EmptyState> | |
| 121 | + )} | |
| 122 | + </Section> | |
| 123 | + | |
| 124 | + <Section id="records" kicker="Records" title="Source records by entity kind" description="Native records kept for idempotency and lineage (source + entity kind + source record id)."> | |
| 125 | + {kinds.length ? ( | |
| 126 | + <div className="ci-table-wrap"> | |
| 127 | + <table className="ci-table"> | |
| 128 | + <thead> | |
| 129 | + <tr> | |
| 130 | + <th>Entity kind</th> | |
| 131 | + <th>Status</th> | |
| 132 | + <th className="num">Records (count)</th> | |
| 133 | + <th>Last retrieved</th> | |
| 134 | + </tr> | |
| 135 | + </thead> | |
| 136 | + <tbody> | |
| 137 | + {kinds.map((k) => ( | |
| 138 | + <tr key={`${k.entity_kind}-${k.status}`}> | |
| 139 | + <td className="ci-mono">{k.entity_kind}</td> | |
| 140 | + <td> | |
| 141 | + <StatusBadge status={k.status} /> | |
| 142 | + </td> | |
| 143 | + <td className="num">{fmtInt(k.n)}</td> | |
| 144 | + <td className="whitespace-nowrap text-[12.5px]">{k.last_retrieved ? fmtDateTime(k.last_retrieved) : '—'}</td> | |
| 145 | + </tr> | |
| 146 | + ))} | |
| 147 | + </tbody> | |
| 148 | + </table> | |
| 149 | + </div> | |
| 150 | + ) : ( | |
| 151 | + <EmptyState compact>No record ingested from this source yet.</EmptyState> | |
| 152 | + )} | |
| 153 | + <Freshness dataUpdatedAt={s.last_success_at} sourceVersion={s.last_dataset_version} /> | |
| 154 | + </Section> | |
| 155 | + </div> | |
| 156 | + | |
| 157 | + <aside className="space-y-8"> | |
| 158 | + <Section id="access" kicker="Access" title="Connector profile" level={3}> | |
| 159 | + <KV | |
| 160 | + items={[ | |
| 161 | + { k: 'Organization', v: s.organization }, | |
| 162 | + { k: 'Access', v: `${s.access_type} · ${humanize(s.access_auth)}` }, | |
| 163 | + { k: 'Update frequency', v: s.update_frequency }, | |
| 164 | + { k: 'Incremental', v: s.supports_incremental ? 'Yes' : 'No (full sync)' }, | |
| 165 | + { k: 'Rate limit', v: s.rate_limit }, | |
| 166 | + { k: 'Tier', v: <span className="ci-num">{s.tier}</span> }, | |
| 167 | + { k: 'Entities', v: s.entities.length ? s.entities.join(', ') : null }, | |
| 168 | + { k: 'Metrics', v: s.metrics.length ? s.metrics.join(', ') : null }, | |
| 169 | + { k: 'Domains', v: domains.length ? domains.join(', ') : null }, | |
| 170 | + ]} | |
| 171 | + /> | |
| 172 | + </Section> | |
| 173 | + <Section id="health" kicker="Health" title="Connector health" level={3}> | |
| 174 | + <KV | |
| 175 | + items={[ | |
| 176 | + { k: 'Health', v: <StatusBadge status={s.health ?? 'unknown'} /> }, | |
| 177 | + { k: 'Detail', v: s.health_detail }, | |
| 178 | + { k: 'Paused', v: s.paused ? 'Yes' : 'No' }, | |
| 179 | + { k: 'Last success', v: s.last_success_at ? `${relativeTime(s.last_success_at)} (${fmtDateTime(s.last_success_at)})` : '—' }, | |
| 180 | + { k: 'Last attempt', v: s.last_attempt_at ? fmtDateTime(s.last_attempt_at) : '—' }, | |
| 181 | + { k: 'Last run', v: s.last_run_id ? <Link className="ci-mono ci-link text-[11.5px]" href={`/admin/runs/${s.last_run_id}`}>{s.last_run_id}</Link> : '—' }, | |
| 182 | + ]} | |
| 183 | + /> | |
| 184 | + </Section> | |
| 185 | + </aside> | |
| 186 | + </div> | |
| 187 | + </div> | |
| 188 | + ); | |
| 189 | +} | |
added
apps/web/src/app/sources/page.tsx
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Check } from 'lucide-react'; | |
| 4 | +import { PageHeader, Section, Note } from '@/components/ui/section'; | |
| 5 | +import { Badge, StatusBadge } from '@/components/ui/badge'; | |
| 6 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 7 | +import { Freshness } from '@/components/ui/freshness'; | |
| 8 | +import { listSources, COVERAGE_DOMAINS, sourceDomains } from '@/lib/queries/sources'; | |
| 9 | +import { fmtDate, fmtInt, humanize, relativeTime } from '@/lib/format'; | |
| 10 | + | |
| 11 | +export const metadata: Metadata = { title: 'Sources', description: 'Public catalog of every data source: license status, access, coverage matrix and connector health.' }; | |
| 12 | +export const revalidate = 600; | |
| 13 | + | |
| 14 | +export default async function SourcesPage() { | |
| 15 | + const sources = await listSources(); | |
| 16 | + return ( | |
| 17 | + <div> | |
| 18 | + <PageHeader kicker="Sources" title="Source registry" lede="Every fact on CancerIndex traces back to one of these providers. A connector goes live only after its license is reviewed; sources under review are listed but contribute no public records." /> | |
| 19 | + {sources.length === 0 ? ( | |
| 20 | + <EmptyState title="No source registered">Run `pnpm cix sources:sync` to seed the registry from connector manifests.</EmptyState> | |
| 21 | + ) : ( | |
| 22 | + <div className="space-y-10"> | |
| 23 | + <Section id="coverage" kicker="Coverage matrix" title="Sources × data domains" description="A check means the source is declared to contribute to that domain (from its manifest). It does not imply records exist yet — see each source page for counts."> | |
| 24 | + <div className="ci-table-wrap"> | |
| 25 | + <table className="ci-table"> | |
| 26 | + <thead> | |
| 27 | + <tr> | |
| 28 | + <th className="sticky-col">Source</th> | |
| 29 | + {COVERAGE_DOMAINS.map((d) => ( | |
| 30 | + <th key={d} className="text-center"> | |
| 31 | + {d} | |
| 32 | + </th> | |
| 33 | + ))} | |
| 34 | + <th>License</th> | |
| 35 | + <th>Status</th> | |
| 36 | + </tr> | |
| 37 | + </thead> | |
| 38 | + <tbody> | |
| 39 | + {sources.map((s) => { | |
| 40 | + const dom = sourceDomains(s); | |
| 41 | + return ( | |
| 42 | + <tr key={s.slug}> | |
| 43 | + <td className="sticky-col min-w-[200px]"> | |
| 44 | + <Link className="ci-link font-medium" href={`/source/${s.slug}`}> | |
| 45 | + {s.name} | |
| 46 | + </Link> | |
| 47 | + <span className="block text-[11.5px] text-ink-3">{s.organization ?? humanize(s.category)}</span> | |
| 48 | + </td> | |
| 49 | + {COVERAGE_DOMAINS.map((d) => ( | |
| 50 | + <td key={d} className="text-center"> | |
| 51 | + {dom.has(d) ? <Check className="mx-auto h-4 w-4 text-accent" aria-label={`covers ${d}`} /> : <span className="text-ink-4" aria-label={`does not cover ${d}`}>·</span>} | |
| 52 | + </td> | |
| 53 | + ))} | |
| 54 | + <td> | |
| 55 | + <Badge tone={s.license_status === 'approved' ? 'ok' : s.license_status === 'blocked' ? 'danger' : 'warn'} title={s.license ?? undefined}> | |
| 56 | + {s.license_status} | |
| 57 | + </Badge> | |
| 58 | + </td> | |
| 59 | + <td> | |
| 60 | + <StatusBadge status={s.status} /> | |
| 61 | + </td> | |
| 62 | + </tr> | |
| 63 | + ); | |
| 64 | + })} | |
| 65 | + </tbody> | |
| 66 | + </table> | |
| 67 | + </div> | |
| 68 | + </Section> | |
| 69 | + | |
| 70 | + <Section id="catalog" kicker="Catalog" title="All sources" description={`${fmtInt(sources.length)} registered sources.`}> | |
| 71 | + <div className="ci-table-wrap"> | |
| 72 | + <table className="ci-table"> | |
| 73 | + <thead> | |
| 74 | + <tr> | |
| 75 | + <th>Source</th> | |
| 76 | + <th>Category</th> | |
| 77 | + <th>Access</th> | |
| 78 | + <th>License</th> | |
| 79 | + <th>Redistribution</th> | |
| 80 | + <th>Health</th> | |
| 81 | + <th>Last success</th> | |
| 82 | + <th className="num">Records</th> | |
| 83 | + <th>Dataset version</th> | |
| 84 | + </tr> | |
| 85 | + </thead> | |
| 86 | + <tbody> | |
| 87 | + {sources.map((s) => ( | |
| 88 | + <tr key={s.slug}> | |
| 89 | + <td className="min-w-[200px]"> | |
| 90 | + <Link className="ci-link" href={`/source/${s.slug}`}> | |
| 91 | + {s.name} | |
| 92 | + </Link> | |
| 93 | + <span className="ci-mono ml-1 text-[10.5px] text-ink-4">{s.slug}</span> | |
| 94 | + </td> | |
| 95 | + <td>{humanize(s.category)}</td> | |
| 96 | + <td className="text-[12.5px]"> | |
| 97 | + {s.access_type} · {humanize(s.access_auth)} | |
| 98 | + </td> | |
| 99 | + <td className="text-[12.5px]">{s.license ?? <span className="text-ink-4">—</span>}</td> | |
| 100 | + <td className="text-[12.5px]">{humanize(s.redistribution)}</td> | |
| 101 | + <td> | |
| 102 | + <StatusBadge status={s.health ?? 'unknown'} /> | |
| 103 | + </td> | |
| 104 | + <td className="whitespace-nowrap text-[12.5px]" title={s.last_success_at ? fmtDate(s.last_success_at) : undefined}> | |
| 105 | + {s.last_success_at ? relativeTime(s.last_success_at) : '—'} | |
| 106 | + </td> | |
| 107 | + <td className="num">{fmtInt(s.record_count)}</td> | |
| 108 | + <td className="ci-mono text-[11.5px]">{s.last_dataset_version ?? '—'}</td> | |
| 109 | + </tr> | |
| 110 | + ))} | |
| 111 | + </tbody> | |
| 112 | + </table> | |
| 113 | + </div> | |
| 114 | + <Freshness dataUpdatedAt={sources.reduce<Date | null>((m, s) => (s.last_run_finished_at && (!m || s.last_run_finished_at > m) ? s.last_run_finished_at : m), null)} /> | |
| 115 | + </Section> | |
| 116 | + <Note>License status meanings — approved: reviewed, may be ingested and displayed with attribution · review: terms under assessment, no public records · restricted: displayed with limits · blocked: not usable. See each source page for the plain-language summary.</Note> | |
| 117 | + </div> | |
| 118 | + )} | |
| 119 | + </div> | |
| 120 | + ); | |
| 121 | +} | |
added
apps/web/src/app/taxonomy/page.tsx
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { TOP_LEVEL_CANCERS } from '@cancerindex/ontology'; | |
| 4 | +import { PageHeader, Section } from '@/components/ui/section'; | |
| 5 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 6 | +import { Badge } from '@/components/ui/badge'; | |
| 7 | +import { Tree } from '@/components/data/tree'; | |
| 8 | +import { hierarchyTypes, rootsOf, orphanCount, anatomicalView } from '@/lib/queries/taxonomy'; | |
| 9 | +import { resolveTopLevel } from '@/lib/queries/cancers'; | |
| 10 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 11 | +import { str, type SP } from '@/lib/search-params'; | |
| 12 | + | |
| 13 | +export const metadata: Metadata = { title: 'Taxonomy', description: 'Browse the cancer taxonomy by NCIt hierarchy, OncoTree hierarchy or anatomical site.' }; | |
| 14 | +export const revalidate = 3600; | |
| 15 | + | |
| 16 | +export default async function TaxonomyPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 17 | + const sp = await searchParams; | |
| 18 | + const types = await hierarchyTypes(); | |
| 19 | + const available = types.map((t) => t.hierarchy_type); | |
| 20 | + const requested = str(sp, 'view', ''); | |
| 21 | + const view = requested === 'anatomy' ? 'anatomy' : available.includes(requested) ? requested : available.includes('ncit') ? 'ncit' : (available[0] ?? 'ncit'); | |
| 22 | + | |
| 23 | + const topLevel = await resolveTopLevel(TOP_LEVEL_CANCERS.map((t) => t.ncit)); | |
| 24 | + const [roots, orphans, sites] = await Promise.all([view === 'anatomy' ? Promise.resolve([]) : rootsOf(view), view === 'anatomy' ? Promise.resolve(0) : orphanCount(view), view === 'anatomy' ? anatomicalView() : Promise.resolve([])]); | |
| 25 | + | |
| 26 | + const views = [...available.map((t) => ({ key: t, label: t === 'ncit' ? 'NCIt hierarchy' : t === 'oncotree' ? 'OncoTree hierarchy' : `${humanize(t)} hierarchy`, n: types.find((x) => x.hierarchy_type === t)?.n ?? 0 })), { key: 'anatomy', label: 'Anatomical view', n: null as number | null }]; | |
| 27 | + if (!available.includes('ncit')) views.unshift({ key: 'ncit', label: 'NCIt hierarchy', n: 0 }); | |
| 28 | + | |
| 29 | + return ( | |
| 30 | + <div> | |
| 31 | + <PageHeader kicker="Taxonomy" title="Tree browser" lede="Several hierarchies coexist: the NCIt-derived disease tree, the OncoTree clinical tree and the anatomical grouping. Children load on demand. Non-malignant and precursor entities are shown in grey." /> | |
| 32 | + | |
| 33 | + <nav aria-label="Hierarchy" className="flex flex-wrap gap-2 border-y border-rule py-2 text-[13.5px]"> | |
| 34 | + {views.map((v) => ( | |
| 35 | + <Link key={v.key} href={`/taxonomy?view=${v.key}`} aria-current={view === v.key ? 'page' : undefined} className={`border px-2.5 py-1 no-underline ${view === v.key ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 36 | + {v.label} | |
| 37 | + {v.n != null ? <span className="ci-num ml-1.5 text-[11px] text-ink-3">{fmtInt(v.n)} edges</span> : null} | |
| 38 | + </Link> | |
| 39 | + ))} | |
| 40 | + </nav> | |
| 41 | + | |
| 42 | + <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 43 | + <div> | |
| 44 | + {view === 'anatomy' ? ( | |
| 45 | + sites.length ? ( | |
| 46 | + <div className="space-y-6"> | |
| 47 | + {sites.map((s) => ( | |
| 48 | + <Section key={s.id} id={`site-${s.slug}`} level={3} title={s.name} description={s.ncit_code ? `NCIt ${s.ncit_code} · ${s.cancers.length} linked ${s.cancers.length === 1 ? 'entity' : 'entities'}` : `${s.cancers.length} linked entities`}> | |
| 49 | + {s.cancers.length ? ( | |
| 50 | + <ul className="columns-1 gap-6 text-[13.5px] sm:columns-2 lg:columns-3"> | |
| 51 | + {s.cancers.map((c) => ( | |
| 52 | + <li key={`${c.id}-${c.relation}`} className="flex items-baseline gap-1.5 py-0.5"> | |
| 53 | + <Link href={`/cancer/${c.slug}`} className={`ci-link ${c.malignant ? '' : 'text-ink-3'}`}> | |
| 54 | + {c.canonical_name} | |
| 55 | + </Link> | |
| 56 | + {c.relation !== 'primary' ? <Badge tone="outline">{c.relation}</Badge> : null} | |
| 57 | + </li> | |
| 58 | + ))} | |
| 59 | + </ul> | |
| 60 | + ) : ( | |
| 61 | + <EmptyState compact>No entity linked to this site yet.</EmptyState> | |
| 62 | + )} | |
| 63 | + </Section> | |
| 64 | + ))} | |
| 65 | + </div> | |
| 66 | + ) : ( | |
| 67 | + <EmptyState title="Anatomical sites not yet available">Anatomical sites are populated by the terminology connectors (OncoTree tissues, NCIt anatomy).</EmptyState> | |
| 68 | + ) | |
| 69 | + ) : roots.length ? ( | |
| 70 | + <> | |
| 71 | + <p className="mb-2 text-[12.5px] text-ink-3"> | |
| 72 | + {fmtInt(roots.length)} root {roots.length === 1 ? 'node' : 'nodes'} | |
| 73 | + {orphans > 0 ? ( | |
| 74 | + <> | |
| 75 | + {' '} | |
| 76 | + · {fmtInt(orphans)} entities have no edge in this hierarchy and are listed in the{' '} | |
| 77 | + <Link className="ci-link" href="/cancers"> | |
| 78 | + explorer | |
| 79 | + </Link> | |
| 80 | + </> | |
| 81 | + ) : null} | |
| 82 | + </p> | |
| 83 | + <Tree roots={roots} hierarchyType={view} /> | |
| 84 | + </> | |
| 85 | + ) : ( | |
| 86 | + <EmptyState title={`${view === 'ncit' ? 'NCIt' : humanize(view)} hierarchy not yet available`} knows={views.filter((v) => v.key !== view && (v.n == null || v.n > 0)).map((v) => ({ label: v.label, href: `/taxonomy?view=${v.key}` }))}> | |
| 87 | + {view === 'ncit' ? 'The NCIt connector has not run on this environment yet. NCIt is the primary disease hierarchy; OncoTree edges are available meanwhile.' : 'No edges of this type have been ingested.'} | |
| 88 | + </EmptyState> | |
| 89 | + )} | |
| 90 | + </div> | |
| 91 | + | |
| 92 | + <aside> | |
| 93 | + <Section id="top-level" level={3} kicker="Ranking scope" title="Top-level set (36)"> | |
| 94 | + <p className="mb-2 text-[12.5px] text-ink-3">Mutually exclusive registry site groups used for global rankings, anchored to NCIt concepts. Greyed items are not yet indexed.</p> | |
| 95 | + <ul className="text-[13px]"> | |
| 96 | + {TOP_LEVEL_CANCERS.map((t) => { | |
| 97 | + const m = topLevel.get(t.ncit); | |
| 98 | + return ( | |
| 99 | + <li key={t.key} className="flex items-baseline justify-between gap-2 border-b border-rule py-1"> | |
| 100 | + {m ? ( | |
| 101 | + <Link href={`/cancer/${m.slug}`} className="ci-link truncate"> | |
| 102 | + {t.name} | |
| 103 | + </Link> | |
| 104 | + ) : ( | |
| 105 | + <span className="truncate text-ink-3">{t.name}</span> | |
| 106 | + )} | |
| 107 | + <span className="ci-mono shrink-0 text-[10.5px] text-ink-4">{t.icd10.join(', ')}</span> | |
| 108 | + </li> | |
| 109 | + ); | |
| 110 | + })} | |
| 111 | + </ul> | |
| 112 | + </Section> | |
| 113 | + </aside> | |
| 114 | + </div> | |
| 115 | + </div> | |
| 116 | + ); | |
| 117 | +} | |
added
apps/web/src/app/trial/[nct]/page.tsx
+268 −0
@@ -0,0 +1,268 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 10 | +import { JsonView } from '@/components/ui/json-view'; | |
| 11 | +import { PublicationList } from '@/components/data/publication-list'; | |
| 12 | +import { getTrialByNct, trialConditionsFor, trialInterventionsFor, trialLocationsByCountry } from '@/lib/queries/trials'; | |
| 13 | +import { publicationsForTrial } from '@/lib/queries/publications'; | |
| 14 | +import { fmtDate, fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 15 | + | |
| 16 | +export const revalidate = 3600; | |
| 17 | + | |
| 18 | +export async function generateMetadata({ params }: { params: Promise<{ nct: string }> }): Promise<Metadata> { | |
| 19 | + const t = await getTrialByNct((await params).nct); | |
| 20 | + return t ? { title: `${t.nct_id} — ${t.brief_title}`, description: t.brief_summary ? t.brief_summary.slice(0, 160) : `${t.nct_id}: status, phase, conditions, interventions, locations and references.` } : { title: 'Trial' }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export default async function TrialPage({ params }: { params: Promise<{ nct: string }> }) { | |
| 24 | + const { nct } = await params; | |
| 25 | + const t = await getTrialByNct(nct); | |
| 26 | + if (!t) notFound(); | |
| 27 | + if (t.nct_id !== nct) permanentRedirect(`/trial/${t.nct_id}`); | |
| 28 | + const [conditions, interventions, locations, pubs] = await Promise.all([trialConditionsFor(t.id), trialInterventionsFor(t.id), trialLocationsByCountry(t.id), publicationsForTrial(t.nct_id)]); | |
| 29 | + const elig = t.eligibility ?? {}; | |
| 30 | + const eligText = typeof elig.criteria === 'string' ? elig.criteria : typeof elig.eligibilityCriteria === 'string' ? elig.eligibilityCriteria : null; | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <article> | |
| 34 | + <PageHeader kicker={`Clinical trial · ${t.study_type ? humanize(t.study_type) : 'study'}`} title={t.brief_title} lede={t.official_title && t.official_title !== t.brief_title ? t.official_title : undefined}> | |
| 35 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 36 | + <span className="ci-mono text-ink-2">{t.nct_id}</span> | |
| 37 | + <span className="ci-mono text-ink-3">{t.id}</span> | |
| 38 | + {t.acronym ? <Badge tone="outline">{t.acronym}</Badge> : null} | |
| 39 | + <StatusBadge status={t.overall_status} /> | |
| 40 | + {t.phases.length ? <Badge>{t.phases.map(phaseLabel).join(' / ')}</Badge> : null} | |
| 41 | + {t.has_results ? <Badge tone="ok">Results posted</Badge> : null} | |
| 42 | + <a className="ci-link inline-flex items-center gap-1" href={`https://clinicaltrials.gov/study/${t.nct_id}`} target="_blank" rel="noopener noreferrer"> | |
| 43 | + ClinicalTrials.gov <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 44 | + </a> | |
| 45 | + <SourceBadge p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', retrievedAt: t.updated_at, ingestRunId: t.ingest_run_id, layer: 'normalized' }} /> | |
| 46 | + <ClaimBadge kind="published" /> | |
| 47 | + </p> | |
| 48 | + {t.why_stopped ? <Note tone="warn">Why stopped (as posted): {t.why_stopped}</Note> : null} | |
| 49 | + </PageHeader> | |
| 50 | + | |
| 51 | + <div className="grid gap-8 lg:grid-cols-[1fr_340px]"> | |
| 52 | + <div className="space-y-8"> | |
| 53 | + {t.brief_summary ? ( | |
| 54 | + <Section id="summary" kicker="Summary" title="Brief summary (as posted)"> | |
| 55 | + <p className="max-w-3xl whitespace-pre-line text-[14.5px] leading-relaxed">{t.brief_summary}</p> | |
| 56 | + </Section> | |
| 57 | + ) : null} | |
| 58 | + | |
| 59 | + <Section id="conditions" kicker="Conditions" title={`Conditions (${fmtInt(conditions.length || t.conditions.length)})`} description="Free-text conditions as registered, with the CancerIndex entity they were reconciled to and the match type."> | |
| 60 | + {conditions.length ? ( | |
| 61 | + <div className="ci-table-wrap"> | |
| 62 | + <table className="ci-table"> | |
| 63 | + <thead> | |
| 64 | + <tr> | |
| 65 | + <th>Condition (as posted)</th> | |
| 66 | + <th>Mapped entity</th> | |
| 67 | + <th>Match</th> | |
| 68 | + <th className="num">Confidence</th> | |
| 69 | + </tr> | |
| 70 | + </thead> | |
| 71 | + <tbody> | |
| 72 | + {conditions.map((c) => ( | |
| 73 | + <tr key={c.normalized}> | |
| 74 | + <td>{c.condition_text}</td> | |
| 75 | + <td> | |
| 76 | + {c.cancer_slug ? ( | |
| 77 | + <Link className="ci-link" href={`/cancer/${c.cancer_slug}/trials`}> | |
| 78 | + {c.cancer_name} | |
| 79 | + </Link> | |
| 80 | + ) : ( | |
| 81 | + <span className="text-ink-3">—</span> | |
| 82 | + )} | |
| 83 | + </td> | |
| 84 | + <td> | |
| 85 | + <MatchBadge matchType={c.match_type} /> | |
| 86 | + </td> | |
| 87 | + <td className="num">{c.confidence != null ? c.confidence.toFixed(2) : '—'}</td> | |
| 88 | + </tr> | |
| 89 | + ))} | |
| 90 | + </tbody> | |
| 91 | + </table> | |
| 92 | + </div> | |
| 93 | + ) : t.conditions.length ? ( | |
| 94 | + <ul className="flex flex-wrap gap-1.5 text-[13.5px]"> | |
| 95 | + {t.conditions.map((c) => ( | |
| 96 | + <li key={c} className="border border-rule px-2 py-0.5"> | |
| 97 | + {c} <MatchBadge matchType="UNRESOLVED" className="ml-1" /> | |
| 98 | + </li> | |
| 99 | + ))} | |
| 100 | + </ul> | |
| 101 | + ) : ( | |
| 102 | + <EmptyState compact>No condition recorded.</EmptyState> | |
| 103 | + )} | |
| 104 | + </Section> | |
| 105 | + | |
| 106 | + <Section id="interventions" kicker="Interventions" title={`Interventions (${fmtInt(interventions.length || t.interventions.length)})`}> | |
| 107 | + {interventions.length ? ( | |
| 108 | + <div className="ci-table-wrap"> | |
| 109 | + <table className="ci-table"> | |
| 110 | + <thead> | |
| 111 | + <tr> | |
| 112 | + <th>Intervention</th> | |
| 113 | + <th>Type</th> | |
| 114 | + <th>Mapped drug</th> | |
| 115 | + <th>Match</th> | |
| 116 | + </tr> | |
| 117 | + </thead> | |
| 118 | + <tbody> | |
| 119 | + {interventions.map((i) => ( | |
| 120 | + <tr key={i.name}> | |
| 121 | + <td>{i.name}</td> | |
| 122 | + <td>{i.intervention_type ? <Badge>{humanize(i.intervention_type)}</Badge> : '—'}</td> | |
| 123 | + <td> | |
| 124 | + {i.drug_slug ? ( | |
| 125 | + <Link className="ci-link" href={`/drug/${i.drug_slug}`}> | |
| 126 | + {i.drug_name} | |
| 127 | + </Link> | |
| 128 | + ) : ( | |
| 129 | + <span className="text-ink-3">—</span> | |
| 130 | + )} | |
| 131 | + </td> | |
| 132 | + <td> | |
| 133 | + <MatchBadge matchType={i.match_type} /> | |
| 134 | + </td> | |
| 135 | + </tr> | |
| 136 | + ))} | |
| 137 | + </tbody> | |
| 138 | + </table> | |
| 139 | + </div> | |
| 140 | + ) : t.interventions.length ? ( | |
| 141 | + <ul className="space-y-1 text-[13.5px]"> | |
| 142 | + {t.interventions.map((i, k) => ( | |
| 143 | + <li key={`${i.name}-${k}`}> | |
| 144 | + <Badge className="mr-1">{humanize(i.type)}</Badge> {i.name} | |
| 145 | + {i.description ? <span className="block text-[12.5px] text-ink-3">{i.description}</span> : null} | |
| 146 | + </li> | |
| 147 | + ))} | |
| 148 | + </ul> | |
| 149 | + ) : ( | |
| 150 | + <EmptyState compact>No intervention recorded.</EmptyState> | |
| 151 | + )} | |
| 152 | + </Section> | |
| 153 | + | |
| 154 | + {t.arms.length || t.primary_outcomes.length ? ( | |
| 155 | + <Section id="design" kicker="Design" title="Arms and outcomes"> | |
| 156 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 157 | + <div> | |
| 158 | + <p className="ci-kicker mb-1">Arms ({t.arms.length})</p> | |
| 159 | + <JsonView data={t.arms} /> | |
| 160 | + </div> | |
| 161 | + <div> | |
| 162 | + <p className="ci-kicker mb-1">Primary outcomes ({t.primary_outcomes.length})</p> | |
| 163 | + <JsonView data={t.primary_outcomes} /> | |
| 164 | + {t.secondary_outcomes.length ? ( | |
| 165 | + <details className="mt-2 text-[12.5px]"> | |
| 166 | + <summary className="ci-link">Secondary outcomes ({t.secondary_outcomes.length})</summary> | |
| 167 | + <JsonView data={t.secondary_outcomes} /> | |
| 168 | + </details> | |
| 169 | + ) : null} | |
| 170 | + </div> | |
| 171 | + </div> | |
| 172 | + </Section> | |
| 173 | + ) : null} | |
| 174 | + | |
| 175 | + <Section id="eligibility" kicker="Eligibility" title="Eligibility (as posted)"> | |
| 176 | + <KV items={[{ k: 'Sex', v: t.sex ? humanize(t.sex) : null }, { k: 'Minimum age', v: t.minimum_age }, { k: 'Maximum age', v: t.maximum_age }]} /> | |
| 177 | + {eligText ? ( | |
| 178 | + <details className="mt-3"> | |
| 179 | + <summary className="ci-link text-[13.5px]">Show eligibility criteria text</summary> | |
| 180 | + <pre className="ci-code mt-2 whitespace-pre-wrap">{eligText}</pre> | |
| 181 | + </details> | |
| 182 | + ) : Object.keys(elig).length ? ( | |
| 183 | + <details className="mt-3"> | |
| 184 | + <summary className="ci-link text-[13.5px]">Show eligibility record</summary> | |
| 185 | + <JsonView data={elig} /> | |
| 186 | + </details> | |
| 187 | + ) : ( | |
| 188 | + <p className="mt-2 text-[13px] text-ink-3">No eligibility text recorded.</p> | |
| 189 | + )} | |
| 190 | + </Section> | |
| 191 | + | |
| 192 | + <Section id="references" kicker="References" title={`Publications (${fmtInt(pubs.length || t.references.length)})`}> | |
| 193 | + {pubs.length ? <PublicationList rows={pubs} /> : null} | |
| 194 | + {t.references.length ? ( | |
| 195 | + <ul className="mt-2 space-y-1 text-[13px]"> | |
| 196 | + {t.references.map((r, i) => ( | |
| 197 | + <li key={`${r.pmid ?? i}`} className="text-ink-2"> | |
| 198 | + {r.type ? <Badge tone="outline" className="mr-1">{r.type}</Badge> : null} | |
| 199 | + {r.citation ?? ''} | |
| 200 | + {r.pmid ? ( | |
| 201 | + <> | |
| 202 | + {' '} | |
| 203 | + <Link className="ci-mono ci-link" href={`/publication/${r.pmid}`}> | |
| 204 | + PMID {r.pmid} | |
| 205 | + </Link> | |
| 206 | + </> | |
| 207 | + ) : null} | |
| 208 | + </li> | |
| 209 | + ))} | |
| 210 | + </ul> | |
| 211 | + ) : !pubs.length ? ( | |
| 212 | + <EmptyState compact>No reference posted for this study.</EmptyState> | |
| 213 | + ) : null} | |
| 214 | + </Section> | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + <aside className="space-y-8"> | |
| 218 | + <Section id="dates" kicker="Registry" title="Dates and enrollment" level={3}> | |
| 219 | + <KV | |
| 220 | + items={[ | |
| 221 | + { k: 'Start', v: fmtDate(t.start_date) }, | |
| 222 | + { k: 'Primary completion', v: fmtDate(t.primary_completion_date) }, | |
| 223 | + { k: 'Completion', v: fmtDate(t.completion_date) }, | |
| 224 | + { k: 'First posted', v: fmtDate(t.first_posted_date) }, | |
| 225 | + { k: 'Last update posted', v: fmtDate(t.last_update_posted_date) }, | |
| 226 | + { k: 'Results first posted', v: t.results_first_posted_date ? fmtDate(t.results_first_posted_date) : null }, | |
| 227 | + { k: 'Enrollment', v: t.enrollment_count != null ? <span className="ci-num">{fmtInt(t.enrollment_count)} {t.enrollment_type ? `(${t.enrollment_type.toLowerCase()})` : ''}</span> : null }, | |
| 228 | + { k: 'Lead sponsor', v: t.lead_sponsor ? <>{t.lead_sponsor} {t.lead_sponsor_class ? <Badge tone="outline">{t.lead_sponsor_class}</Badge> : null}</> : null }, | |
| 229 | + { k: 'Collaborators', v: t.collaborators.length ? t.collaborators.join('; ') : null }, | |
| 230 | + { k: 'Keywords', v: t.keywords.length ? t.keywords.join(', ') : null }, | |
| 231 | + ]} | |
| 232 | + /> | |
| 233 | + <Freshness dataUpdatedAt={t.updated_at} sourceUpdatedAt={t.last_update_posted_date} extra="source: clinicaltrials" /> | |
| 234 | + </Section> | |
| 235 | + <Section id="locations" kicker="Locations" title={`Locations by country (${fmtInt(t.locations_count)})`} level={3}> | |
| 236 | + {locations.length ? ( | |
| 237 | + <div className="ci-table-wrap"> | |
| 238 | + <table className="ci-table"> | |
| 239 | + <thead> | |
| 240 | + <tr> | |
| 241 | + <th>Country</th> | |
| 242 | + <th className="num">Sites</th> | |
| 243 | + <th className="num">Recruiting</th> | |
| 244 | + </tr> | |
| 245 | + </thead> | |
| 246 | + <tbody> | |
| 247 | + {locations.map((l) => ( | |
| 248 | + <tr key={l.country}> | |
| 249 | + <td>{l.country}</td> | |
| 250 | + <td className="num">{fmtInt(l.n)}</td> | |
| 251 | + <td className="num">{fmtInt(l.recruiting)}</td> | |
| 252 | + </tr> | |
| 253 | + ))} | |
| 254 | + </tbody> | |
| 255 | + </table> | |
| 256 | + </div> | |
| 257 | + ) : t.countries.length ? ( | |
| 258 | + <p className="text-[13.5px]">{t.countries.join(', ')}</p> | |
| 259 | + ) : ( | |
| 260 | + <p className="text-[13px] text-ink-3">No location posted.</p> | |
| 261 | + )} | |
| 262 | + </Section> | |
| 263 | + <Note tone="warn">Trial listing is informational. Eligibility is decided by the study team; contact information is on ClinicalTrials.gov.</Note> | |
| 264 | + </aside> | |
| 265 | + </div> | |
| 266 | + </article> | |
| 267 | + ); | |
| 268 | +} | |
added
apps/web/src/app/trials/page.tsx
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { PageHeader } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { TrialTable } from '@/components/data/trial-list'; | |
| 6 | +import { Freshness } from '@/components/ui/freshness'; | |
| 7 | +import { listTrials, trialFacets } from '@/lib/queries/trials'; | |
| 8 | +import { getCancerBySlug, getDescendantIds } from '@/lib/queries/cancers'; | |
| 9 | +import { fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 10 | +import { str, int, withParams, type SP } from '@/lib/search-params'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { title: 'Clinical trials', description: 'ClinicalTrials.gov oncology studies mapped to the cancer taxonomy, filterable by status, phase, country and cancer.' }; | |
| 13 | +export const dynamic = 'force-dynamic'; | |
| 14 | +const PAGE_SIZE = 50; | |
| 15 | + | |
| 16 | +export default async function TrialsPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 17 | + const sp = await searchParams; | |
| 18 | + const q = str(sp, 'q'); | |
| 19 | + const status = str(sp, 'status'); | |
| 20 | + const phase = str(sp, 'phase'); | |
| 21 | + const country = str(sp, 'country'); | |
| 22 | + const cancerSlug = str(sp, 'cancer'); | |
| 23 | + const page = int(sp, 'page', 1, 1, 100_000); | |
| 24 | + const cancer = cancerSlug ? await getCancerBySlug(cancerSlug) : null; | |
| 25 | + const cancerIds = cancer ? await getDescendantIds(cancer.id) : null; | |
| 26 | + const [facets, { rows, total }] = await Promise.all([trialFacets(cancerIds), listTrials({ q, status, phase, country, cancerIds, page, pageSize: PAGE_SIZE })]); | |
| 27 | + const current = { q, status, phase, country, cancer: cancerSlug }; | |
| 28 | + const href = (o: Record<string, string | number | null | undefined>) => `/trials${withParams(current, o)}`; | |
| 29 | + const anyTrials = facets.statuses.reduce((s, x) => s + x.n, 0); | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <div> | |
| 33 | + <PageHeader kicker="Clinical trials" title="Clinical trials" lede="Studies registered on ClinicalTrials.gov whose conditions were reconciled to the cancer taxonomy. Status and phase are as posted by the registrant." /> | |
| 34 | + <form method="get" action="/trials" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr_auto]"> | |
| 35 | + <label className="flex flex-col gap-1"> | |
| 36 | + <span className="ci-kicker">NCT, title, acronym or sponsor</span> | |
| 37 | + <input name="q" defaultValue={q} className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 38 | + </label> | |
| 39 | + <label className="flex flex-col gap-1"> | |
| 40 | + <span className="ci-kicker">Status</span> | |
| 41 | + <select name="status" defaultValue={status} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 42 | + <option value="">Any</option> | |
| 43 | + <option value="active">Active (any)</option> | |
| 44 | + {facets.statuses.map((s) => ( | |
| 45 | + <option key={s.k} value={s.k}> | |
| 46 | + {humanize(s.k)} ({s.n}) | |
| 47 | + </option> | |
| 48 | + ))} | |
| 49 | + </select> | |
| 50 | + </label> | |
| 51 | + <label className="flex flex-col gap-1"> | |
| 52 | + <span className="ci-kicker">Phase</span> | |
| 53 | + <select name="phase" defaultValue={phase} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 54 | + <option value="">Any</option> | |
| 55 | + {facets.phases.map((p) => ( | |
| 56 | + <option key={p.k} value={p.k}> | |
| 57 | + {phaseLabel(p.k)} ({p.n}) | |
| 58 | + </option> | |
| 59 | + ))} | |
| 60 | + </select> | |
| 61 | + </label> | |
| 62 | + <label className="flex flex-col gap-1"> | |
| 63 | + <span className="ci-kicker">Country</span> | |
| 64 | + <select name="country" defaultValue={country} className="border border-rule-strong bg-white px-2 py-1.5"> | |
| 65 | + <option value="">Any</option> | |
| 66 | + {facets.countries.map((c) => ( | |
| 67 | + <option key={c.k} value={c.k}> | |
| 68 | + {c.k} ({c.n}) | |
| 69 | + </option> | |
| 70 | + ))} | |
| 71 | + </select> | |
| 72 | + </label> | |
| 73 | + <label className="flex flex-col gap-1"> | |
| 74 | + <span className="ci-kicker">Cancer (slug)</span> | |
| 75 | + <input name="cancer" defaultValue={cancerSlug} placeholder="e.g. glioblastoma" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 76 | + </label> | |
| 77 | + <div className="flex items-end"> | |
| 78 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 79 | + Apply | |
| 80 | + </button> | |
| 81 | + </div> | |
| 82 | + </form> | |
| 83 | + <p className="mt-3 text-[13px] text-ink-2"> | |
| 84 | + <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> studies | |
| 85 | + {cancer ? ( | |
| 86 | + <> | |
| 87 | + {' '} | |
| 88 | + mapped to <span className="font-medium">{cancer.canonical_name}</span> and descendants | |
| 89 | + </> | |
| 90 | + ) : cancerSlug ? ( | |
| 91 | + <span className="text-warn"> — unknown cancer slug "{cancerSlug}" (ignored)</span> | |
| 92 | + ) : null} | |
| 93 | + </p> | |
| 94 | + {rows.length === 0 ? ( | |
| 95 | + <div className="mt-3"> | |
| 96 | + <EmptyState title={anyTrials === 0 ? 'Trials not yet available' : 'No study matches these filters'} knows={[{ label: 'Cancers explorer', href: '/cancers' }, { label: 'Sources', href: '/sources' }]}> | |
| 97 | + {anyTrials === 0 ? 'The ClinicalTrials.gov connector has not run on this environment. Studies appear here once ingested and their conditions reconciled to the taxonomy.' : 'Relax a filter or clear the search.'} | |
| 98 | + </EmptyState> | |
| 99 | + </div> | |
| 100 | + ) : ( | |
| 101 | + <> | |
| 102 | + <div className="mt-3"> | |
| 103 | + <TrialTable rows={rows} /> | |
| 104 | + </div> | |
| 105 | + <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 106 | + <Freshness dataUpdatedAt={rows.reduce((m, t) => (t.updated_at > m ? t.updated_at : m), rows[0]!.updated_at)} extra="source: clinicaltrials" /> | |
| 107 | + </> | |
| 108 | + )} | |
| 109 | + </div> | |
| 110 | + ); | |
| 111 | +} | |
added
apps/web/src/app/trust/page.tsx
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Section } from '@/components/ui/section'; | |
| 4 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { CONTACT_EMAIL, DISCLAIMER } from '@/lib/site'; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = { title: 'Trust & policies', description: 'Provenance, methodology, AI policy, privacy, security, source policy and corrections.' }; | |
| 8 | + | |
| 9 | +export default function TrustPage() { | |
| 10 | + return ( | |
| 11 | + <div className="ci-prose max-w-3xl"> | |
| 12 | + <PageHeader kicker="Trust" title="Trust & policies" lede={DISCLAIMER} /> | |
| 13 | + <Section id="provenance" kicker="§2" title="Provenance"> | |
| 14 | + <p>Every imported fact carries a provenance row: source, dataset, version, retrieval date, evidence type, license and — where relevant — PMID or DOI. Raw payloads are archived unchanged. Hover any source badge to see the chain; administrators can trace any value to its raw record.</p> | |
| 15 | + </Section> | |
| 16 | + <Section id="methodology" kicker="§250" title="Methodology"> | |
| 17 | + <p> | |
| 18 | + Derived values reference a versioned formula from the public <Link className="ci-link" href="/methodology">metric catalog</Link>. Snapshots store their inputs hash; identical inputs yield identical ranks. | |
| 19 | + </p> | |
| 20 | + </Section> | |
| 21 | + <Section id="labels" kicker="§3" title="Scientific safety labels"> | |
| 22 | + <p className="flex flex-wrap items-center gap-1.5"> | |
| 23 | + <ClaimBadge kind="observed" /> <ClaimBadge kind="published" /> <ClaimBadge kind="curated" /> <ClaimBadge kind="regulatory" /> <ClaimBadge kind="guideline" /> <ClaimBadge kind="computed" /> <ClaimBadge kind="ai" /> | |
| 24 | + </p> | |
| 25 | + <p className="mt-2">Labels are never merged. Regulatory status is always tied to a jurisdiction and an indication. Population survival is never presented as an individual prognosis.</p> | |
| 26 | + </Section> | |
| 27 | + <Section id="ai" kicker="AI policy" title="AI-generated content: not enabled"> | |
| 28 | + <p>Phase 1 contains no AI-generated text. When AI synthesis is introduced, it will be database-grounded only (answers must cite records present in the index), cached with the model name, prompt version and record snapshot, and always labelled "AI-generated". Language models are never used to map source labels to entities; at most they propose candidates that a curator reviews.</p> | |
| 29 | + </Section> | |
| 30 | + <Section id="privacy" kicker="Privacy" title="Privacy"> | |
| 31 | + <p>CancerIndex holds no patient-level data: all statistics are aggregate and published by their sources. The site sets no tracking cookies; the only cookie is the administrator session. Server logs retain IP addresses for a short period for abuse prevention.</p> | |
| 32 | + </Section> | |
| 33 | + <Section id="security" kicker="Security" title="Security"> | |
| 34 | + <p>Read-only public access; administrative actions require a token and are written to an audit log. Connectors run with rate limits, idempotent writes and an anomaly guard that refuses destructive updates when a source shrinks unexpectedly. Report vulnerabilities to {CONTACT_EMAIL}.</p> | |
| 35 | + </Section> | |
| 36 | + <Section id="sources" kicker="Source policy" title="Source policy"> | |
| 37 | + <p> | |
| 38 | + A connector goes live only after its license is reviewed; sources under review are listed with no public records. Attribution requirements are honoured on every page and in exports. See the <Link className="ci-link" href="/sources">source registry</Link>. | |
| 39 | + </p> | |
| 40 | + </Section> | |
| 41 | + <Section id="corrections" kicker="Corrections" title="Corrections and takedowns"> | |
| 42 | + <p> | |
| 43 | + Found an error, a mis-mapped label or a licensing concern? Write to <a className="ci-link" href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a> with the page URL and the identifier (CI-…). Corrections are recorded as change events on the affected entity. | |
| 44 | + </p> | |
| 45 | + </Section> | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | +} | |
added
apps/web/src/app/variant/[slug]/page.tsx
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 10 | +import { JsonView } from '@/components/ui/json-view'; | |
| 11 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 12 | +import { getVariantBySlug, variantAliases, clinicalSignificanceFor } from '@/lib/queries/genomics'; | |
| 13 | +import { evidenceForVariant, type EvidenceItem } from '@/lib/queries/evidence'; | |
| 14 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 15 | +import { fmtInt, humanize } from '@/lib/format'; | |
| 16 | + | |
| 17 | +export const revalidate = 3600; | |
| 18 | + | |
| 19 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 20 | + const v = await getVariantBySlug((await params).slug); | |
| 21 | + return v ? { title: `${v.gene_symbol ? `${v.gene_symbol} ` : ''}${v.name} — variant`, description: `Curated evidence by cancer and ClinVar interpretations for ${v.gene_symbol ?? ''} ${v.name}.` } : { title: 'Variant' }; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export default async function VariantPage({ params }: { params: Promise<{ slug: string }> }) { | |
| 25 | + const { slug } = await params; | |
| 26 | + const v = await getVariantBySlug(slug); | |
| 27 | + if (!v) notFound(); | |
| 28 | + const [aliases, evidence, clinsig] = await Promise.all([variantAliases(v.id), evidenceForVariant(v.id), clinicalSignificanceFor(v.id)]); | |
| 29 | + const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...clinsig.map((c) => c.provenance_id)]); | |
| 30 | + // Evidence grouped by cancer (§50) | |
| 31 | + const byCancer = new Map<string, { slug: string | null; name: string; items: EvidenceItem[] }>(); | |
| 32 | + for (const e of evidence) { | |
| 33 | + const k = e.cancer_id ?? `unmapped:${e.disease_name ?? 'unknown'}`; | |
| 34 | + if (!byCancer.has(k)) byCancer.set(k, { slug: e.cancer_slug, name: e.cancer_name ?? e.disease_name ?? 'Unmapped disease', items: [] }); | |
| 35 | + byCancer.get(k)!.items.push(e); | |
| 36 | + } | |
| 37 | + const cancerGroups = [...byCancer.values()].sort((a, b) => b.items.length - a.items.length); | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <article> | |
| 41 | + <PageHeader kicker={`Variant${v.variant_type ? ` · ${humanize(v.variant_type)}` : ''}`} title={<>{v.gene_symbol ? <Link href={`/gene/${v.gene_symbol}`} className="ci-mono font-sans text-ink no-underline hover:text-accent">{v.gene_symbol}</Link> : null} {v.name}</>}> | |
| 42 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 43 | + <span className="ci-mono text-ink-3">{v.id}</span> | |
| 44 | + {v.hgvs_p ? <span className="ci-mono">{v.hgvs_p}</span> : null} | |
| 45 | + {v.hgvs_c ? <span className="ci-mono">{v.hgvs_c}</span> : null} | |
| 46 | + {v.clinvar_variation_id ? ( | |
| 47 | + <a className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/clinvar/variation/${v.clinvar_variation_id}/`} target="_blank" rel="noopener noreferrer"> | |
| 48 | + ClinVar {v.clinvar_variation_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 49 | + </a> | |
| 50 | + ) : null} | |
| 51 | + {v.civic_variant_id ? ( | |
| 52 | + <a className="ci-link inline-flex items-center gap-1" href={`https://civicdb.org/variants/${v.civic_variant_id}/summary`} target="_blank" rel="noopener noreferrer"> | |
| 53 | + CIViC {v.civic_variant_id} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 54 | + </a> | |
| 55 | + ) : null} | |
| 56 | + {v.dbsnp_ids.map((r) => ( | |
| 57 | + <a key={r} className="ci-link inline-flex items-center gap-1" href={`https://www.ncbi.nlm.nih.gov/snp/${r}`} target="_blank" rel="noopener noreferrer"> | |
| 58 | + {r} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 59 | + </a> | |
| 60 | + ))} | |
| 61 | + </p> | |
| 62 | + </PageHeader> | |
| 63 | + | |
| 64 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 65 | + <div className="space-y-8"> | |
| 66 | + <Section id="evidence" kicker="Curated evidence" title={`Evidence by cancer (${fmtInt(evidence.length)} items)`} description="Grouped by cancer context first, then therapy. The same variant can be sensitizing in one cancer and irrelevant in another — contexts are never merged."> | |
| 67 | + {cancerGroups.length ? ( | |
| 68 | + <div className="space-y-8"> | |
| 69 | + {cancerGroups.map((g) => ( | |
| 70 | + <div key={g.slug ?? g.name}> | |
| 71 | + <h3 className="mb-2 text-lg"> | |
| 72 | + {g.slug ? ( | |
| 73 | + <Link href={`/cancer/${g.slug}/evidence`} className="ci-link"> | |
| 74 | + {g.name} | |
| 75 | + </Link> | |
| 76 | + ) : ( | |
| 77 | + <span className="text-ink-2">{g.name}</span> | |
| 78 | + )}{' '} | |
| 79 | + <span className="ci-num text-[13px] text-ink-3">{g.items.length}</span> | |
| 80 | + {!g.slug ? <Badge tone="danger" className="ml-2">unmapped disease</Badge> : null} | |
| 81 | + </h3> | |
| 82 | + <EvidenceTable items={g.items} prov={prov} showVariant={false} /> | |
| 83 | + </div> | |
| 84 | + ))} | |
| 85 | + </div> | |
| 86 | + ) : ( | |
| 87 | + <EmptyState compact>No curated evidence item references this variant yet.</EmptyState> | |
| 88 | + )} | |
| 89 | + </Section> | |
| 90 | + | |
| 91 | + <Section id="clinvar" kicker="ClinVar" title={`Clinical significance (${fmtInt(clinsig.length)})`} description="ClinVar interpretations are shown as structured records — significance, review status, star rating, conditions — never flattened into one word."> | |
| 92 | + {clinsig.length ? ( | |
| 93 | + <div className="ci-table-wrap"> | |
| 94 | + <table className="ci-table"> | |
| 95 | + <thead> | |
| 96 | + <tr> | |
| 97 | + <th>Variation</th> | |
| 98 | + <th>Clinical significance</th> | |
| 99 | + <th>Review status</th> | |
| 100 | + <th className="num">Stars</th> | |
| 101 | + <th>Conditions</th> | |
| 102 | + <th>Origin</th> | |
| 103 | + <th className="num">Submitters</th> | |
| 104 | + <th>Last evaluated</th> | |
| 105 | + <th>Source</th> | |
| 106 | + </tr> | |
| 107 | + </thead> | |
| 108 | + <tbody> | |
| 109 | + {clinsig.map((c) => ( | |
| 110 | + <tr key={c.id}> | |
| 111 | + <td className="ci-mono">{c.clinvar_variation_id}</td> | |
| 112 | + <td className="font-medium">{c.clinical_significance}</td> | |
| 113 | + <td className="text-[12.5px]">{c.review_status ?? '—'}</td> | |
| 114 | + <td className="num">{c.star_rating ?? '—'}</td> | |
| 115 | + <td className="max-w-[300px] text-[12.5px]">{c.conditions.join('; ') || '—'}</td> | |
| 116 | + <td className="text-[12.5px]">{c.origin_simple ?? '—'}</td> | |
| 117 | + <td className="num">{c.number_submitters ?? '—'}</td> | |
| 118 | + <td className="whitespace-nowrap text-[12.5px]">{c.last_evaluated ?? '—'}</td> | |
| 119 | + <td> | |
| 120 | + <span className="inline-flex gap-1"> | |
| 121 | + <SourceBadge p={toInfo(prov.get(c.provenance_id)) ?? { sourceSlug: 'clinvar', sourceName: 'ClinVar' }} /> | |
| 122 | + <ClaimBadge kind="curated" /> | |
| 123 | + </span> | |
| 124 | + </td> | |
| 125 | + </tr> | |
| 126 | + ))} | |
| 127 | + </tbody> | |
| 128 | + </table> | |
| 129 | + </div> | |
| 130 | + ) : ( | |
| 131 | + <EmptyState compact>No ClinVar interpretation attached to this variant.</EmptyState> | |
| 132 | + )} | |
| 133 | + </Section> | |
| 134 | + </div> | |
| 135 | + | |
| 136 | + <aside className="space-y-8"> | |
| 137 | + <Section id="coordinates" kicker="Nomenclature" title="Record" level={3}> | |
| 138 | + <KV | |
| 139 | + items={[ | |
| 140 | + { k: 'Gene', v: v.gene_symbol ? <Link className="ci-mono ci-link" href={`/gene/${v.gene_symbol}`}>{v.gene_symbol}</Link> : null }, | |
| 141 | + { k: 'Type', v: v.variant_type ? humanize(v.variant_type) : null }, | |
| 142 | + { k: 'HGVS g.', v: v.hgvs_g ? <span className="ci-mono break-all">{v.hgvs_g}</span> : null }, | |
| 143 | + { k: 'HGVS c.', v: v.hgvs_c ? <span className="ci-mono break-all">{v.hgvs_c}</span> : null }, | |
| 144 | + { k: 'HGVS p.', v: v.hgvs_p ? <span className="ci-mono break-all">{v.hgvs_p}</span> : null }, | |
| 145 | + { k: 'Assembly', v: v.assembly ? <Badge mono>{v.assembly}</Badge> : null }, | |
| 146 | + { k: 'Position', v: v.chromosome && v.start ? <span className="ci-mono">chr{v.chromosome}:{v.start}{v.end && v.end !== v.start ? `-${v.end}` : ''}</span> : null }, | |
| 147 | + { k: 'Ref / Alt', v: v.reference_bases || v.alternate_bases ? <span className="ci-mono">{v.reference_bases ?? '?'} → {v.alternate_bases ?? '?'}</span> : null }, | |
| 148 | + { k: 'Fusion partners', v: v.fusion_partners.length ? v.fusion_partners.join(' :: ') : null }, | |
| 149 | + { k: 'Aliases', v: aliases.length ? aliases.join(', ') : null }, | |
| 150 | + ]} | |
| 151 | + /> | |
| 152 | + {v.coordinates.length ? ( | |
| 153 | + <details className="mt-2 text-[12.5px]"> | |
| 154 | + <summary className="ci-link">Per-assembly coordinates</summary> | |
| 155 | + <JsonView data={v.coordinates} /> | |
| 156 | + </details> | |
| 157 | + ) : null} | |
| 158 | + <Freshness dataUpdatedAt={v.updated_at} /> | |
| 159 | + </Section> | |
| 160 | + <Note tone="warn">Coordinates always carry their assembly; do not compare positions across GRCh37 and GRCh38. Evidence is not treatment guidance.</Note> | |
| 161 | + </aside> | |
| 162 | + </div> | |
| 163 | + </article> | |
| 164 | + ); | |
| 165 | +} | |
added
apps/web/src/components/cancer/header.tsx
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { ExternalLink } from 'lucide-react'; | |
| 3 | +import { Badge } from '@/components/ui/badge'; | |
| 4 | +import { Breadcrumbs } from '@/components/layout/breadcrumbs'; | |
| 5 | +import { Tabs } from '@/components/ui/tabs'; | |
| 6 | +import { CODE_SYSTEM_LABEL, codeUrl } from '@/lib/site'; | |
| 7 | +import { humanize, fmtInt } from '@/lib/format'; | |
| 8 | +import { TABS, type CancerBundle, type TabKey } from './load'; | |
| 9 | + | |
| 10 | +const PRIMARY_SYSTEMS = ['ncit', 'oncotree', 'icd10', 'icd10cm', 'doid', 'umls', 'mesh', 'mondo']; | |
| 11 | + | |
| 12 | +export function CancerHeader({ b, tab }: { b: CancerBundle; tab: TabKey }) { | |
| 13 | + const { cancer: c, counters, codes, aliases, path } = b; | |
| 14 | + const abbreviations = aliases.filter((a) => a.alias_type === 'abbreviation').map((a) => a.alias); | |
| 15 | + const shown = codes.filter((k) => PRIMARY_SYSTEMS.includes(k.system)).sort((a, b2) => PRIMARY_SYSTEMS.indexOf(a.system) - PRIMARY_SYSTEMS.indexOf(b2.system)); | |
| 16 | + const otherCodes = codes.length - shown.length; | |
| 17 | + | |
| 18 | + const badges: Array<{ label: string; tone?: 'neutral' | 'accent' | 'warn' | 'outline'; title: string }> = []; | |
| 19 | + if (c.rare_cancer === true) badges.push({ label: 'Rare', tone: 'accent', title: 'Flagged rare from incidence data (rule: age-standardized incidence < 6 per 100,000)' }); | |
| 20 | + if (c.pediatric_relevant) badges.push({ label: 'Pediatric', title: 'Pediatric-relevant entity (NCIt childhood neoplasm branch)' }); | |
| 21 | + if (c.hematologic) badges.push({ label: 'Hematologic', title: 'Hematologic malignancy' }); | |
| 22 | + if (!c.malignant) badges.push({ label: c.entity_type === 'precursor_condition' ? 'Precursor' : 'Non-malignant', tone: 'outline', title: 'Not a malignant neoplasm; kept for taxonomy completeness' }); | |
| 23 | + if (c.top_level) badges.push({ label: 'Top-level', tone: 'outline', title: 'Member of the mutually exclusive global ranking set' }); | |
| 24 | + if (counters) { | |
| 25 | + if (counters.active_trial_count > 0) badges.push({ label: `${fmtInt(counters.active_trial_count)} active trials`, tone: 'outline', title: 'From ClinicalTrials.gov, over descendants' }); | |
| 26 | + if (counters.approved_drug_count > 0) badges.push({ label: `${fmtInt(counters.approved_drug_count)} approved drugs`, tone: 'outline', title: 'Drugs with at least one approval in any jurisdiction' }); | |
| 27 | + if (counters.evidence_count > 0) badges.push({ label: `${fmtInt(counters.evidence_count)} evidence items`, tone: 'outline', title: 'Accepted CIViC evidence items' }); | |
| 28 | + } | |
| 29 | + | |
| 30 | + const crumbs = [{ label: 'Cancers', href: '/cancers' }, ...path.slice(0, -1).map((p) => ({ label: p.canonical_name, href: `/cancer/${p.slug}` })), { label: c.canonical_name }]; | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <header className="pt-5"> | |
| 34 | + <Breadcrumbs items={crumbs} /> | |
| 35 | + {b.redirectedFrom ? ( | |
| 36 | + <p className="mt-2 border-l-2 border-warn pl-3 text-[12.5px] text-warn"> | |
| 37 | + <span className="ci-mono">{b.redirectedFrom.id}</span> ({b.redirectedFrom.canonical_name}) was merged into this entity. | |
| 38 | + </p> | |
| 39 | + ) : null} | |
| 40 | + {c.status === 'deprecated' ? <p className="mt-2 border-l-2 border-danger pl-3 text-[12.5px] text-danger">Deprecated entity{c.deprecated_reason ? `: ${c.deprecated_reason}` : ''}. Kept for identifier stability.</p> : null} | |
| 41 | + <div className="mt-3 flex flex-wrap items-start justify-between gap-4"> | |
| 42 | + <div className="min-w-0"> | |
| 43 | + <p className="ci-kicker mb-1">{humanize(c.entity_type)}</p> | |
| 44 | + <h1 className="text-3xl leading-tight sm:text-[40px]">{c.canonical_name}</h1> | |
| 45 | + <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-ink-3"> | |
| 46 | + <span className="ci-mono text-ink-2">{c.id}</span> | |
| 47 | + {c.short_name && c.short_name !== c.canonical_name ? <span>{c.short_name}</span> : null} | |
| 48 | + {abbreviations.length ? <span>{abbreviations.slice(0, 6).join(' · ')}</span> : null} | |
| 49 | + </p> | |
| 50 | + {badges.length ? ( | |
| 51 | + <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 52 | + {badges.map((bd) => ( | |
| 53 | + <Badge key={bd.label} tone={bd.tone ?? 'neutral'} title={bd.title}> | |
| 54 | + {bd.label} | |
| 55 | + </Badge> | |
| 56 | + ))} | |
| 57 | + </div> | |
| 58 | + ) : null} | |
| 59 | + </div> | |
| 60 | + <dl className="grid shrink-0 grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> | |
| 61 | + {shown.map((k) => { | |
| 62 | + const url = codeUrl(k.system, k.code); | |
| 63 | + return ( | |
| 64 | + <div key={`${k.system}-${k.code}`} className="contents"> | |
| 65 | + <dt className="text-ink-3">{CODE_SYSTEM_LABEL[k.system] ?? k.system}</dt> | |
| 66 | + <dd> | |
| 67 | + {url ? ( | |
| 68 | + <a href={url} target="_blank" rel="noopener noreferrer" className="ci-mono inline-flex items-center gap-1 border border-rule px-1.5 py-[1px] text-ink no-underline hover:border-accent hover:text-accent" title={`Open ${k.code} in the ${CODE_SYSTEM_LABEL[k.system] ?? k.system} browser`}> | |
| 69 | + {k.code} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 70 | + </a> | |
| 71 | + ) : ( | |
| 72 | + <span className="ci-mono border border-rule px-1.5 py-[1px]">{k.code}</span> | |
| 73 | + )} | |
| 74 | + </dd> | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | + })} | |
| 78 | + {otherCodes > 0 ? ( | |
| 79 | + <div className="contents"> | |
| 80 | + <dt className="text-ink-3">Other</dt> | |
| 81 | + <dd> | |
| 82 | + <Link href={`/cancer/${c.slug}/sources`} className="ci-link"> | |
| 83 | + {otherCodes} more {otherCodes === 1 ? 'code' : 'codes'} | |
| 84 | + </Link> | |
| 85 | + </dd> | |
| 86 | + </div> | |
| 87 | + ) : null} | |
| 88 | + {shown.length === 0 && otherCodes === 0 ? <dd className="col-span-2 text-ink-4">No cross-reference codes yet</dd> : null} | |
| 89 | + </dl> | |
| 90 | + </div> | |
| 91 | + <div className="mt-4"> | |
| 92 | + <Tabs | |
| 93 | + ariaLabel="Entity sections" | |
| 94 | + current={tab} | |
| 95 | + tabs={TABS.map((t) => ({ | |
| 96 | + key: t.key, | |
| 97 | + label: t.label, | |
| 98 | + href: t.key === 'overview' ? `/cancer/${c.slug}` : `/cancer/${c.slug}/${t.key}`, | |
| 99 | + count: | |
| 100 | + t.key === 'trials' | |
| 101 | + ? counters?.trial_count | |
| 102 | + : t.key === 'evidence' | |
| 103 | + ? counters?.evidence_count | |
| 104 | + : t.key === 'drugs' | |
| 105 | + ? counters?.drug_count | |
| 106 | + : t.key === 'statistics' | |
| 107 | + ? counters?.epidemiology_obs_count | |
| 108 | + : t.key === 'survival' | |
| 109 | + ? counters?.survival_obs_count | |
| 110 | + : t.key === 'genomics' | |
| 111 | + ? counters?.cohort_count | |
| 112 | + : null, | |
| 113 | + }))} | |
| 114 | + /> | |
| 115 | + </div> | |
| 116 | + </header> | |
| 117 | + ); | |
| 118 | +} | |
added
apps/web/src/components/cancer/load.ts
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { cache } from 'react'; | |
| 3 | +import { getCancerBySlug, getCounters, getCodes, getAliases, getParents, getChildren, getBreadcrumbPath, getDescendantIds, getAnatomy, getCancerById, type CancerCore } from '@/lib/queries/cancers'; | |
| 4 | + | |
| 5 | +/** Everything the header and every tab need, loaded once per request (React cache). */ | |
| 6 | +export const loadCancer = cache(async (slug: string) => { | |
| 7 | + let cancer = await getCancerBySlug(slug); | |
| 8 | + let redirectedFrom: CancerCore | null = null; | |
| 9 | + if (cancer && cancer.status === 'merged' && cancer.merged_into) { | |
| 10 | + const target = await getCancerById(cancer.merged_into); | |
| 11 | + if (target) { | |
| 12 | + redirectedFrom = cancer; | |
| 13 | + cancer = target; | |
| 14 | + } | |
| 15 | + } | |
| 16 | + if (!cancer) return null; | |
| 17 | + const [counters, codes, aliases, parents, children, path, descendants, anatomy] = await Promise.all([ | |
| 18 | + getCounters(cancer.id), | |
| 19 | + getCodes(cancer.id), | |
| 20 | + getAliases(cancer.id), | |
| 21 | + getParents(cancer.id), | |
| 22 | + getChildren(cancer.id), | |
| 23 | + getBreadcrumbPath(cancer.id), | |
| 24 | + getDescendantIds(cancer.id), | |
| 25 | + getAnatomy(cancer.id), | |
| 26 | + ]); | |
| 27 | + return { cancer, redirectedFrom, counters, codes, aliases, parents, children, path, descendants, anatomy }; | |
| 28 | +}); | |
| 29 | + | |
| 30 | +export type CancerBundle = NonNullable<Awaited<ReturnType<typeof loadCancer>>>; | |
| 31 | + | |
| 32 | +export const TABS = [ | |
| 33 | + { key: 'overview', label: 'Overview' }, | |
| 34 | + { key: 'statistics', label: 'Statistics' }, | |
| 35 | + { key: 'survival', label: 'Survival' }, | |
| 36 | + { key: 'genomics', label: 'Genomics' }, | |
| 37 | + { key: 'evidence', label: 'Variants & evidence' }, | |
| 38 | + { key: 'drugs', label: 'Drugs' }, | |
| 39 | + { key: 'trials', label: 'Trials' }, | |
| 40 | + { key: 'research', label: 'Research' }, | |
| 41 | + { key: 'rankings', label: 'Rankings' }, | |
| 42 | + { key: 'sources', label: 'Sources & provenance' }, | |
| 43 | +] as const; | |
| 44 | +export type TabKey = (typeof TABS)[number]['key']; | |
added
apps/web/src/components/cancer/tabs/drugs.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, Note } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import { Badge } from '@/components/ui/badge'; | |
| 6 | +import { ApprovalsTable } from '@/components/data/approvals-table'; | |
| 7 | +import { approvalsForCancer } from '@/lib/queries/drugs'; | |
| 8 | +import { evidenceForCancer } from '@/lib/queries/evidence'; | |
| 9 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 10 | +import { fmtInt } from '@/lib/format'; | |
| 11 | +import type { CancerBundle } from '../load'; | |
| 12 | + | |
| 13 | +export async function DrugsTab({ b, jurisdiction }: { b: CancerBundle; jurisdiction: string | null }) { | |
| 14 | + const [approvals, evidence] = await Promise.all([approvalsForCancer(b.descendants), evidenceForCancer(b.descendants)]); | |
| 15 | + const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); | |
| 16 | + const selected = jurisdiction && jurisdictions.includes(jurisdiction) ? jurisdiction : null; | |
| 17 | + const shownApprovals = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals; | |
| 18 | + | |
| 19 | + // Therapies mentioned in curated evidence (not approvals) — listed separately, never called "approved". | |
| 20 | + const therapyMap = new Map<string, { slug: string; name: string; n: number; sensitivity: number; resistance: number }>(); | |
| 21 | + for (const e of evidence) { | |
| 22 | + e.therapy_ids.forEach((id, i) => { | |
| 23 | + const slug = e.therapy_slugs?.[i]; | |
| 24 | + if (!slug) return; | |
| 25 | + const cur = therapyMap.get(id) ?? { slug, name: e.therapy_names[i] ?? slug, n: 0, sensitivity: 0, resistance: 0 }; | |
| 26 | + cur.n += 1; | |
| 27 | + if ((e.significance ?? '').includes('SENSITIV')) cur.sensitivity += 1; | |
| 28 | + if ((e.significance ?? '').includes('RESIST')) cur.resistance += 1; | |
| 29 | + therapyMap.set(id, cur); | |
| 30 | + }); | |
| 31 | + } | |
| 32 | + const therapies = [...therapyMap.values()].sort((a, c) => c.n - a.n); | |
| 33 | + | |
| 34 | + if (approvals.length === 0 && therapies.length === 0) { | |
| 35 | + return ( | |
| 36 | + <Section id="drugs" kicker="Drugs" title="Regulatory approvals and therapies in evidence"> | |
| 37 | + <EmptyState knows={[{ label: 'Variants & evidence', href: `/cancer/${b.cancer.slug}/evidence` }, { label: 'Trials', href: `/cancer/${b.cancer.slug}/trials` }, { label: 'Drugs index', href: '/drugs' }]}> | |
| 38 | + No regulatory approval or evidence-linked therapy is recorded for this entity or its descendants. Approvals are always shown with jurisdiction, authority and indication text — a drug is never marked simply "approved". | |
| 39 | + </EmptyState> | |
| 40 | + </Section> | |
| 41 | + ); | |
| 42 | + } | |
| 43 | + const prov = await loadProvenance(approvals.map((a) => a.provenance_id)); | |
| 44 | + return ( | |
| 45 | + <div className="space-y-8"> | |
| 46 | + <Section id="approvals" kicker="Regulatory" title="Approvals by jurisdiction" description={approvals.length ? `${fmtInt(approvals.length)} approval records across ${jurisdictions.length} jurisdiction${jurisdictions.length === 1 ? '' : 's'}, including tumor-agnostic approvals.` : undefined}> | |
| 47 | + {approvals.length ? ( | |
| 48 | + <> | |
| 49 | + <nav aria-label="Jurisdiction" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]"> | |
| 50 | + <Link href={`/cancer/${b.cancer.slug}/drugs`} aria-current={!selected ? 'page' : undefined} 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'}`}> | |
| 51 | + All jurisdictions | |
| 52 | + </Link> | |
| 53 | + {jurisdictions.map((j) => ( | |
| 54 | + <Link key={j} href={`/cancer/${b.cancer.slug}/drugs?jurisdiction=${j}`} aria-current={selected === j ? 'page' : undefined} 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'}`}> | |
| 55 | + {j} | |
| 56 | + </Link> | |
| 57 | + ))} | |
| 58 | + </nav> | |
| 59 | + <ApprovalsTable rows={shownApprovals} prov={prov} /> | |
| 60 | + <Freshness dataUpdatedAt={approvals.reduce((m, a) => (a.updated_at > m ? a.updated_at : m), approvals[0]!.updated_at)} /> | |
| 61 | + </> | |
| 62 | + ) : ( | |
| 63 | + <EmptyState compact>No regulatory approval recorded for this entity yet.</EmptyState> | |
| 64 | + )} | |
| 65 | + </Section> | |
| 66 | + | |
| 67 | + <Section id="therapies-in-evidence" kicker="Curated evidence" title="Therapies appearing in curated evidence" description="Counts of CIViC evidence items mentioning each therapy for this entity or its descendants. Presence here is not an approval."> | |
| 68 | + {therapies.length ? ( | |
| 69 | + <div className="ci-table-wrap"> | |
| 70 | + <table className="ci-table"> | |
| 71 | + <thead> | |
| 72 | + <tr> | |
| 73 | + <th>Therapy</th> | |
| 74 | + <th className="num">Evidence items (count)</th> | |
| 75 | + <th className="num">Sensitivity / response</th> | |
| 76 | + <th className="num">Resistance</th> | |
| 77 | + </tr> | |
| 78 | + </thead> | |
| 79 | + <tbody> | |
| 80 | + {therapies.map((t) => ( | |
| 81 | + <tr key={t.slug}> | |
| 82 | + <td> | |
| 83 | + <Link className="ci-link" href={`/drug/${t.slug}`}> | |
| 84 | + {t.name} | |
| 85 | + </Link> | |
| 86 | + </td> | |
| 87 | + <td className="num">{fmtInt(t.n)}</td> | |
| 88 | + <td className="num">{fmtInt(t.sensitivity)}</td> | |
| 89 | + <td className="num">{fmtInt(t.resistance)}</td> | |
| 90 | + </tr> | |
| 91 | + ))} | |
| 92 | + </tbody> | |
| 93 | + </table> | |
| 94 | + </div> | |
| 95 | + ) : ( | |
| 96 | + <EmptyState compact>No therapy appears in curated evidence for this entity.</EmptyState> | |
| 97 | + )} | |
| 98 | + <p className="mt-2 text-[12px] text-ink-3"> | |
| 99 | + <Badge>Curated</Badge> Items are counted regardless of level or direction; see the evidence tab for the detail. | |
| 100 | + </p> | |
| 101 | + </Section> | |
| 102 | + <Note tone="warn">Regulatory status is jurisdiction-specific and changes over time. This page is not treatment guidance.</Note> | |
| 103 | + </div> | |
| 104 | + ); | |
| 105 | +} | |
added
apps/web/src/components/cancer/tabs/evidence.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { Section, Note } from '@/components/ui/section'; | |
| 2 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 3 | +import { Freshness } from '@/components/ui/freshness'; | |
| 4 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 5 | +import { evidenceForCancer } from '@/lib/queries/evidence'; | |
| 6 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 7 | +import { fmtInt } from '@/lib/format'; | |
| 8 | +import type { CancerBundle } from '../load'; | |
| 9 | + | |
| 10 | +export async function EvidenceTab({ b }: { b: CancerBundle }) { | |
| 11 | + const items = await evidenceForCancer(b.descendants); | |
| 12 | + if (items.length === 0) { | |
| 13 | + return ( | |
| 14 | + <Section id="evidence" kicker="Variants & evidence" title="Curated clinical evidence by variant and therapy"> | |
| 15 | + <EmptyState knows={[{ label: 'Genomics tab', href: `/cancer/${b.cancer.slug}/genomics` }, { label: 'Drugs tab', href: `/cancer/${b.cancer.slug}/drugs` }]}> | |
| 16 | + No curated evidence item maps to this entity or its descendants. Evidence comes from CIViC and is displayed with its native level (A–E), direction (supports / does not support) and significance — never collapsed to a verdict. | |
| 17 | + </EmptyState> | |
| 18 | + </Section> | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + const prov = await loadProvenance(items.map((e) => e.provenance_id)); | |
| 22 | + const accepted = items.filter((e) => e.status === 'ACCEPTED').length; | |
| 23 | + const descendantItems = items.filter((e) => e.cancer_id !== b.cancer.id).length; | |
| 24 | + const latest = items.reduce((m, e) => (e.updated_at > m ? e.updated_at : m), items[0]!.updated_at); | |
| 25 | + return ( | |
| 26 | + <div className="space-y-6"> | |
| 27 | + <Section id="evidence" kicker="Variants & evidence" title="Curated clinical evidence by variant and therapy" description={`${fmtInt(items.length)} evidence items (${fmtInt(accepted)} accepted${descendantItems ? `, ${fmtInt(descendantItems)} mapped to a descendant entity` : ''}). Grouped by molecular profile, then therapy.`}> | |
| 28 | + <EvidenceTable items={items} prov={prov} showCancer={descendantItems > 0} /> | |
| 29 | + <Freshness dataUpdatedAt={latest} extra="source: civic (CC0)" /> | |
| 30 | + </Section> | |
| 31 | + <Note tone="warn">Evidence levels, directions and ratings are those assigned by CIViC curators. "Submitted" items have not completed curation review. This is not treatment guidance.</Note> | |
| 32 | + </div> | |
| 33 | + ); | |
| 34 | +} | |
added
apps/web/src/components/cancer/tabs/genomics.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, Note } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { FrequencyTables } from '@/components/data/frequency-table'; | |
| 5 | +import { frequenciesForCancer } from '@/lib/queries/genomics'; | |
| 6 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 7 | +import { fmtInt } from '@/lib/format'; | |
| 8 | +import type { CancerBundle } from '../load'; | |
| 9 | + | |
| 10 | +export async function GenomicsTab({ b, cohort }: { b: CancerBundle; cohort: string | null }) { | |
| 11 | + const rows = await frequenciesForCancer(b.descendants); | |
| 12 | + if (rows.length === 0) { | |
| 13 | + return ( | |
| 14 | + <Section id="genomics" kicker="Genomics" title="Gene alteration frequencies by cohort"> | |
| 15 | + <EmptyState knows={[{ label: 'Variants & evidence', href: `/cancer/${b.cancer.slug}/evidence` }, { label: 'Genes index', href: '/genes' }]}> | |
| 16 | + No genomic cohort is mapped to this entity or its descendants. Frequencies come from open-access cohorts (GDC projects) and always carry their denominator (cases profiled); they are never pooled across cohorts. | |
| 17 | + </EmptyState> | |
| 18 | + </Section> | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + const prov = await loadProvenance(rows.map((r) => r.provenance_id)); | |
| 22 | + const cohorts = [...new Map(rows.map((r) => [r.cohort_id, r])).values()]; | |
| 23 | + const selected = cohort && cohorts.some((c) => c.cohort_id === cohort) ? cohort : null; | |
| 24 | + return ( | |
| 25 | + <div className="space-y-6"> | |
| 26 | + <Section id="genomics" kicker="Genomics" title="Gene alteration frequencies by cohort" description={`${fmtInt(cohorts.length)} cohort${cohorts.length === 1 ? '' : 's'} mapped to this entity or its descendants. Each frequency is affected / profiled within one cohort; cohorts are shown separately because case selection, sequencing depth and definitions differ.`}> | |
| 27 | + <nav aria-label="Cohort" className="mb-4 flex flex-wrap gap-1.5 text-[12.5px]"> | |
| 28 | + <Link href={`/cancer/${b.cancer.slug}/genomics`} aria-current={!selected ? 'page' : undefined} 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'}`}> | |
| 29 | + All cohorts | |
| 30 | + </Link> | |
| 31 | + {cohorts.map((c) => ( | |
| 32 | + <Link key={c.cohort_id} href={`/cancer/${b.cancer.slug}/genomics?cohort=${encodeURIComponent(c.cohort_id)}`} aria-current={selected === c.cohort_id ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${selected === c.cohort_id ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 33 | + {c.study_id} | |
| 34 | + {c.cancer_name && c.cancer_id !== b.cancer.id ? <span className="ml-1 text-ink-3">({c.cancer_name})</span> : null} | |
| 35 | + </Link> | |
| 36 | + ))} | |
| 37 | + </nav> | |
| 38 | + <FrequencyTables rows={rows} prov={prov} cohortFilter={selected} showCancer /> | |
| 39 | + </Section> | |
| 40 | + <Note>Cohorts mapped to a descendant (e.g. a subtype) are included because their cases belong to this entity by definition; the mapping and its match type are shown per cohort.</Note> | |
| 41 | + </div> | |
| 42 | + ); | |
| 43 | +} | |
added
apps/web/src/components/cancer/tabs/overview.tsx
+196 −0
@@ -0,0 +1,196 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, KV, Note } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 6 | +import { Freshness } from '@/components/ui/freshness'; | |
| 7 | +import { CompletenessDots } from '@/components/ui/completeness'; | |
| 8 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 9 | +import { fmtInt, humanize, isoDate } from '@/lib/format'; | |
| 10 | +import type { CancerBundle } from '../load'; | |
| 11 | + | |
| 12 | +export async function OverviewTab({ b }: { b: CancerBundle }) { | |
| 13 | + const { cancer: c, counters, parents, children, aliases, anatomy } = b; | |
| 14 | + const prov = await loadProvenance([c.description_provenance_id]); | |
| 15 | + const descProv = toInfo(prov.get(c.description_provenance_id ?? -1), 'raw'); | |
| 16 | + const byType = new Map<string, typeof parents>(); | |
| 17 | + for (const p of parents) byType.set(p.hierarchy_type, [...(byType.get(p.hierarchy_type) ?? []), p]); | |
| 18 | + const childrenByType = new Map<string, typeof children>(); | |
| 19 | + for (const ch of children) childrenByType.set(ch.hierarchy_type, [...(childrenByType.get(ch.hierarchy_type) ?? []), ch]); | |
| 20 | + const synonyms = aliases.filter((a) => a.alias_type !== 'preferred' && a.alias_type !== 'abbreviation'); | |
| 21 | + | |
| 22 | + const knows = [ | |
| 23 | + { label: `Taxonomy: ${parents.length} parent${parents.length === 1 ? '' : 's'}, ${children.length} ${children.length === 1 ? 'child' : 'children'}`, href: `/cancer/${c.slug}#taxonomy` }, | |
| 24 | + ...(anatomy.length ? [{ label: `Anatomy: ${anatomy.map((a) => a.name).join(', ')}`, href: `/cancer/${c.slug}#anatomy` }] : []), | |
| 25 | + ...(b.codes.length ? [{ label: `${b.codes.length} cross-reference codes`, href: `/cancer/${c.slug}/sources` }] : []), | |
| 26 | + ]; | |
| 27 | + | |
| 28 | + return ( | |
| 29 | + <div className="grid gap-8 lg:grid-cols-[1.5fr_1fr]"> | |
| 30 | + <div className="space-y-8"> | |
| 31 | + <Section id="definition" kicker="Definition" title="What this entity is"> | |
| 32 | + {c.description ? ( | |
| 33 | + <> | |
| 34 | + <p className="max-w-3xl text-[15px] leading-relaxed text-ink">{c.description}</p> | |
| 35 | + <p className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 36 | + Definition text as published by {descProv ? <SourceBadge p={descProv} /> : 'the source terminology'} <ClaimBadge kind="curated" /> | |
| 37 | + </p> | |
| 38 | + <Freshness dataUpdatedAt={prov.get(c.description_provenance_id ?? -1)?.retrieved_at ?? c.updated_at} sourceVersion={prov.get(c.description_provenance_id ?? -1)?.dataset_version ?? c.classification_version} /> | |
| 39 | + </> | |
| 40 | + ) : ( | |
| 41 | + <EmptyState title="Definition not yet available" knows={knows}> | |
| 42 | + The NCIt definition is attached when the NCIt connector ingests this concept{c.primary_ncit_code ? ` (${c.primary_ncit_code})` : ''}. CancerIndex does not write its own definitions. | |
| 43 | + </EmptyState> | |
| 44 | + )} | |
| 45 | + </Section> | |
| 46 | + | |
| 47 | + <Section id="taxonomy" kicker="Taxonomy" title="Position in the hierarchies" description="Several hierarchies coexist; edges are listed per hierarchy type with their source."> | |
| 48 | + {parents.length === 0 && children.length === 0 ? ( | |
| 49 | + <EmptyState compact>No hierarchy edge for this entity yet.</EmptyState> | |
| 50 | + ) : ( | |
| 51 | + <div className="grid gap-6 sm:grid-cols-2"> | |
| 52 | + <div> | |
| 53 | + <p className="ci-kicker mb-1.5">Parents</p> | |
| 54 | + {parents.length ? ( | |
| 55 | + [...byType.entries()].map(([type, ps]) => ( | |
| 56 | + <div key={type} className="mb-2"> | |
| 57 | + <Badge tone="outline" className="mb-1"> | |
| 58 | + {type} | |
| 59 | + </Badge> | |
| 60 | + <ul className="text-[13.5px]"> | |
| 61 | + {ps.map((p) => ( | |
| 62 | + <li key={`${type}-${p.id}`} className="py-0.5"> | |
| 63 | + <Link className={`ci-link ${p.malignant ? '' : 'text-ink-3'}`} href={`/cancer/${p.slug}`}> | |
| 64 | + {p.canonical_name} | |
| 65 | + </Link> | |
| 66 | + <span className="ml-1.5 text-[11.5px] text-ink-3">{humanize(p.entity_type)}</span> | |
| 67 | + </li> | |
| 68 | + ))} | |
| 69 | + </ul> | |
| 70 | + </div> | |
| 71 | + )) | |
| 72 | + ) : ( | |
| 73 | + <p className="text-[13px] text-ink-3">Root node — no parent in any ingested hierarchy.</p> | |
| 74 | + )} | |
| 75 | + </div> | |
| 76 | + <div> | |
| 77 | + <p className="ci-kicker mb-1.5">Children ({fmtInt(children.length)})</p> | |
| 78 | + {children.length ? ( | |
| 79 | + [...childrenByType.entries()].map(([type, cs]) => ( | |
| 80 | + <div key={type} className="mb-2"> | |
| 81 | + <Badge tone="outline" className="mb-1"> | |
| 82 | + {type} | |
| 83 | + </Badge> | |
| 84 | + <ul className="max-h-[420px] overflow-y-auto text-[13.5px]"> | |
| 85 | + {cs.map((ch) => ( | |
| 86 | + <li key={`${type}-${ch.id}`} className="flex items-baseline justify-between gap-2 py-0.5"> | |
| 87 | + <Link className={`ci-link ${ch.malignant ? '' : 'text-ink-3'}`} href={`/cancer/${ch.slug}`}> | |
| 88 | + {ch.canonical_name} | |
| 89 | + </Link> | |
| 90 | + {ch.child_count > 0 ? <span className="ci-num text-[11px] text-ink-3">{ch.child_count} sub</span> : null} | |
| 91 | + </li> | |
| 92 | + ))} | |
| 93 | + </ul> | |
| 94 | + </div> | |
| 95 | + )) | |
| 96 | + ) : ( | |
| 97 | + <p className="text-[13px] text-ink-3">Leaf node — no children.</p> | |
| 98 | + )} | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + )} | |
| 102 | + {counters && counters.descendant_count > children.length ? <p className="mt-2 text-[12px] text-ink-3">{fmtInt(counters.descendant_count)} descendants in total; counters on this page aggregate over all of them.</p> : null} | |
| 103 | + </Section> | |
| 104 | + | |
| 105 | + <Section id="anatomy" kicker="Anatomy" title="Anatomical sites"> | |
| 106 | + {anatomy.length ? ( | |
| 107 | + <ul className="flex flex-wrap gap-2 text-[13.5px]"> | |
| 108 | + {anatomy.map((a) => ( | |
| 109 | + <li key={`${a.site_id}-${a.relation}`}> | |
| 110 | + <Link href={`/cancers?site=${a.slug}`} className="inline-flex items-center gap-1.5 border border-rule px-2 py-1 no-underline hover:border-accent"> | |
| 111 | + <span className="text-ink">{a.name}</span> | |
| 112 | + {a.relation !== 'primary' ? <Badge tone="outline">{a.relation}</Badge> : null} | |
| 113 | + {a.ncit_code ? <span className="ci-mono text-[10.5px] text-ink-4">{a.ncit_code}</span> : null} | |
| 114 | + </Link> | |
| 115 | + </li> | |
| 116 | + ))} | |
| 117 | + </ul> | |
| 118 | + ) : ( | |
| 119 | + <EmptyState compact>No anatomical site linked yet.</EmptyState> | |
| 120 | + )} | |
| 121 | + </Section> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <aside className="space-y-8"> | |
| 125 | + <Section id="facts" kicker="Record" title="Identity" level={3}> | |
| 126 | + <KV | |
| 127 | + items={[ | |
| 128 | + { k: 'CancerIndex ID', v: <span className="ci-mono">{c.id}</span> }, | |
| 129 | + { k: 'Entity type', v: humanize(c.entity_type) }, | |
| 130 | + { k: 'Malignant', v: c.malignant ? 'Yes' : 'No' }, | |
| 131 | + { k: 'Solid / hematologic', v: c.hematologic ? 'Hematologic' : c.solid_tumor ? 'Solid tumor' : '—' }, | |
| 132 | + { k: 'Pediatric relevant', v: c.pediatric_relevant ? 'Yes' : 'Not flagged' }, | |
| 133 | + { k: 'Rare cancer', v: c.rare_cancer == null ? <span className="text-ink-3">Unknown (no incidence data yet)</span> : c.rare_cancer ? 'Yes' : 'No' }, | |
| 134 | + { k: 'Hierarchy depth', v: <span className="ci-num">{c.depth}</span> }, | |
| 135 | + { k: 'Classification version', v: c.classification_version ? <span className="ci-mono">{c.classification_version}</span> : null }, | |
| 136 | + { k: 'Semantic types', v: c.semantic_types.length ? c.semantic_types.join(', ') : null }, | |
| 137 | + { k: 'Status', v: humanize(c.status) }, | |
| 138 | + { k: 'Record updated', v: isoDate(c.updated_at) }, | |
| 139 | + ]} | |
| 140 | + /> | |
| 141 | + </Section> | |
| 142 | + | |
| 143 | + <Section id="completeness" kicker="Coverage" title="Data completeness" level={3}> | |
| 144 | + <CompletenessDots | |
| 145 | + filled={{ | |
| 146 | + taxonomy: true, | |
| 147 | + epidemiology: counters?.epidemiology_obs_count ?? 0, | |
| 148 | + survival: counters?.survival_obs_count ?? 0, | |
| 149 | + genomics: counters?.cohort_count ?? 0, | |
| 150 | + evidence: counters?.evidence_count ?? 0, | |
| 151 | + drugs: counters?.drug_count ?? 0, | |
| 152 | + trials: counters?.trial_count ?? 0, | |
| 153 | + literature: counters?.publication_count ?? 0, | |
| 154 | + }} | |
| 155 | + /> | |
| 156 | + {counters ? ( | |
| 157 | + <KV | |
| 158 | + className="mt-3" | |
| 159 | + items={[ | |
| 160 | + { k: 'Trials (all / active)', v: <span className="ci-num">{fmtInt(counters.trial_count)} / {fmtInt(counters.active_trial_count)}</span> }, | |
| 161 | + { k: 'Publications (all / 5y)', v: <span className="ci-num">{fmtInt(counters.publication_count)} / {fmtInt(counters.publication_count_5y)}</span> }, | |
| 162 | + { k: 'Evidence items', v: <span className="ci-num">{fmtInt(counters.evidence_count)}</span> }, | |
| 163 | + { k: 'Genes / variants', v: <span className="ci-num">{fmtInt(counters.gene_count)} / {fmtInt(counters.variant_count)}</span> }, | |
| 164 | + { k: 'Drugs (any / approved)', v: <span className="ci-num">{fmtInt(counters.drug_count)} / {fmtInt(counters.approved_drug_count)}</span> }, | |
| 165 | + { k: 'Genomic cohorts', v: <span className="ci-num">{fmtInt(counters.cohort_count)}</span> }, | |
| 166 | + { k: 'Epidemiology / survival obs.', v: <span className="ci-num">{fmtInt(counters.epidemiology_obs_count)} / {fmtInt(counters.survival_obs_count)}</span> }, | |
| 167 | + ]} | |
| 168 | + /> | |
| 169 | + ) : ( | |
| 170 | + <p className="mt-2 text-[12.5px] text-ink-3">Counters have not been computed for this entity yet (they are refreshed after each connector run). Only the taxonomy domain is populated.</p> | |
| 171 | + )} | |
| 172 | + {counters ? <Freshness dataUpdatedAt={counters.updated_at} extra="counters aggregate over descendants" /> : null} | |
| 173 | + </Section> | |
| 174 | + | |
| 175 | + <Section id="synonyms" kicker="Names" title={`Synonyms (${fmtInt(synonyms.length)})`} level={3}> | |
| 176 | + {synonyms.length ? ( | |
| 177 | + <ul className="max-h-[300px] overflow-y-auto text-[13px]"> | |
| 178 | + {synonyms.map((a, i) => ( | |
| 179 | + <li key={`${a.alias}-${i}`} className="flex items-baseline justify-between gap-2 border-b border-rule py-0.5"> | |
| 180 | + <span>{a.alias}</span> | |
| 181 | + <span className="text-[11px] text-ink-3"> | |
| 182 | + {a.alias_type} | |
| 183 | + {a.source_terminology ? ` · ${a.source_terminology}` : ''} | |
| 184 | + </span> | |
| 185 | + </li> | |
| 186 | + ))} | |
| 187 | + </ul> | |
| 188 | + ) : ( | |
| 189 | + <p className="text-[13px] text-ink-3">Only the preferred name is recorded so far.</p> | |
| 190 | + )} | |
| 191 | + </Section> | |
| 192 | + <Note>CancerIndex is a research and information platform. It does not diagnose and does not recommend treatment.</Note> | |
| 193 | + </aside> | |
| 194 | + </div> | |
| 195 | + ); | |
| 196 | +} | |
added
apps/web/src/components/cancer/tabs/rankings.tsx
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, Note } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; | |
| 5 | +import { JsonView } from '@/components/ui/json-view'; | |
| 6 | +import { rankingsForCancer } from '@/lib/queries/rankings'; | |
| 7 | +import { fmtDate, fmtInt, fmtValue, scopeLabel, unitLabel } from '@/lib/format'; | |
| 8 | +import type { CancerBundle } from '../load'; | |
| 9 | + | |
| 10 | +function Delta({ rank, prev }: { rank: number; prev: number | null }) { | |
| 11 | + if (prev == null) return <span className="text-ink-4" title="No previous snapshot for this scope">new</span>; | |
| 12 | + const d = prev - rank; | |
| 13 | + if (d === 0) return <span className="text-ink-3">=</span>; | |
| 14 | + return ( | |
| 15 | + <span className={d > 0 ? 'text-ok' : 'text-danger'} title={`Previous rank ${prev}`}> | |
| 16 | + {d > 0 ? '▲' : '▼'} {Math.abs(d)} | |
| 17 | + </span> | |
| 18 | + ); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function RankingsTab({ b }: { b: CancerBundle }) { | |
| 22 | + const rows = await rankingsForCancer(b.cancer.id); | |
| 23 | + if (rows.length === 0) { | |
| 24 | + return ( | |
| 25 | + <Section id="rankings" kicker="Rankings" title="Why this rank?"> | |
| 26 | + <EmptyState title="Not ranked yet" knows={[{ label: 'All rankings', href: '/rankings' }, { label: 'Methodology', href: '/methodology' }]}> | |
| 27 | + This entity appears in no current ranking snapshot. Rankings are computed per metric and scope (geography, sex, age, year, entity level) once the underlying counters or licensed observations exist; an entity with a zero counter is ranked only when the metric admits zeros. | |
| 28 | + </EmptyState> | |
| 29 | + </Section> | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + return ( | |
| 33 | + <div className="space-y-6"> | |
| 34 | + <Section id="rankings" kicker="Rankings" title="Why this rank?" description={`${fmtInt(rows.length)} current ranking rows. Each row lists the scope, the formula version, the value and the exact inputs that produced it.`}> | |
| 35 | + <div className="ci-table-wrap"> | |
| 36 | + <table className="ci-table"> | |
| 37 | + <thead> | |
| 38 | + <tr> | |
| 39 | + <th>Metric</th> | |
| 40 | + <th>Scope</th> | |
| 41 | + <th className="num">Rank / eligible</th> | |
| 42 | + <th className="num">Δ</th> | |
| 43 | + <th className="num">Value</th> | |
| 44 | + <th className="num">Percentile</th> | |
| 45 | + <th>Confidence</th> | |
| 46 | + <th>Formula</th> | |
| 47 | + <th>Generated</th> | |
| 48 | + <th>Inputs</th> | |
| 49 | + </tr> | |
| 50 | + </thead> | |
| 51 | + <tbody> | |
| 52 | + {rows.map((r) => ( | |
| 53 | + <tr key={r.id}> | |
| 54 | + <td> | |
| 55 | + <Link className="ci-link" href={`/rankings/${r.metric_slug}?scope=${encodeURIComponent(r.scope_key)}`}> | |
| 56 | + {r.metric_name} | |
| 57 | + </Link> | |
| 58 | + </td> | |
| 59 | + <td className="text-[12.5px] text-ink-2">{scopeLabel(r.scope_key)}</td> | |
| 60 | + <td className="num"> | |
| 61 | + <span className="font-medium">#{r.rank}</span> <span className="text-ink-3">/ {fmtInt(r.eligible_entities)}</span> | |
| 62 | + </td> | |
| 63 | + <td className="num"> | |
| 64 | + <Delta rank={r.rank} prev={r.previous_rank} /> | |
| 65 | + </td> | |
| 66 | + <td className="num"> | |
| 67 | + {fmtValue(r.value, r.unit)} <span className="text-[11px] text-ink-3">{unitLabel(r.unit)}</span> | |
| 68 | + </td> | |
| 69 | + <td className="num">{r.percentile}</td> | |
| 70 | + <td> | |
| 71 | + <ConfidenceBadge level={r.confidence} /> | |
| 72 | + </td> | |
| 73 | + <td> | |
| 74 | + <span className="ci-mono text-[11.5px]">{r.formula_version}</span> | |
| 75 | + </td> | |
| 76 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDate(r.generated_at)}</td> | |
| 77 | + <td> | |
| 78 | + <details> | |
| 79 | + <summary className="ci-link text-[12.5px]">show</summary> | |
| 80 | + <div className="mt-1 max-w-[420px]"> | |
| 81 | + <p className="ci-mono mb-1 text-[11px] text-ink-3">formula: {r.formula}</p> | |
| 82 | + <JsonView data={r.inputs} /> | |
| 83 | + <p className="ci-mono mt-1 text-[11px] text-ink-3">inputs hash {r.inputs_hash}</p> | |
| 84 | + <Link href={`/admin/trace?table=rankings&id=${r.id}`} className="ci-link text-[11.5px]"> | |
| 85 | + Trace lineage (admin) | |
| 86 | + </Link> | |
| 87 | + </div> | |
| 88 | + </details> | |
| 89 | + </td> | |
| 90 | + </tr> | |
| 91 | + ))} | |
| 92 | + </tbody> | |
| 93 | + </table> | |
| 94 | + </div> | |
| 95 | + <p className="mt-2 flex items-center gap-2 text-[12px] text-ink-3"> | |
| 96 | + <ClaimBadge kind="computed" /> Ranks are competition ranks (ties share a rank). Percentile = share of eligible entities ranked at or below this one in the ranking direction. | |
| 97 | + </p> | |
| 98 | + </Section> | |
| 99 | + <Note>"Deadliest" is ambiguous: annual deaths, age-standardized mortality rate, mortality-to-incidence ratio and 5-year survival rank cancers differently. CancerIndex publishes each as a separate metric.</Note> | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | +} | |
added
apps/web/src/components/cancer/tabs/research.tsx
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +import { Section } from '@/components/ui/section'; | |
| 2 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 3 | +import { Freshness } from '@/components/ui/freshness'; | |
| 4 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 6 | +import { PublicationList } from '@/components/data/publication-list'; | |
| 7 | +import { literatureCountsFor, recentPublicationsFor } from '@/lib/queries/publications'; | |
| 8 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 9 | +import { fmtInt, isoDate } from '@/lib/format'; | |
| 10 | +import type { CancerBundle } from '../load'; | |
| 11 | + | |
| 12 | +const WINDOW_LABEL: Record<string, string> = { all: 'All time', '10y': 'Last 10 years', '5y': 'Last 5 years', '5y_prior': 'Preceding 5-year window', '12m': 'Last 12 months' }; | |
| 13 | + | |
| 14 | +export async function ResearchTab({ b }: { b: CancerBundle }) { | |
| 15 | + const [counts, pubs] = await Promise.all([literatureCountsFor(b.cancer.id), recentPublicationsFor('cancer', b.descendants, 30)]); | |
| 16 | + const prov = await loadProvenance(counts.map((c) => c.provenance_id)); | |
| 17 | + return ( | |
| 18 | + <div className="space-y-8"> | |
| 19 | + <Section id="literature-counts" kicker="Research activity" title="PubMed record counts" description="Each count is the number of PubMed records returned by the exact query shown, at the time it was run. Counts are research-activity signals, not quality measures."> | |
| 20 | + {counts.length ? ( | |
| 21 | + <> | |
| 22 | + <div className="ci-table-wrap"> | |
| 23 | + <table className="ci-table"> | |
| 24 | + <thead> | |
| 25 | + <tr> | |
| 26 | + <th>Window</th> | |
| 27 | + <th>Period</th> | |
| 28 | + <th className="num">Records (count)</th> | |
| 29 | + <th>Exact query</th> | |
| 30 | + <th>Computed</th> | |
| 31 | + <th>Source</th> | |
| 32 | + </tr> | |
| 33 | + </thead> | |
| 34 | + <tbody> | |
| 35 | + {counts.map((c) => ( | |
| 36 | + <tr key={c.id}> | |
| 37 | + <td>{WINDOW_LABEL[c.window_key] ?? c.window_key}</td> | |
| 38 | + <td className="whitespace-nowrap text-[12.5px] text-ink-3">{c.window_start || c.window_end ? `${c.window_start ?? '…'} → ${c.window_end ?? '…'}` : '—'}</td> | |
| 39 | + <td className="num font-medium">{fmtInt(c.count)}</td> | |
| 40 | + <td> | |
| 41 | + <code className="ci-mono block max-w-[520px] whitespace-pre-wrap break-words text-[11.5px] text-ink-2">{c.query}</code> | |
| 42 | + </td> | |
| 43 | + <td className="whitespace-nowrap text-[12.5px]">{isoDate(c.updated_at)}</td> | |
| 44 | + <td> | |
| 45 | + <span className="inline-flex gap-1"> | |
| 46 | + <SourceBadge p={toInfo(prov.get(c.provenance_id), 'derived') ?? { sourceSlug: 'pubmed', sourceName: 'PubMed' }} /> | |
| 47 | + <ClaimBadge kind="computed" /> | |
| 48 | + </span> | |
| 49 | + </td> | |
| 50 | + </tr> | |
| 51 | + ))} | |
| 52 | + </tbody> | |
| 53 | + </table> | |
| 54 | + </div> | |
| 55 | + <Freshness dataUpdatedAt={counts[0]!.updated_at} extra="source: pubmed E-utilities" /> | |
| 56 | + </> | |
| 57 | + ) : ( | |
| 58 | + <EmptyState knows={[{ label: 'Trials tab', href: `/cancer/${b.cancer.slug}/trials` }, { label: 'Methodology: publication counts', href: '/methodology#publications_5y' }]}> | |
| 59 | + No PubMed query has been run for this entity. Counts require a MeSH-anchored query built from the entity's terminology mappings; the query string is stored with each count so it can be reproduced. | |
| 60 | + </EmptyState> | |
| 61 | + )} | |
| 62 | + </Section> | |
| 63 | + <Section id="recent-publications" kicker="Literature" title="Linked publications" description="Publications linked to this entity or its descendants. Link method and validation status are shown; 'candidate' links have not been reviewed."> | |
| 64 | + {pubs.length ? <PublicationList rows={pubs} /> : <EmptyState compact>No publication is linked to this entity yet.</EmptyState>} | |
| 65 | + </Section> | |
| 66 | + </div> | |
| 67 | + ); | |
| 68 | +} | |
added
apps/web/src/components/cancer/tabs/sources.tsx
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Badge, MatchBadge } from '@/components/ui/badge'; | |
| 5 | +import { JsonView } from '@/components/ui/json-view'; | |
| 6 | +import { getContributingSources, getChangeEvents } from '@/lib/queries/cancers'; | |
| 7 | +import { CODE_SYSTEM_LABEL, codeUrl } from '@/lib/site'; | |
| 8 | +import { fmtDate, fmtDateTime, humanize } from '@/lib/format'; | |
| 9 | +import type { CancerBundle } from '../load'; | |
| 10 | + | |
| 11 | +export async function SourcesTab({ b }: { b: CancerBundle }) { | |
| 12 | + const [contrib, events] = await Promise.all([getContributingSources(b.cancer.id, b.descendants), getChangeEvents('cancer', b.cancer.id)]); | |
| 13 | + return ( | |
| 14 | + <div className="space-y-8"> | |
| 15 | + <Section id="contributing" kicker="Provenance" title="Sources contributing to this entity" description="Every source that supplied a code, alias, edge, observation or derived value, with the ingest runs involved."> | |
| 16 | + {contrib.length ? ( | |
| 17 | + <div className="ci-table-wrap"> | |
| 18 | + <table className="ci-table"> | |
| 19 | + <thead> | |
| 20 | + <tr> | |
| 21 | + <th>Source</th> | |
| 22 | + <th>License</th> | |
| 23 | + <th>Contributions</th> | |
| 24 | + <th>Ingest runs</th> | |
| 25 | + <th>Last retrieved</th> | |
| 26 | + </tr> | |
| 27 | + </thead> | |
| 28 | + <tbody> | |
| 29 | + {contrib.map((s) => ( | |
| 30 | + <tr key={s.slug}> | |
| 31 | + <td> | |
| 32 | + <Link className="ci-link" href={`/source/${s.slug}`}> | |
| 33 | + {s.name} | |
| 34 | + </Link>{' '} | |
| 35 | + <span className="ci-mono text-[11px] text-ink-3">{s.slug}</span> | |
| 36 | + </td> | |
| 37 | + <td> | |
| 38 | + <Badge tone={s.license_status === 'approved' ? 'ok' : 'warn'}>{s.license_status}</Badge> | |
| 39 | + </td> | |
| 40 | + <td className="text-[12.5px]">{s.kinds.map(humanize).join(', ')}</td> | |
| 41 | + <td className="text-[11.5px]"> | |
| 42 | + {s.run_ids.length ? ( | |
| 43 | + s.run_ids.slice(0, 5).map((r) => ( | |
| 44 | + <Link key={r} href={`/admin/runs/${r}`} className="ci-mono ci-link block"> | |
| 45 | + {r} | |
| 46 | + </Link> | |
| 47 | + )) | |
| 48 | + ) : ( | |
| 49 | + <span className="text-ink-4">—</span> | |
| 50 | + )} | |
| 51 | + {s.run_ids.length > 5 ? <span className="text-ink-3">+{s.run_ids.length - 5} more</span> : null} | |
| 52 | + </td> | |
| 53 | + <td className="whitespace-nowrap text-[12.5px]">{s.last_retrieved ? fmtDateTime(s.last_retrieved) : '—'}</td> | |
| 54 | + </tr> | |
| 55 | + ))} | |
| 56 | + </tbody> | |
| 57 | + </table> | |
| 58 | + </div> | |
| 59 | + ) : ( | |
| 60 | + <EmptyState compact>No source contribution recorded.</EmptyState> | |
| 61 | + )} | |
| 62 | + </Section> | |
| 63 | + | |
| 64 | + <Section id="codes" kicker="Identifiers" title={`Cross-reference codes (${b.codes.length})`} description="Every upstream identifier is kept with its match type; nothing is buried in JSON."> | |
| 65 | + {b.codes.length ? ( | |
| 66 | + <div className="ci-table-wrap"> | |
| 67 | + <table className="ci-table"> | |
| 68 | + <thead> | |
| 69 | + <tr> | |
| 70 | + <th>System</th> | |
| 71 | + <th>Code</th> | |
| 72 | + <th>Match type</th> | |
| 73 | + <th>Source</th> | |
| 74 | + </tr> | |
| 75 | + </thead> | |
| 76 | + <tbody> | |
| 77 | + {b.codes.map((k) => { | |
| 78 | + const url = codeUrl(k.system, k.code); | |
| 79 | + return ( | |
| 80 | + <tr key={`${k.system}-${k.code}`}> | |
| 81 | + <td>{CODE_SYSTEM_LABEL[k.system] ?? k.system}</td> | |
| 82 | + <td> | |
| 83 | + {url ? ( | |
| 84 | + <a className="ci-mono ci-link" href={url} target="_blank" rel="noopener noreferrer"> | |
| 85 | + {k.code} | |
| 86 | + </a> | |
| 87 | + ) : ( | |
| 88 | + <span className="ci-mono">{k.code}</span> | |
| 89 | + )} | |
| 90 | + </td> | |
| 91 | + <td> | |
| 92 | + <MatchBadge matchType={k.match_type} /> | |
| 93 | + </td> | |
| 94 | + <td> | |
| 95 | + {k.source_slug ? ( | |
| 96 | + <Link className="ci-link" href={`/source/${k.source_slug}`}> | |
| 97 | + {k.source_slug} | |
| 98 | + </Link> | |
| 99 | + ) : ( | |
| 100 | + '—' | |
| 101 | + )} | |
| 102 | + </td> | |
| 103 | + </tr> | |
| 104 | + ); | |
| 105 | + })} | |
| 106 | + </tbody> | |
| 107 | + </table> | |
| 108 | + </div> | |
| 109 | + ) : ( | |
| 110 | + <EmptyState compact>No cross-reference code.</EmptyState> | |
| 111 | + )} | |
| 112 | + </Section> | |
| 113 | + | |
| 114 | + <Section id="aliases" kicker="Names" title={`Aliases (${b.aliases.length})`}> | |
| 115 | + {b.aliases.length ? ( | |
| 116 | + <div className="ci-table-wrap"> | |
| 117 | + <table className="ci-table"> | |
| 118 | + <thead> | |
| 119 | + <tr> | |
| 120 | + <th>Alias</th> | |
| 121 | + <th>Type</th> | |
| 122 | + <th>Terminology</th> | |
| 123 | + <th>Language</th> | |
| 124 | + </tr> | |
| 125 | + </thead> | |
| 126 | + <tbody> | |
| 127 | + {b.aliases.map((a, i) => ( | |
| 128 | + <tr key={`${a.alias}-${a.alias_type}-${i}`}> | |
| 129 | + <td>{a.alias}</td> | |
| 130 | + <td> | |
| 131 | + <Badge tone="outline">{a.alias_type}</Badge> | |
| 132 | + </td> | |
| 133 | + <td className="text-[12.5px] text-ink-3">{a.source_terminology ?? a.source_slug ?? '—'}</td> | |
| 134 | + <td className="ci-mono text-[12px]">en</td> | |
| 135 | + </tr> | |
| 136 | + ))} | |
| 137 | + </tbody> | |
| 138 | + </table> | |
| 139 | + </div> | |
| 140 | + ) : ( | |
| 141 | + <EmptyState compact>No alias recorded.</EmptyState> | |
| 142 | + )} | |
| 143 | + </Section> | |
| 144 | + | |
| 145 | + <Section id="history" kicker="Change history" title="Change events" description="Creation, updates, merges and ranking changes recorded for this entity."> | |
| 146 | + {events.length ? ( | |
| 147 | + <ol className="divide-y divide-rule text-[13.5px]"> | |
| 148 | + {events.map((e) => ( | |
| 149 | + <li key={e.id} className="py-2"> | |
| 150 | + <div className="flex flex-wrap items-baseline gap-2"> | |
| 151 | + <Badge>{humanize(e.kind)}</Badge> | |
| 152 | + <span>{e.summary}</span> | |
| 153 | + <span className="ml-auto text-[12px] text-ink-3">{fmtDate(e.created_at)}</span> | |
| 154 | + </div> | |
| 155 | + {e.ingest_run_id ? ( | |
| 156 | + <Link href={`/admin/runs/${e.ingest_run_id}`} className="ci-mono ci-link text-[11.5px]"> | |
| 157 | + {e.ingest_run_id} | |
| 158 | + </Link> | |
| 159 | + ) : null} | |
| 160 | + {e.before || e.after ? ( | |
| 161 | + <details className="mt-1 text-[12px]"> | |
| 162 | + <summary className="ci-link">diff</summary> | |
| 163 | + <div className="mt-1 grid gap-3 sm:grid-cols-2"> | |
| 164 | + <div> | |
| 165 | + <p className="ci-kicker">Before</p> | |
| 166 | + <JsonView data={e.before} /> | |
| 167 | + </div> | |
| 168 | + <div> | |
| 169 | + <p className="ci-kicker">After</p> | |
| 170 | + <JsonView data={e.after} /> | |
| 171 | + </div> | |
| 172 | + </div> | |
| 173 | + </details> | |
| 174 | + ) : null} | |
| 175 | + </li> | |
| 176 | + ))} | |
| 177 | + </ol> | |
| 178 | + ) : ( | |
| 179 | + <EmptyState compact>No change event recorded yet. Created {fmtDate(b.cancer.created_at)}; last updated {fmtDate(b.cancer.updated_at)}.</EmptyState> | |
| 180 | + )} | |
| 181 | + </Section> | |
| 182 | + </div> | |
| 183 | + ); | |
| 184 | +} | |
added
apps/web/src/components/cancer/tabs/statistics.tsx
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +import { Section, Note } from '@/components/ui/section'; | |
| 2 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 3 | +import { Freshness } from '@/components/ui/freshness'; | |
| 4 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 6 | +import { LineChart, type Series } from '@/components/charts/line-chart'; | |
| 7 | +import { epidemiologyFor, EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; | |
| 8 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 9 | +import { fmtValue, humanize, unitLabel } from '@/lib/format'; | |
| 10 | +import type { CancerBundle } from '../load'; | |
| 11 | + | |
| 12 | +export async function StatisticsTab({ b }: { b: CancerBundle }) { | |
| 13 | + const obs = await epidemiologyFor(b.cancer.id); | |
| 14 | + if (obs.length === 0) { | |
| 15 | + return ( | |
| 16 | + <Section id="statistics" kicker="Epidemiology" title="Incidence, mortality and prevalence"> | |
| 17 | + <EmptyState | |
| 18 | + title="Data not yet available" | |
| 19 | + knows={[ | |
| 20 | + { label: 'Taxonomy and codes', href: `/cancer/${b.cancer.slug}` }, | |
| 21 | + ...(b.counters?.trial_count ? [{ label: `${b.counters.trial_count} trials`, href: `/cancer/${b.cancer.slug}/trials` }] : []), | |
| 22 | + { label: 'Source registry and license status', href: '/sources' }, | |
| 23 | + ]} | |
| 24 | + > | |
| 25 | + No epidemiology observation is attached to this entity. Population statistics come from registries (SEER, CDC WONDER) and the IARC Global Cancer Observatory; IARC data stays under license review and SEER awaits credentials, so no burden figure is displayed. Statistics are attached per geography, year and sex — never invented or extrapolated. | |
| 26 | + </EmptyState> | |
| 27 | + </Section> | |
| 28 | + ); | |
| 29 | + } | |
| 30 | + | |
| 31 | + const prov = await loadProvenance(obs.map((o) => o.provenance_id)); | |
| 32 | + // Group: geography → metric → (sex, age) → series by year | |
| 33 | + type Key = string; | |
| 34 | + const groups = new Map<Key, { geography: string; metric: string; unit: string; rows: typeof obs }>(); | |
| 35 | + for (const o of obs) { | |
| 36 | + const k = `${o.geography_id}|${o.metric}`; | |
| 37 | + if (!groups.has(k)) groups.set(k, { geography: o.geography_name, metric: o.metric, unit: o.unit, rows: [] }); | |
| 38 | + groups.get(k)!.rows.push(o); | |
| 39 | + } | |
| 40 | + const latest = obs.reduce((m, o) => (o.updated_at > m ? o.updated_at : m), obs[0]!.updated_at); | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <div className="space-y-8"> | |
| 44 | + <Note tone="warn">Population statistics describe groups defined by geography, period and sex. They do not predict any individual outcome. Values labelled "estimated" or "projected" are model outputs from the source, not registry counts.</Note> | |
| 45 | + {[...groups.values()].map((g) => { | |
| 46 | + const seriesMap = new Map<string, Series>(); | |
| 47 | + for (const r of g.rows) { | |
| 48 | + const name = `${humanize(r.sex)}${r.age_group !== 'all' ? ` · ${r.age_group}` : ''}${r.estimate_type !== 'observed' ? ` (${r.estimate_type})` : ''} · ${r.source_slug}`; | |
| 49 | + if (!seriesMap.has(name)) seriesMap.set(name, { name, points: [], dashed: r.estimate_type !== 'observed' }); | |
| 50 | + seriesMap.get(name)!.points.push({ x: r.year, y: r.value, lo: r.lower_ci, hi: r.upper_ci }); | |
| 51 | + } | |
| 52 | + const series = [...seriesMap.values()]; | |
| 53 | + const multiYear = series.some((s) => s.points.length > 1); | |
| 54 | + return ( | |
| 55 | + <Section key={`${g.geography}-${g.metric}`} id={`${g.metric}-${g.geography}`} kicker={g.geography} title={EPI_METRIC_LABEL[g.metric] ?? humanize(g.metric)} description={`Unit: ${unitLabel(g.unit)}${g.rows[0]?.standard_population ? ` · standard population: ${g.rows[0].standard_population}` : ''}`}> | |
| 56 | + {multiYear ? <LineChart series={series} unit={g.unit} ariaLabel={`${EPI_METRIC_LABEL[g.metric] ?? g.metric} in ${g.geography} by year`} /> : null} | |
| 57 | + <div className={`ci-table-wrap ${multiYear ? 'mt-4' : ''}`}> | |
| 58 | + <table className="ci-table"> | |
| 59 | + <thead> | |
| 60 | + <tr> | |
| 61 | + <th>Year</th> | |
| 62 | + <th>Sex</th> | |
| 63 | + <th>Age group</th> | |
| 64 | + <th className="num">Value ({unitLabel(g.unit)})</th> | |
| 65 | + <th className="num">95% CI</th> | |
| 66 | + <th>Type</th> | |
| 67 | + <th>Site definition</th> | |
| 68 | + <th>Source</th> | |
| 69 | + </tr> | |
| 70 | + </thead> | |
| 71 | + <tbody> | |
| 72 | + {g.rows.map((r) => ( | |
| 73 | + <tr key={r.id}> | |
| 74 | + <td className="ci-num">{r.year_end && r.year_end !== r.year ? `${r.year}–${r.year_end}` : r.year}</td> | |
| 75 | + <td>{humanize(r.sex)}</td> | |
| 76 | + <td>{r.age_group === 'all' ? 'All ages' : r.age_group}</td> | |
| 77 | + <td className="num font-medium">{fmtValue(r.value, r.unit)}</td> | |
| 78 | + <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> | |
| 79 | + <td> | |
| 80 | + <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge> | |
| 81 | + </td> | |
| 82 | + <td className="max-w-[240px] text-[12px] text-ink-3">{r.site_definition ?? '—'}</td> | |
| 83 | + <td> | |
| 84 | + <span className="inline-flex gap-1"> | |
| 85 | + <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} /> | |
| 86 | + <ClaimBadge kind="observed" /> | |
| 87 | + </span> | |
| 88 | + </td> | |
| 89 | + </tr> | |
| 90 | + ))} | |
| 91 | + </tbody> | |
| 92 | + </table> | |
| 93 | + </div> | |
| 94 | + <Freshness dataUpdatedAt={g.rows.reduce((m, r) => (r.updated_at > m ? r.updated_at : m), g.rows[0]!.updated_at)} sourceVersion={prov.get(g.rows[0]!.provenance_id)?.dataset_version ?? null} /> | |
| 95 | + </Section> | |
| 96 | + ); | |
| 97 | + })} | |
| 98 | + <Freshness dataUpdatedAt={latest} extra={`${obs.length} observations`} /> | |
| 99 | + </div> | |
| 100 | + ); | |
| 101 | +} | |
added
apps/web/src/components/cancer/tabs/survival.tsx
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +import { Section, Note } from '@/components/ui/section'; | |
| 2 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 3 | +import { Freshness } from '@/components/ui/freshness'; | |
| 4 | +import { ClaimBadge } from '@/components/ui/badge'; | |
| 5 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 6 | +import { survivalFor } from '@/lib/queries/epidemiology'; | |
| 7 | +import { loadProvenance, toInfo } from '@/lib/queries/provenance'; | |
| 8 | +import { fmtInt, fmtPct, fmtNum, humanize } from '@/lib/format'; | |
| 9 | +import type { CancerBundle } from '../load'; | |
| 10 | + | |
| 11 | +export async function SurvivalTab({ b }: { b: CancerBundle }) { | |
| 12 | + const rows = await survivalFor(b.cancer.id); | |
| 13 | + if (rows.length === 0) { | |
| 14 | + return ( | |
| 15 | + <Section id="survival" kicker="Survival" title="Population survival"> | |
| 16 | + <EmptyState knows={[{ label: 'Statistics tab', href: `/cancer/${b.cancer.slug}/statistics` }, { label: 'Methodology: 5-year relative survival', href: '/methodology#five_year_survival' }]}> | |
| 17 | + No survival observation is attached. Survival statistics require stage, staging system, diagnosis period, survival type and population — they are only imported with that full context (SEER, once credentials and terms are settled). | |
| 18 | + </EmptyState> | |
| 19 | + </Section> | |
| 20 | + ); | |
| 21 | + } | |
| 22 | + const prov = await loadProvenance(rows.map((r) => r.provenance_id)); | |
| 23 | + const groups = new Map<string, typeof rows>(); | |
| 24 | + for (const r of rows) { | |
| 25 | + const k = `${r.survival_type}|${r.stage ?? 'all stages'}|${r.geography_name ?? ''}`; | |
| 26 | + if (!groups.has(k)) groups.set(k, []); | |
| 27 | + groups.get(k)!.push(r); | |
| 28 | + } | |
| 29 | + return ( | |
| 30 | + <div className="space-y-8"> | |
| 31 | + <Note tone="warn">Population survival is not an individual prognosis. Figures apply to the cohort, period, stage and population declared by the source; treatment landscapes change after the diagnosis period shown.</Note> | |
| 32 | + {[...groups.entries()].map(([k, rs]) => { | |
| 33 | + const f = rs[0]!; | |
| 34 | + return ( | |
| 35 | + <Section key={k} id={k.replace(/[^a-z0-9]+/gi, '-')} kicker={f.geography_name ?? 'Population'} title={`${humanize(f.survival_type)} survival${f.stage ? ` — ${f.stage}` : ' — all stages'}`} description={f.staging_system ? `Staging system: ${f.staging_system}` : undefined}> | |
| 36 | + <div className="ci-table-wrap"> | |
| 37 | + <table className="ci-table"> | |
| 38 | + <thead> | |
| 39 | + <tr> | |
| 40 | + <th>Diagnosis period</th> | |
| 41 | + <th>Sex</th> | |
| 42 | + <th>Age group</th> | |
| 43 | + <th className="num">Follow-up (months)</th> | |
| 44 | + <th className="num">Survival (%)</th> | |
| 45 | + <th className="num">95% CI</th> | |
| 46 | + <th className="num">Median (months)</th> | |
| 47 | + <th className="num">Cohort (n)</th> | |
| 48 | + <th>Method</th> | |
| 49 | + <th>Source</th> | |
| 50 | + </tr> | |
| 51 | + </thead> | |
| 52 | + <tbody> | |
| 53 | + {rs.map((r) => ( | |
| 54 | + <tr key={r.id}> | |
| 55 | + <td>{r.diagnosis_period ?? '—'}</td> | |
| 56 | + <td>{humanize(r.sex)}</td> | |
| 57 | + <td>{r.age_group === 'all' ? 'All ages' : r.age_group}</td> | |
| 58 | + <td className="num">{r.duration_months}</td> | |
| 59 | + <td className="num font-medium">{fmtPct(r.probability)}</td> | |
| 60 | + <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtPct(r.lower_ci)}–${fmtPct(r.upper_ci)}` : '—'}</td> | |
| 61 | + <td className="num">{r.median_months != null ? fmtNum(r.median_months, 1) : '—'}</td> | |
| 62 | + <td className="num">{r.cohort_size != null ? fmtInt(r.cohort_size) : '—'}</td> | |
| 63 | + <td className="text-[12px] text-ink-3">{r.method ?? '—'}</td> | |
| 64 | + <td> | |
| 65 | + <span className="inline-flex gap-1"> | |
| 66 | + <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} /> | |
| 67 | + <ClaimBadge kind="observed" /> | |
| 68 | + </span> | |
| 69 | + </td> | |
| 70 | + </tr> | |
| 71 | + ))} | |
| 72 | + </tbody> | |
| 73 | + </table> | |
| 74 | + </div> | |
| 75 | + <Freshness dataUpdatedAt={rs[0]!.updated_at} sourceVersion={prov.get(rs[0]!.provenance_id)?.dataset_version ?? null} /> | |
| 76 | + </Section> | |
| 77 | + ); | |
| 78 | + })} | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +} | |
added
apps/web/src/components/cancer/tabs/trials.tsx
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import { Pagination } from '@/components/ui/pagination'; | |
| 6 | +import { TrialTable } from '@/components/data/trial-list'; | |
| 7 | +import { listTrials, trialFacets } from '@/lib/queries/trials'; | |
| 8 | +import { fmtInt, humanize, phaseLabel } from '@/lib/format'; | |
| 9 | +import { withParams } from '@/lib/search-params'; | |
| 10 | +import type { CancerBundle } from '../load'; | |
| 11 | + | |
| 12 | +const PAGE_SIZE = 25; | |
| 13 | + | |
| 14 | +export async function TrialsTab({ b, status, phase, page }: { b: CancerBundle; status: string; phase: string; page: number }) { | |
| 15 | + const facets = await trialFacets(b.descendants); | |
| 16 | + const { rows, total } = await listTrials({ q: '', status, phase, country: '', cancerIds: b.descendants, page, pageSize: PAGE_SIZE }); | |
| 17 | + const base = `/cancer/${b.cancer.slug}/trials`; | |
| 18 | + const href = (o: Record<string, string | number | null | undefined>) => `${base}${withParams({ status, phase }, o)}`; | |
| 19 | + const anyTrials = facets.statuses.reduce((s, x) => s + x.n, 0); | |
| 20 | + | |
| 21 | + if (anyTrials === 0) { | |
| 22 | + return ( | |
| 23 | + <Section id="trials" kicker="Clinical trials" title="Registered studies"> | |
| 24 | + <EmptyState knows={[{ label: 'Trials explorer', href: '/trials' }, { label: 'Research tab', href: `/cancer/${b.cancer.slug}/research` }]}> | |
| 25 | + No ClinicalTrials.gov study has a condition mapped to this entity or its descendants yet. Conditions are free text at the source; they are reconciled to the taxonomy by identifier, curated alias, then normalized string — unmatched labels are queued for review, never dropped. | |
| 26 | + </EmptyState> | |
| 27 | + </Section> | |
| 28 | + ); | |
| 29 | + } | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <Section id="trials" kicker="Clinical trials" title="Registered studies" description={`${fmtInt(anyTrials)} studies whose conditions map to this entity or one of its descendants. Status and phase are as posted on ClinicalTrials.gov.`}> | |
| 33 | + <div className="mb-3 flex flex-wrap gap-4 text-[12.5px]"> | |
| 34 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 35 | + <span className="ci-kicker mr-1">Status</span> | |
| 36 | + <Link href={href({ status: '', page: '' })} className={`border px-2 py-0.5 no-underline ${!status ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 37 | + All | |
| 38 | + </Link> | |
| 39 | + <Link href={href({ status: 'active', page: '' })} className={`border px-2 py-0.5 no-underline ${status === 'active' ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 40 | + Active (any) | |
| 41 | + </Link> | |
| 42 | + {facets.statuses.map((s) => ( | |
| 43 | + <Link key={s.k} href={href({ status: s.k, page: '' })} className={`border px-2 py-0.5 no-underline ${status === s.k ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 44 | + {humanize(s.k)} <span className="ci-num text-ink-3">{fmtInt(s.n)}</span> | |
| 45 | + </Link> | |
| 46 | + ))} | |
| 47 | + </div> | |
| 48 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 49 | + <span className="ci-kicker mr-1">Phase</span> | |
| 50 | + <Link href={href({ phase: '', page: '' })} className={`border px-2 py-0.5 no-underline ${!phase ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 51 | + All | |
| 52 | + </Link> | |
| 53 | + {facets.phases.map((p) => ( | |
| 54 | + <Link key={p.k} href={href({ phase: p.k, page: '' })} className={`border px-2 py-0.5 no-underline ${phase === p.k ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}> | |
| 55 | + {phaseLabel(p.k)} <span className="ci-num text-ink-3">{fmtInt(p.n)}</span> | |
| 56 | + </Link> | |
| 57 | + ))} | |
| 58 | + </div> | |
| 59 | + </div> | |
| 60 | + {rows.length ? ( | |
| 61 | + <> | |
| 62 | + <TrialTable rows={rows} /> | |
| 63 | + <Pagination page={page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} /> | |
| 64 | + <Freshness dataUpdatedAt={rows.reduce((m, t) => (t.updated_at > m ? t.updated_at : m), rows[0]!.updated_at)} sourceUpdatedAt={rows[0]?.last_update_posted_date ?? null} extra="source: clinicaltrials" /> | |
| 65 | + </> | |
| 66 | + ) : ( | |
| 67 | + <EmptyState compact title="No study matches these filters" /> | |
| 68 | + )} | |
| 69 | + </Section> | |
| 70 | + ); | |
| 71 | +} | |
added
apps/web/src/components/charts/bar-chart.tsx
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +import { fmtValue } from '@/lib/format'; | |
| 2 | + | |
| 3 | +export interface BarDatum { | |
| 4 | + label: string; | |
| 5 | + value: number; | |
| 6 | + href?: string; | |
| 7 | + muted?: boolean; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Horizontal bar chart in pure SVG (no chart library). Single hue; labels carry the meaning. | |
| 12 | + * Values are formatted with the metric unit; bars never start anywhere but zero. | |
| 13 | + */ | |
| 14 | +export function BarChart({ data, unit, maxBars = 15, ariaLabel }: { data: BarDatum[]; unit?: string | null; maxBars?: number; ariaLabel: string }) { | |
| 15 | + const rows = data.slice(0, maxBars); | |
| 16 | + if (rows.length === 0) return null; | |
| 17 | + const max = Math.max(...rows.map((r) => Math.abs(r.value)), Number.EPSILON); | |
| 18 | + const rowH = 22; | |
| 19 | + const labelW = 190; | |
| 20 | + const valueW = 74; | |
| 21 | + const width = 640; | |
| 22 | + const barW = width - labelW - valueW - 8; | |
| 23 | + const height = rows.length * rowH + 4; | |
| 24 | + return ( | |
| 25 | + <figure className="w-full overflow-x-auto"> | |
| 26 | + <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block min-w-[480px] text-ink" style={{ maxHeight: height }}> | |
| 27 | + <title>{ariaLabel}</title> | |
| 28 | + {rows.map((r, i) => { | |
| 29 | + const y = i * rowH + 2; | |
| 30 | + const w = Math.max(1, (Math.abs(r.value) / max) * barW); | |
| 31 | + const fill = r.muted ? 'var(--color-ink-4)' : 'var(--color-accent)'; | |
| 32 | + return ( | |
| 33 | + <g key={`${r.label}-${i}`}> | |
| 34 | + <text x={labelW - 8} y={y + rowH / 2 + 4} textAnchor="end" fontSize="12" fill="var(--color-ink-2)"> | |
| 35 | + {r.label.length > 30 ? `${r.label.slice(0, 29)}…` : r.label} | |
| 36 | + </text> | |
| 37 | + <rect x={labelW} y={y + 4} width={w} height={rowH - 8} fill={fill} /> | |
| 38 | + <text x={labelW + w + 6} y={y + rowH / 2 + 4} fontSize="12" fill="var(--color-ink)" style={{ fontVariantNumeric: 'tabular-nums' }}> | |
| 39 | + {fmtValue(r.value, unit)} | |
| 40 | + </text> | |
| 41 | + </g> | |
| 42 | + ); | |
| 43 | + })} | |
| 44 | + </svg> | |
| 45 | + </figure> | |
| 46 | + ); | |
| 47 | +} | |
added
apps/web/src/components/charts/line-chart.tsx
+83 −0
@@ -0,0 +1,83 @@ | ||
| 1 | +import { fmtValue } from '@/lib/format'; | |
| 2 | + | |
| 3 | +export interface Series { | |
| 4 | + name: string; | |
| 5 | + points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>; | |
| 6 | + dashed?: boolean; // e.g. estimated vs observed | |
| 7 | +} | |
| 8 | + | |
| 9 | +const SERIES_COLORS = ['var(--color-accent)', '#8a4b0a', '#4a4a46', '#2d5f2e', '#8b1e2d', '#7b7b75']; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Time-series line chart in pure SVG: years on x, values on y, optional confidence band. | |
| 13 | + * Zero-based y axis; light grid; every series has a text legend (colour is never the only carrier). | |
| 14 | + */ | |
| 15 | +export function LineChart({ series, unit, ariaLabel, height = 220 }: { series: Series[]; unit?: string | null; ariaLabel: string; height?: number }) { | |
| 16 | + const all = series.flatMap((s) => s.points); | |
| 17 | + if (all.length === 0) return null; | |
| 18 | + const xs = all.map((p) => p.x); | |
| 19 | + const ys = all.flatMap((p) => [p.y, p.lo ?? p.y, p.hi ?? p.y]); | |
| 20 | + const xMin = Math.min(...xs); | |
| 21 | + const xMax = Math.max(...xs); | |
| 22 | + const yMax = Math.max(...ys, Number.EPSILON) * 1.08; | |
| 23 | + const width = 640; | |
| 24 | + const pad = { l: 56, r: 12, t: 10, b: 28 }; | |
| 25 | + const iw = width - pad.l - pad.r; | |
| 26 | + const ih = height - pad.t - pad.b; | |
| 27 | + const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw); | |
| 28 | + const sy = (y: number) => pad.t + ih - (y / yMax) * ih; | |
| 29 | + const yTicks = 4; | |
| 30 | + const xTickCount = Math.min(8, xMax - xMin + 1); | |
| 31 | + const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xTickCount - 1))); | |
| 32 | + return ( | |
| 33 | + <figure className="w-full"> | |
| 34 | + <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block"> | |
| 35 | + <title>{ariaLabel}</title> | |
| 36 | + {Array.from({ length: yTicks + 1 }, (_, i) => { | |
| 37 | + const v = (yMax / yTicks) * i; | |
| 38 | + const y = sy(v); | |
| 39 | + return ( | |
| 40 | + <g key={i}> | |
| 41 | + <line x1={pad.l} x2={width - pad.r} y1={y} y2={y} stroke="var(--color-rule)" strokeWidth="1" /> | |
| 42 | + <text x={pad.l - 6} y={y + 4} textAnchor="end" fontSize="11" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}> | |
| 43 | + {fmtValue(v, unit === 'count' ? 'count' : unit)} | |
| 44 | + </text> | |
| 45 | + </g> | |
| 46 | + ); | |
| 47 | + })} | |
| 48 | + {xTicks.map((x) => ( | |
| 49 | + <text key={x} x={sx(x)} y={height - 8} textAnchor="middle" fontSize="11" fill="var(--color-ink-3)"> | |
| 50 | + {x} | |
| 51 | + </text> | |
| 52 | + ))} | |
| 53 | + {series.map((s, si) => { | |
| 54 | + const pts = [...s.points].sort((a, b) => a.x - b.x); | |
| 55 | + const color = SERIES_COLORS[si % SERIES_COLORS.length]; | |
| 56 | + const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); | |
| 57 | + const band = pts.filter((p) => p.lo != null && p.hi != null); | |
| 58 | + const bandPath = band.length > 1 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.hi!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.lo!).toFixed(1)}`).join(' ')} Z` : null; | |
| 59 | + return ( | |
| 60 | + <g key={s.name}> | |
| 61 | + {bandPath ? <path d={bandPath} fill={color} opacity="0.12" /> : null} | |
| 62 | + <path d={d} fill="none" stroke={color} strokeWidth="1.75" strokeDasharray={s.dashed ? '4 3' : undefined} /> | |
| 63 | + {pts.map((p) => ( | |
| 64 | + <circle key={p.x} cx={sx(p.x)} cy={sy(p.y)} r="2.5" fill={color}> | |
| 65 | + <title>{`${s.name} — ${p.x}: ${fmtValue(p.y, unit)}`}</title> | |
| 66 | + </circle> | |
| 67 | + ))} | |
| 68 | + </g> | |
| 69 | + ); | |
| 70 | + })} | |
| 71 | + </svg> | |
| 72 | + <figcaption className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-ink-2"> | |
| 73 | + {series.map((s, si) => ( | |
| 74 | + <span key={s.name} className="inline-flex items-center gap-1.5"> | |
| 75 | + <span className="inline-block h-[2px] w-4" style={{ background: SERIES_COLORS[si % SERIES_COLORS.length], borderTop: s.dashed ? '2px dashed' : undefined }} aria-hidden /> | |
| 76 | + {s.name} | |
| 77 | + {s.dashed ? ' (estimated)' : ''} | |
| 78 | + </span> | |
| 79 | + ))} | |
| 80 | + </figcaption> | |
| 81 | + </figure> | |
| 82 | + ); | |
| 83 | +} | |
added
apps/web/src/components/data/approvals-table.tsx
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; | |
| 3 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 4 | +import type { ApprovalRow } from '@/lib/queries/drugs'; | |
| 5 | +import type { ProvRow } from '@/lib/queries/provenance'; | |
| 6 | +import { toInfo } from '@/lib/queries/provenance'; | |
| 7 | +import { fmtDate } from '@/lib/format'; | |
| 8 | + | |
| 9 | +/** Jurisdiction-aware regulatory status (§13). Never a bare "approved". */ | |
| 10 | +export function ApprovalsTable({ rows, prov, showDrug = true, showCancer = true }: { rows: ApprovalRow[]; prov: Map<number, ProvRow>; showDrug?: boolean; showCancer?: boolean }) { | |
| 11 | + return ( | |
| 12 | + <div className="ci-table-wrap"> | |
| 13 | + <table className="ci-table"> | |
| 14 | + <thead> | |
| 15 | + <tr> | |
| 16 | + {showDrug ? <th>Drug</th> : null} | |
| 17 | + <th>Jurisdiction · authority</th> | |
| 18 | + {showCancer ? <th>Cancer</th> : null} | |
| 19 | + <th>Indication</th> | |
| 20 | + <th>Status</th> | |
| 21 | + <th>Approval date</th> | |
| 22 | + <th>Source</th> | |
| 23 | + </tr> | |
| 24 | + </thead> | |
| 25 | + <tbody> | |
| 26 | + {rows.map((a) => ( | |
| 27 | + <tr key={a.id}> | |
| 28 | + {showDrug ? ( | |
| 29 | + <td> | |
| 30 | + <Link className="ci-link" href={`/drug/${a.drug_slug}`}> | |
| 31 | + {a.drug_name} | |
| 32 | + </Link> | |
| 33 | + </td> | |
| 34 | + ) : null} | |
| 35 | + <td className="whitespace-nowrap"> | |
| 36 | + <span className="ci-mono font-medium">{a.jurisdiction}</span> <span className="text-ink-3">{a.authority}</span> | |
| 37 | + </td> | |
| 38 | + {showCancer ? ( | |
| 39 | + <td> | |
| 40 | + {a.tumor_agnostic ? <Badge tone="accent">Tumor-agnostic</Badge> : null} | |
| 41 | + {a.cancer_slug ? ( | |
| 42 | + <Link className="ci-link" href={`/cancer/${a.cancer_slug}`}> | |
| 43 | + {a.cancer_name} | |
| 44 | + </Link> | |
| 45 | + ) : !a.tumor_agnostic ? ( | |
| 46 | + <span className="text-ink-3">—</span> | |
| 47 | + ) : null} | |
| 48 | + </td> | |
| 49 | + ) : null} | |
| 50 | + <td className="min-w-[280px] max-w-[520px] text-[12.5px]"> | |
| 51 | + {a.indication} | |
| 52 | + <span className="mt-0.5 flex flex-wrap gap-1"> | |
| 53 | + {a.line_of_therapy ? <Badge tone="outline">{a.line_of_therapy}</Badge> : null} | |
| 54 | + {a.disease_stage ? <Badge tone="outline">{a.disease_stage}</Badge> : null} | |
| 55 | + {a.accelerated ? <Badge tone="warn">accelerated</Badge> : null} | |
| 56 | + {a.conditional ? <Badge tone="warn">conditional</Badge> : null} | |
| 57 | + {a.biomarker_ids.length ? <Badge tone="outline">biomarker-restricted</Badge> : null} | |
| 58 | + </span> | |
| 59 | + </td> | |
| 60 | + <td> | |
| 61 | + <StatusBadge status={a.status} /> | |
| 62 | + {a.withdrawal_date ? <span className="block text-[11px] text-danger">withdrawn {fmtDate(a.withdrawal_date)}</span> : null} | |
| 63 | + </td> | |
| 64 | + <td className="whitespace-nowrap">{fmtDate(a.approval_date)}</td> | |
| 65 | + <td> | |
| 66 | + <span className="inline-flex gap-1"> | |
| 67 | + <SourceBadge p={toInfo(prov.get(a.provenance_id)) ?? { sourceSlug: a.source_slug, sourceName: a.source_name }} /> | |
| 68 | + <ClaimBadge kind="regulatory" /> | |
| 69 | + </span> | |
| 70 | + </td> | |
| 71 | + </tr> | |
| 72 | + ))} | |
| 73 | + </tbody> | |
| 74 | + </table> | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | +} | |
added
apps/web/src/components/data/evidence-table.tsx
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge'; | |
| 3 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 4 | +import { EVIDENCE_LEVEL_LABEL, groupEvidence, type EvidenceItem } from '@/lib/queries/evidence'; | |
| 5 | +import type { ProvRow } from '@/lib/queries/provenance'; | |
| 6 | +import { toInfo } from '@/lib/queries/provenance'; | |
| 7 | +import { humanize } from '@/lib/format'; | |
| 8 | + | |
| 9 | +function DirectionBadge({ e }: { e: EvidenceItem }) { | |
| 10 | + const dir = e.evidence_direction ?? '—'; | |
| 11 | + const sig = e.significance ?? ''; | |
| 12 | + const tone = dir === 'DOES_NOT_SUPPORT' ? 'warn' : sig.includes('RESIST') ? 'danger' : sig.includes('SENSITIV') ? 'ok' : 'neutral'; | |
| 13 | + return ( | |
| 14 | + <span className="inline-flex flex-wrap gap-1"> | |
| 15 | + <Badge tone={tone} title="Evidence direction as curated at the source"> | |
| 16 | + {humanize(dir)} | |
| 17 | + </Badge> | |
| 18 | + {sig ? <Badge tone="outline">{humanize(sig.replace('SENSITIVITYRESPONSE', 'SENSITIVITY_RESPONSE'))}</Badge> : null} | |
| 19 | + </span> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * CIViC evidence grouped by variant → therapy. Levels, directions and significance are shown in | |
| 25 | + * their native form (§3, never collapsed to works/doesn't). | |
| 26 | + */ | |
| 27 | +export function EvidenceTable({ items, prov, showCancer = false, showVariant = true }: { items: EvidenceItem[]; prov: Map<number, ProvRow>; showCancer?: boolean; showVariant?: boolean }) { | |
| 28 | + const groups = groupEvidence(items); | |
| 29 | + return ( | |
| 30 | + <div className="space-y-6"> | |
| 31 | + {groups.map((g) => ( | |
| 32 | + <section key={g.key} aria-label={g.label}> | |
| 33 | + {showVariant ? ( | |
| 34 | + <h3 className="mb-1.5 flex flex-wrap items-baseline gap-2 text-[15px]"> | |
| 35 | + {g.slug ? ( | |
| 36 | + <Link href={`/variant/${g.slug}`} className="ci-link font-sans font-medium"> | |
| 37 | + {g.label} | |
| 38 | + </Link> | |
| 39 | + ) : ( | |
| 40 | + <span className="font-sans font-medium">{g.label}</span> | |
| 41 | + )} | |
| 42 | + {g.genes.map((s) => ( | |
| 43 | + <Link key={s} href={`/gene/${s}`} className="ci-mono text-[12px] text-ink-3 hover:text-accent"> | |
| 44 | + {s} | |
| 45 | + </Link> | |
| 46 | + ))} | |
| 47 | + </h3> | |
| 48 | + ) : null} | |
| 49 | + <div className="ci-table-wrap"> | |
| 50 | + <table className="ci-table"> | |
| 51 | + <thead> | |
| 52 | + <tr> | |
| 53 | + <th>Therapy</th> | |
| 54 | + {showCancer ? <th>Cancer</th> : null} | |
| 55 | + <th>Type</th> | |
| 56 | + <th>Level</th> | |
| 57 | + <th>Direction · significance</th> | |
| 58 | + <th className="num">Rating (1–5)</th> | |
| 59 | + <th>Status</th> | |
| 60 | + <th>Evidence</th> | |
| 61 | + <th>Source</th> | |
| 62 | + </tr> | |
| 63 | + </thead> | |
| 64 | + <tbody> | |
| 65 | + {[...g.therapies.entries()].flatMap(([therapy, es]) => | |
| 66 | + es.map((e, i) => ( | |
| 67 | + <tr key={e.id}> | |
| 68 | + <td className="min-w-[160px]"> | |
| 69 | + {i === 0 ? ( | |
| 70 | + e.therapy_slugs?.length ? ( | |
| 71 | + e.therapy_slugs.map((s, j) => ( | |
| 72 | + <span key={s}> | |
| 73 | + {j > 0 ? ' + ' : ''} | |
| 74 | + <Link className="ci-link" href={`/drug/${s}`}> | |
| 75 | + {e.therapy_names[j] ?? s} | |
| 76 | + </Link> | |
| 77 | + </span> | |
| 78 | + )) | |
| 79 | + ) : ( | |
| 80 | + <span className={therapy.startsWith('(') ? 'text-ink-3' : ''}>{therapy}</span> | |
| 81 | + ) | |
| 82 | + ) : ( | |
| 83 | + <span className="text-ink-4">〃</span> | |
| 84 | + )} | |
| 85 | + {e.therapy_interaction_type && i === 0 ? <span className="block text-[11px] text-ink-3">{humanize(e.therapy_interaction_type)}</span> : null} | |
| 86 | + </td> | |
| 87 | + {showCancer ? ( | |
| 88 | + <td className="min-w-[160px]"> | |
| 89 | + {e.cancer_slug ? ( | |
| 90 | + <Link className="ci-link" href={`/cancer/${e.cancer_slug}`}> | |
| 91 | + {e.cancer_name} | |
| 92 | + </Link> | |
| 93 | + ) : ( | |
| 94 | + <span className="text-ink-3">{e.disease_name ?? '—'}</span> | |
| 95 | + )} | |
| 96 | + {e.cancer_match_type && e.cancer_match_type !== 'EXACT_IDENTIFIER' ? <MatchBadge matchType={e.cancer_match_type} className="ml-1" /> : null} | |
| 97 | + </td> | |
| 98 | + ) : null} | |
| 99 | + <td> | |
| 100 | + <Badge>{humanize(e.evidence_type)}</Badge> | |
| 101 | + </td> | |
| 102 | + <td> | |
| 103 | + <span className="ci-mono font-medium" title={e.evidence_level ? EVIDENCE_LEVEL_LABEL[e.evidence_level] : undefined}> | |
| 104 | + {e.evidence_level ?? '—'} | |
| 105 | + </span> | |
| 106 | + </td> | |
| 107 | + <td> | |
| 108 | + <DirectionBadge e={e} /> | |
| 109 | + </td> | |
| 110 | + <td className="num">{e.evidence_rating ?? '—'}</td> | |
| 111 | + <td> | |
| 112 | + <StatusBadge status={e.status} /> | |
| 113 | + </td> | |
| 114 | + <td className="max-w-[360px] text-[12.5px] text-ink-2"> | |
| 115 | + <details> | |
| 116 | + <summary className="ci-link">EID{e.civic_id}</summary> | |
| 117 | + <p className="mt-1">{e.description ?? 'No description at source.'}</p> | |
| 118 | + {e.pmid ? ( | |
| 119 | + <p className="mt-1"> | |
| 120 | + PMID{' '} | |
| 121 | + <a className="ci-link" href={`https://pubmed.ncbi.nlm.nih.gov/${e.pmid}/`} target="_blank" rel="noopener noreferrer"> | |
| 122 | + {e.pmid} | |
| 123 | + </a> | |
| 124 | + {e.source_citation ? <span className="text-ink-3"> · {e.source_citation}</span> : null} | |
| 125 | + </p> | |
| 126 | + ) : null} | |
| 127 | + <a className="ci-link mt-1 inline-block" href={`https://civicdb.org/evidence/${e.civic_id}/summary`} target="_blank" rel="noopener noreferrer"> | |
| 128 | + Open in CIViC | |
| 129 | + </a> | |
| 130 | + </details> | |
| 131 | + </td> | |
| 132 | + <td> | |
| 133 | + <span className="inline-flex flex-wrap gap-1"> | |
| 134 | + {toInfo(prov.get(e.provenance_id)) ? <SourceBadge p={toInfo(prov.get(e.provenance_id))!} /> : <SourceBadge p={{ sourceSlug: 'civic', sourceName: 'CIViC' }} />} | |
| 135 | + <ClaimBadge kind="curated" /> | |
| 136 | + </span> | |
| 137 | + </td> | |
| 138 | + </tr> | |
| 139 | + )), | |
| 140 | + )} | |
| 141 | + </tbody> | |
| 142 | + </table> | |
| 143 | + </div> | |
| 144 | + </section> | |
| 145 | + ))} | |
| 146 | + </div> | |
| 147 | + ); | |
| 148 | +} | |
added
apps/web/src/components/data/frequency-table.tsx
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Badge, ClaimBadge, MatchBadge } from '@/components/ui/badge'; | |
| 3 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import type { FreqRow } from '@/lib/queries/genomics'; | |
| 6 | +import type { ProvRow } from '@/lib/queries/provenance'; | |
| 7 | +import { toInfo } from '@/lib/queries/provenance'; | |
| 8 | +import { fmtInt, fmtPct, humanize } from '@/lib/format'; | |
| 9 | + | |
| 10 | +/** Gene alteration frequencies with mandatory denominators (§260-261), one table per cohort. */ | |
| 11 | +export function FrequencyTables({ rows, prov, cohortFilter, showCancer = false }: { rows: FreqRow[]; prov: Map<number, ProvRow>; cohortFilter?: string | null; showCancer?: boolean }) { | |
| 12 | + const cohorts = new Map<string, FreqRow[]>(); | |
| 13 | + for (const r of rows) { | |
| 14 | + if (cohortFilter && r.cohort_id !== cohortFilter) continue; | |
| 15 | + if (!cohorts.has(r.cohort_id)) cohorts.set(r.cohort_id, []); | |
| 16 | + cohorts.get(r.cohort_id)!.push(r); | |
| 17 | + } | |
| 18 | + return ( | |
| 19 | + <div className="space-y-8"> | |
| 20 | + {[...cohorts.entries()].map(([cohortId, rs]) => { | |
| 21 | + const c = rs[0]!; | |
| 22 | + return ( | |
| 23 | + <section key={cohortId} aria-label={c.cohort_name}> | |
| 24 | + <header className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2"> | |
| 25 | + <h3 className="font-sans text-[15px] font-medium"> | |
| 26 | + {c.cohort_name} <span className="ci-mono text-[12px] text-ink-3">{c.study_id}</span> | |
| 27 | + {c.program ? <Badge className="ml-2">{c.program}</Badge> : null} | |
| 28 | + </h3> | |
| 29 | + <p className="text-[12.5px] text-ink-3"> | |
| 30 | + {c.cases_with_ssm != null ? ( | |
| 31 | + <> | |
| 32 | + <span className="ci-num">{fmtInt(c.cases_with_ssm)}</span> cases with simple somatic mutation data | |
| 33 | + </> | |
| 34 | + ) : null} | |
| 35 | + {c.case_count != null ? ( | |
| 36 | + <> | |
| 37 | + {' '} | |
| 38 | + · <span className="ci-num">{fmtInt(c.case_count)}</span> cases total | |
| 39 | + </> | |
| 40 | + ) : null} | |
| 41 | + {c.data_release ? <span className="ci-mono"> · {c.data_release}</span> : null} | |
| 42 | + {showCancer && c.cancer_slug ? ( | |
| 43 | + <> | |
| 44 | + {' '} | |
| 45 | + · mapped to{' '} | |
| 46 | + <Link className="ci-link" href={`/cancer/${c.cancer_slug}`}> | |
| 47 | + {c.cancer_name} | |
| 48 | + </Link>{' '} | |
| 49 | + <MatchBadge matchType={c.cancer_match_type} /> | |
| 50 | + </> | |
| 51 | + ) : null} | |
| 52 | + </p> | |
| 53 | + </header> | |
| 54 | + <div className="ci-table-wrap"> | |
| 55 | + <table className="ci-table"> | |
| 56 | + <thead> | |
| 57 | + <tr> | |
| 58 | + <th className="num">#</th> | |
| 59 | + <th>Gene</th> | |
| 60 | + <th>Alteration</th> | |
| 61 | + <th className="num">Affected (n)</th> | |
| 62 | + <th className="num">Profiled (n)</th> | |
| 63 | + <th className="num">Frequency (%)</th> | |
| 64 | + <th>Source</th> | |
| 65 | + </tr> | |
| 66 | + </thead> | |
| 67 | + <tbody> | |
| 68 | + {rs.map((r, i) => ( | |
| 69 | + <tr key={r.id}> | |
| 70 | + <td className="num text-ink-3">{r.rank ?? i + 1}</td> | |
| 71 | + <td> | |
| 72 | + <Link className="ci-mono ci-link font-medium" href={`/gene/${r.gene_symbol}`}> | |
| 73 | + {r.gene_symbol} | |
| 74 | + </Link> | |
| 75 | + </td> | |
| 76 | + <td> | |
| 77 | + <Badge tone="outline">{humanize(r.alteration_type)}</Badge> | |
| 78 | + </td> | |
| 79 | + <td className="num">{fmtInt(r.cases_affected)}</td> | |
| 80 | + <td className="num">{fmtInt(r.cases_profiled)}</td> | |
| 81 | + <td className="num font-medium">{fmtPct(r.frequency, 1)}</td> | |
| 82 | + <td> | |
| 83 | + <span className="inline-flex gap-1"> | |
| 84 | + <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} /> | |
| 85 | + <ClaimBadge kind="observed" /> | |
| 86 | + </span> | |
| 87 | + </td> | |
| 88 | + </tr> | |
| 89 | + ))} | |
| 90 | + </tbody> | |
| 91 | + </table> | |
| 92 | + </div> | |
| 93 | + <Freshness dataUpdatedAt={c.updated_at} sourceVersion={c.data_release} extra="frequency = affected / profiled, as published by the cohort" /> | |
| 94 | + </section> | |
| 95 | + ); | |
| 96 | + })} | |
| 97 | + </div> | |
| 98 | + ); | |
| 99 | +} | |
added
apps/web/src/components/data/publication-list.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Badge } from '@/components/ui/badge'; | |
| 3 | +import type { PublicationRow } from '@/lib/queries/publications'; | |
| 4 | +import { fmtDate } from '@/lib/format'; | |
| 5 | + | |
| 6 | +export function PublicationList({ rows }: { rows: Array<PublicationRow & { method?: string; edge_status?: string }> }) { | |
| 7 | + return ( | |
| 8 | + <ol className="divide-y divide-rule"> | |
| 9 | + {rows.map((p) => ( | |
| 10 | + <li key={p.id} className="py-2.5 text-[13.5px]"> | |
| 11 | + <div className="flex flex-wrap items-baseline gap-x-2"> | |
| 12 | + {p.pmid ? ( | |
| 13 | + <Link href={`/publication/${p.pmid}`} className="ci-link font-medium"> | |
| 14 | + {p.title} | |
| 15 | + </Link> | |
| 16 | + ) : ( | |
| 17 | + <span className="font-medium">{p.title}</span> | |
| 18 | + )} | |
| 19 | + {p.retracted ? <Badge tone="danger">Retracted</Badge> : null} | |
| 20 | + {p.is_preprint ? <Badge tone="warn">Preprint</Badge> : null} | |
| 21 | + </div> | |
| 22 | + <p className="mt-0.5 text-[12.5px] text-ink-3"> | |
| 23 | + {p.authors | |
| 24 | + .slice(0, 3) | |
| 25 | + .map((a) => a.name) | |
| 26 | + .join(', ')} | |
| 27 | + {p.authors.length > 3 ? ' et al.' : ''} | |
| 28 | + {p.journal_iso || p.journal ? ` · ${p.journal_iso ?? p.journal}` : ''} | |
| 29 | + {p.pub_date ? ` · ${fmtDate(p.pub_date)}` : p.pub_year ? ` · ${p.pub_year}` : ''} | |
| 30 | + {p.pmid ? <span className="ci-mono"> · PMID {p.pmid}</span> : null} | |
| 31 | + {p.edge_status ? ( | |
| 32 | + <> | |
| 33 | + {' '} | |
| 34 | + · <Badge tone={p.edge_status === 'validated' ? 'ok' : 'warn'}>{p.edge_status}</Badge> {p.method ? <span className="ci-mono">{p.method}</span> : null} | |
| 35 | + </> | |
| 36 | + ) : null} | |
| 37 | + </p> | |
| 38 | + </li> | |
| 39 | + ))} | |
| 40 | + </ol> | |
| 41 | + ); | |
| 42 | +} | |
added
apps/web/src/components/data/tree.tsx
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import Link from 'next/link'; | |
| 5 | +import { ChevronRight, Loader2 } from 'lucide-react'; | |
| 6 | +import type { TreeNode } from '@/lib/queries/taxonomy'; | |
| 7 | + | |
| 8 | +/** Lazily expandable hierarchy tree. Children are fetched on first expand from /api/taxonomy/children. */ | |
| 9 | +export function Tree({ roots, hierarchyType }: { roots: TreeNode[]; hierarchyType: string }) { | |
| 10 | + return ( | |
| 11 | + <ul role="tree" className="text-[13.5px]"> | |
| 12 | + {roots.map((n) => ( | |
| 13 | + <Node key={n.id} node={n} hierarchyType={hierarchyType} depth={0} /> | |
| 14 | + ))} | |
| 15 | + </ul> | |
| 16 | + ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +function Node({ node, hierarchyType, depth }: { node: TreeNode; hierarchyType: string; depth: number }) { | |
| 20 | + const [open, setOpen] = useState(false); | |
| 21 | + const [children, setChildren] = useState<TreeNode[] | null>(null); | |
| 22 | + const [loading, setLoading] = useState(false); | |
| 23 | + const [error, setError] = useState<string | null>(null); | |
| 24 | + | |
| 25 | + const toggle = async () => { | |
| 26 | + const next = !open; | |
| 27 | + setOpen(next); | |
| 28 | + if (next && children == null && node.child_count > 0) { | |
| 29 | + setLoading(true); | |
| 30 | + setError(null); | |
| 31 | + try { | |
| 32 | + const r = await fetch(`/api/taxonomy/children?id=${encodeURIComponent(node.id)}&type=${encodeURIComponent(hierarchyType)}`); | |
| 33 | + if (!r.ok) throw new Error(`HTTP ${r.status}`); | |
| 34 | + const j = (await r.json()) as { data: TreeNode[] }; | |
| 35 | + setChildren(j.data); | |
| 36 | + } catch (e) { | |
| 37 | + setError((e as Error).message); | |
| 38 | + } finally { | |
| 39 | + setLoading(false); | |
| 40 | + } | |
| 41 | + } | |
| 42 | + }; | |
| 43 | + | |
| 44 | + return ( | |
| 45 | + <li role="treeitem" aria-expanded={node.child_count > 0 ? open : undefined} aria-level={depth + 1}> | |
| 46 | + <div className="flex items-center gap-1 border-b border-rule py-1" style={{ paddingLeft: depth * 18 }}> | |
| 47 | + {node.child_count > 0 ? ( | |
| 48 | + <button type="button" onClick={toggle} aria-label={open ? `Collapse ${node.canonical_name}` : `Expand ${node.canonical_name} (${node.child_count} children)`} className="inline-flex h-5 w-5 shrink-0 items-center justify-center text-ink-3 hover:text-accent"> | |
| 49 | + {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} aria-hidden />} | |
| 50 | + </button> | |
| 51 | + ) : ( | |
| 52 | + <span className="inline-block h-5 w-5 shrink-0" aria-hidden /> | |
| 53 | + )} | |
| 54 | + <Link href={`/cancer/${node.slug}`} className={`ci-link truncate ${node.malignant ? '' : 'text-ink-3'}`}> | |
| 55 | + {node.canonical_name} | |
| 56 | + </Link> | |
| 57 | + {node.primary_oncotree_code && hierarchyType === 'oncotree' ? <span className="ci-mono shrink-0 text-[10.5px] text-ink-4">{node.primary_oncotree_code}</span> : null} | |
| 58 | + {node.primary_ncit_code && hierarchyType === 'ncit' ? <span className="ci-mono shrink-0 text-[10.5px] text-ink-4">{node.primary_ncit_code}</span> : null} | |
| 59 | + {node.child_count > 0 ? <span className="ci-num ml-auto shrink-0 text-[11px] text-ink-3">{node.child_count}</span> : null} | |
| 60 | + </div> | |
| 61 | + {open && error ? ( | |
| 62 | + <p className="py-1 text-[12px] text-danger" style={{ paddingLeft: depth * 18 + 24 }}> | |
| 63 | + Could not load children ({error}). | |
| 64 | + </p> | |
| 65 | + ) : null} | |
| 66 | + {open && children ? ( | |
| 67 | + <ul role="group"> | |
| 68 | + {children.map((c) => ( | |
| 69 | + <Node key={c.id} node={c} hierarchyType={hierarchyType} depth={depth + 1} /> | |
| 70 | + ))} | |
| 71 | + </ul> | |
| 72 | + ) : null} | |
| 73 | + </li> | |
| 74 | + ); | |
| 75 | +} | |
added
apps/web/src/components/data/trial-list.tsx
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Badge, StatusBadge } from '@/components/ui/badge'; | |
| 3 | +import type { TrialRow } from '@/lib/queries/trials'; | |
| 4 | +import { fmtDate, fmtInt, phaseLabel } from '@/lib/format'; | |
| 5 | + | |
| 6 | +export function TrialTable({ rows }: { rows: TrialRow[] }) { | |
| 7 | + return ( | |
| 8 | + <div className="ci-table-wrap"> | |
| 9 | + <table className="ci-table"> | |
| 10 | + <thead> | |
| 11 | + <tr> | |
| 12 | + <th>NCT</th> | |
| 13 | + <th>Title</th> | |
| 14 | + <th>Status</th> | |
| 15 | + <th>Phase</th> | |
| 16 | + <th className="num">Enrollment (n)</th> | |
| 17 | + <th>Sponsor</th> | |
| 18 | + <th className="num">Countries</th> | |
| 19 | + <th>Last update</th> | |
| 20 | + </tr> | |
| 21 | + </thead> | |
| 22 | + <tbody> | |
| 23 | + {rows.map((t) => ( | |
| 24 | + <tr key={t.id}> | |
| 25 | + <td> | |
| 26 | + <Link href={`/trial/${t.nct_id}`} className="ci-mono ci-link"> | |
| 27 | + {t.nct_id} | |
| 28 | + </Link> | |
| 29 | + </td> | |
| 30 | + <td className="min-w-[280px] max-w-[520px]"> | |
| 31 | + <Link href={`/trial/${t.nct_id}`} className="text-ink no-underline hover:text-accent"> | |
| 32 | + {t.brief_title} | |
| 33 | + </Link> | |
| 34 | + {t.acronym ? <span className="ml-1 text-[12px] text-ink-3">({t.acronym})</span> : null} | |
| 35 | + </td> | |
| 36 | + <td> | |
| 37 | + <StatusBadge status={t.overall_status} /> | |
| 38 | + </td> | |
| 39 | + <td className="whitespace-nowrap">{t.phases.length ? t.phases.map(phaseLabel).join(' / ') : <span className="text-ink-4">—</span>}</td> | |
| 40 | + <td className="num">{t.enrollment_count == null ? '—' : fmtInt(t.enrollment_count)}</td> | |
| 41 | + <td className="max-w-[220px] truncate text-[12.5px]" title={t.lead_sponsor ?? ''}> | |
| 42 | + {t.lead_sponsor ?? '—'} | |
| 43 | + {t.lead_sponsor_class ? <Badge tone="outline" className="ml-1">{t.lead_sponsor_class}</Badge> : null} | |
| 44 | + </td> | |
| 45 | + <td className="num">{t.countries.length}</td> | |
| 46 | + <td className="whitespace-nowrap text-[12.5px]">{fmtDate(t.last_update_posted_date)}</td> | |
| 47 | + </tr> | |
| 48 | + ))} | |
| 49 | + </tbody> | |
| 50 | + </table> | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | +} | |
added
apps/web/src/components/layout/breadcrumbs.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { ChevronRight } from 'lucide-react'; | |
| 3 | + | |
| 4 | +export interface Crumb { | |
| 5 | + label: string; | |
| 6 | + href?: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function Breadcrumbs({ items, className = '' }: { items: Crumb[]; className?: string }) { | |
| 10 | + if (items.length === 0) return null; | |
| 11 | + return ( | |
| 12 | + <nav aria-label="Breadcrumb" className={`overflow-x-auto text-[12.5px] text-ink-3 ${className}`}> | |
| 13 | + <ol className="flex items-center gap-1 whitespace-nowrap"> | |
| 14 | + {items.map((c, i) => ( | |
| 15 | + <li key={`${c.label}-${i}`} className="flex items-center gap-1"> | |
| 16 | + {i > 0 ? <ChevronRight className="h-3 w-3 text-ink-4" aria-hidden /> : null} | |
| 17 | + {c.href && i < items.length - 1 ? ( | |
| 18 | + <Link href={c.href} className="text-ink-2 no-underline hover:text-accent"> | |
| 19 | + {c.label} | |
| 20 | + </Link> | |
| 21 | + ) : ( | |
| 22 | + <span aria-current={i === items.length - 1 ? 'page' : undefined} className={i === items.length - 1 ? 'text-ink' : ''}> | |
| 23 | + {c.label} | |
| 24 | + </span> | |
| 25 | + )} | |
| 26 | + </li> | |
| 27 | + ))} | |
| 28 | + </ol> | |
| 29 | + </nav> | |
| 30 | + ); | |
| 31 | +} | |
added
apps/web/src/components/layout/command-palette.tsx
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useRef, useState } from 'react'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { Search, CornerDownLeft } from 'lucide-react'; | |
| 6 | + | |
| 7 | +export interface SearchHit { | |
| 8 | + type: 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication' | 'source'; | |
| 9 | + id: string; | |
| 10 | + title: string; | |
| 11 | + subtitle?: string | null; | |
| 12 | + href: string; | |
| 13 | + match: 'exact' | 'alias' | 'prefix' | 'trigram' | 'identifier'; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const TYPE_LABEL: Record<SearchHit['type'], string> = { cancer: 'Cancer', gene: 'Gene', variant: 'Variant', drug: 'Drug', trial: 'Trial', publication: 'Publication', source: 'Source' }; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * ⌘K command palette. Queries /api/search (own route handler, database-backed) with debounce. | |
| 20 | + * Keyboard: ↑/↓ to move, Enter to open, Esc to close. Announces result count for screen readers. | |
| 21 | + */ | |
| 22 | +export function CommandPalette({ variant = 'button' }: { variant?: 'button' | 'hero' }) { | |
| 23 | + const [open, setOpen] = useState(false); | |
| 24 | + const [q, setQ] = useState(''); | |
| 25 | + const [hits, setHits] = useState<SearchHit[]>([]); | |
| 26 | + const [active, setActive] = useState(0); | |
| 27 | + const [loading, setLoading] = useState(false); | |
| 28 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 29 | + const router = useRouter(); | |
| 30 | + | |
| 31 | + useEffect(() => { | |
| 32 | + const onKey = (e: KeyboardEvent) => { | |
| 33 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { | |
| 34 | + e.preventDefault(); | |
| 35 | + setOpen((o) => !o); | |
| 36 | + } else if (e.key === 'Escape') setOpen(false); | |
| 37 | + }; | |
| 38 | + window.addEventListener('keydown', onKey); | |
| 39 | + return () => window.removeEventListener('keydown', onKey); | |
| 40 | + }, []); | |
| 41 | + | |
| 42 | + useEffect(() => { | |
| 43 | + if (open) setTimeout(() => inputRef.current?.focus(), 10); | |
| 44 | + else { | |
| 45 | + setQ(''); | |
| 46 | + setHits([]); | |
| 47 | + setActive(0); | |
| 48 | + } | |
| 49 | + }, [open]); | |
| 50 | + | |
| 51 | + useEffect(() => { | |
| 52 | + if (!open) return; | |
| 53 | + const term = q.trim(); | |
| 54 | + if (term.length < 2) { | |
| 55 | + setHits([]); | |
| 56 | + return; | |
| 57 | + } | |
| 58 | + const ctrl = new AbortController(); | |
| 59 | + const t = setTimeout(async () => { | |
| 60 | + setLoading(true); | |
| 61 | + try { | |
| 62 | + const r = await fetch(`/api/search?q=${encodeURIComponent(term)}&limit=12`, { signal: ctrl.signal }); | |
| 63 | + const j = (await r.json()) as { data: SearchHit[] }; | |
| 64 | + setHits(j.data ?? []); | |
| 65 | + setActive(0); | |
| 66 | + } catch { | |
| 67 | + /* aborted */ | |
| 68 | + } finally { | |
| 69 | + setLoading(false); | |
| 70 | + } | |
| 71 | + }, 120); | |
| 72 | + return () => { | |
| 73 | + clearTimeout(t); | |
| 74 | + ctrl.abort(); | |
| 75 | + }; | |
| 76 | + }, [q, open]); | |
| 77 | + | |
| 78 | + const go = useCallback( | |
| 79 | + (href: string) => { | |
| 80 | + setOpen(false); | |
| 81 | + router.push(href); | |
| 82 | + }, | |
| 83 | + [router], | |
| 84 | + ); | |
| 85 | + | |
| 86 | + const onKeyDown = (e: React.KeyboardEvent) => { | |
| 87 | + if (e.key === 'ArrowDown') { | |
| 88 | + e.preventDefault(); | |
| 89 | + setActive((a) => Math.min(hits.length - 1, a + 1)); | |
| 90 | + } else if (e.key === 'ArrowUp') { | |
| 91 | + e.preventDefault(); | |
| 92 | + setActive((a) => Math.max(0, a - 1)); | |
| 93 | + } else if (e.key === 'Enter') { | |
| 94 | + e.preventDefault(); | |
| 95 | + const h = hits[active]; | |
| 96 | + if (h) go(h.href); | |
| 97 | + else if (q.trim()) go(`/search?q=${encodeURIComponent(q.trim())}`); | |
| 98 | + } | |
| 99 | + }; | |
| 100 | + | |
| 101 | + return ( | |
| 102 | + <> | |
| 103 | + {variant === 'hero' ? ( | |
| 104 | + <button type="button" onClick={() => setOpen(true)} className="flex w-full items-center gap-3 border border-rule-strong bg-white px-4 py-3 text-left text-[15px] text-ink-3 hover:border-accent focus:border-accent"> | |
| 105 | + <Search className="h-4 w-4 shrink-0" aria-hidden /> | |
| 106 | + <span className="flex-1">Search a cancer, gene, variant, drug, trial or PMID…</span> | |
| 107 | + <kbd className="ci-mono hidden rounded-sm border border-rule px-1.5 text-[11px] text-ink-3 sm:inline">⌘K</kbd> | |
| 108 | + </button> | |
| 109 | + ) : ( | |
| 110 | + <button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-2 border border-rule px-2.5 py-1.5 text-[13px] text-ink-2 hover:border-accent hover:text-accent" aria-label="Search (Command K)"> | |
| 111 | + <Search className="h-3.5 w-3.5" aria-hidden /> | |
| 112 | + <span className="hidden sm:inline">Search</span> | |
| 113 | + <kbd className="ci-mono hidden rounded-sm border border-rule px-1 text-[10.5px] text-ink-3 md:inline">⌘K</kbd> | |
| 114 | + </button> | |
| 115 | + )} | |
| 116 | + {open ? ( | |
| 117 | + <div className="fixed inset-0 z-[100] bg-ink/30 p-3 sm:p-[10vh]" onMouseDown={(e) => e.target === e.currentTarget && setOpen(false)} role="presentation"> | |
| 118 | + <div role="dialog" aria-modal="true" aria-label="Search CancerIndex" className="mx-auto w-full max-w-2xl border border-rule-strong bg-paper shadow-2xl"> | |
| 119 | + <div className="flex items-center gap-2 border-b border-rule px-3"> | |
| 120 | + <Search className="h-4 w-4 text-ink-3" aria-hidden /> | |
| 121 | + <input | |
| 122 | + ref={inputRef} | |
| 123 | + value={q} | |
| 124 | + onChange={(e) => setQ(e.target.value)} | |
| 125 | + onKeyDown={onKeyDown} | |
| 126 | + placeholder="Search cancers, genes, variants, drugs, trials, PMIDs…" | |
| 127 | + className="w-full bg-transparent py-3 text-[15px] outline-none placeholder:text-ink-4" | |
| 128 | + role="combobox" | |
| 129 | + aria-expanded={hits.length > 0} | |
| 130 | + aria-controls="ci-search-results" | |
| 131 | + aria-activedescendant={hits[active] ? `ci-hit-${active}` : undefined} | |
| 132 | + autoComplete="off" | |
| 133 | + spellCheck={false} | |
| 134 | + /> | |
| 135 | + <kbd className="ci-mono rounded-sm border border-rule px-1 text-[10.5px] text-ink-3">esc</kbd> | |
| 136 | + </div> | |
| 137 | + <ul id="ci-search-results" role="listbox" className="max-h-[60vh] overflow-y-auto"> | |
| 138 | + {hits.map((h, i) => ( | |
| 139 | + <li | |
| 140 | + key={`${h.type}-${h.id}`} | |
| 141 | + id={`ci-hit-${i}`} | |
| 142 | + role="option" | |
| 143 | + aria-selected={i === active} | |
| 144 | + onMouseEnter={() => setActive(i)} | |
| 145 | + onClick={() => go(h.href)} | |
| 146 | + className={`flex cursor-pointer items-center gap-3 border-b border-rule px-3 py-2 text-[14px] ${i === active ? 'bg-accent-soft' : ''}`} | |
| 147 | + > | |
| 148 | + <span className="ci-kicker w-20 shrink-0">{TYPE_LABEL[h.type]}</span> | |
| 149 | + <span className="min-w-0 flex-1"> | |
| 150 | + <span className="block truncate">{h.title}</span> | |
| 151 | + {h.subtitle ? <span className="block truncate text-[12px] text-ink-3">{h.subtitle}</span> : null} | |
| 152 | + </span> | |
| 153 | + <span className="ci-mono hidden text-[10.5px] text-ink-4 sm:inline">{h.match}</span> | |
| 154 | + {i === active ? <CornerDownLeft className="h-3.5 w-3.5 text-ink-3" aria-hidden /> : null} | |
| 155 | + </li> | |
| 156 | + ))} | |
| 157 | + {q.trim().length >= 2 && !loading && hits.length === 0 ? <li className="px-3 py-4 text-[13.5px] text-ink-3">No matching entity. Press Enter for full-text search.</li> : null} | |
| 158 | + {q.trim().length < 2 ? <li className="px-3 py-4 text-[13px] text-ink-3">Type at least two characters. Ordering: exact name, alias, prefix, then fuzzy match.</li> : null} | |
| 159 | + </ul> | |
| 160 | + <p className="sr-only" aria-live="polite"> | |
| 161 | + {hits.length} results | |
| 162 | + </p> | |
| 163 | + </div> | |
| 164 | + </div> | |
| 165 | + ) : null} | |
| 166 | + </> | |
| 167 | + ); | |
| 168 | +} | |
added
apps/web/src/components/layout/mobile-nav.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Menu } from 'lucide-react'; | |
| 3 | +import { NAV, FOOTER_NAV } from '@/lib/site'; | |
| 4 | + | |
| 5 | +/** CSS-only disclosure menu for small screens (no client JS). */ | |
| 6 | +export function MobileNav() { | |
| 7 | + return ( | |
| 8 | + <details className="relative lg:hidden"> | |
| 9 | + <summary className="inline-flex items-center border border-rule px-2 py-1.5 text-ink-2 hover:border-accent hover:text-accent" aria-label="Open menu"> | |
| 10 | + <Menu className="h-4 w-4" aria-hidden /> | |
| 11 | + </summary> | |
| 12 | + <nav aria-label="Mobile" className="absolute right-0 top-[calc(100%+6px)] z-40 w-56 border border-rule-strong bg-paper py-1 shadow-lg"> | |
| 13 | + {NAV.map((n) => ( | |
| 14 | + <Link key={n.href} href={n.href} className="block px-3 py-2 text-[14px] text-ink no-underline hover:bg-paper-2"> | |
| 15 | + {n.label} | |
| 16 | + </Link> | |
| 17 | + ))} | |
| 18 | + <div className="my-1 border-t border-rule" /> | |
| 19 | + {FOOTER_NAV.filter((f) => !NAV.some((n) => n.href === f.href)).map((n) => ( | |
| 20 | + <Link key={n.href} href={n.href} className="block px-3 py-1.5 text-[13px] text-ink-2 no-underline hover:bg-paper-2"> | |
| 21 | + {n.label} | |
| 22 | + </Link> | |
| 23 | + ))} | |
| 24 | + </nav> | |
| 25 | + </details> | |
| 26 | + ); | |
| 27 | +} | |
added
apps/web/src/components/layout/site-footer.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { DISCLAIMER, FOOTER_NAV, CONTACT_EMAIL } from '@/lib/site'; | |
| 3 | + | |
| 4 | +export function SiteFooter() { | |
| 5 | + return ( | |
| 6 | + <footer className="mt-16 border-t border-rule-strong bg-paper-2"> | |
| 7 | + <div className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6"> | |
| 8 | + <div className="grid gap-6 md:grid-cols-[1.4fr_1fr]"> | |
| 9 | + <div> | |
| 10 | + <p className="font-display text-lg">CancerIndex</p> | |
| 11 | + <p className="mt-2 max-w-2xl text-[13px] leading-relaxed text-ink-2">{DISCLAIMER}</p> | |
| 12 | + <p className="mt-2 max-w-2xl text-[12.5px] leading-relaxed text-ink-3"> | |
| 13 | + Every number links to its source and retrieval date. Derived metrics carry a formula version. AI-generated synthesis is not enabled in this phase; all content is database-grounded. | |
| 14 | + </p> | |
| 15 | + </div> | |
| 16 | + <nav aria-label="Footer" className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]"> | |
| 17 | + {FOOTER_NAV.map((n) => ( | |
| 18 | + <Link key={n.href} href={n.href} className="text-ink-2 no-underline hover:text-accent"> | |
| 19 | + {n.label} | |
| 20 | + </Link> | |
| 21 | + ))} | |
| 22 | + <a href={`mailto:${CONTACT_EMAIL}`} className="text-ink-2 no-underline hover:text-accent"> | |
| 23 | + Report a correction | |
| 24 | + </a> | |
| 25 | + <Link href="/healthz" className="text-ink-2 no-underline hover:text-accent"> | |
| 26 | + Status | |
| 27 | + </Link> | |
| 28 | + </nav> | |
| 29 | + </div> | |
| 30 | + <p className="mt-6 text-[11.5px] text-ink-3">© {new Date().getUTCFullYear()} CancerIndex. Source data remains under the license of each provider — see /sources.</p> | |
| 31 | + </div> | |
| 32 | + </footer> | |
| 33 | + ); | |
| 34 | +} | |
added
apps/web/src/components/layout/site-header.tsx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { NAV } from '@/lib/site'; | |
| 3 | +import { CommandPalette } from './command-palette'; | |
| 4 | +import { MobileNav } from './mobile-nav'; | |
| 5 | + | |
| 6 | +export function SiteHeader() { | |
| 7 | + return ( | |
| 8 | + <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur-sm" style={{ height: 'var(--ci-header-h)' }}> | |
| 9 | + <div className="mx-auto flex h-full max-w-[1400px] items-center gap-4 px-4 sm:px-6"> | |
| 10 | + <Link href="/" className="flex items-baseline gap-2 no-underline"> | |
| 11 | + <span className="font-display text-[20px] font-medium tracking-tight text-ink">CancerIndex</span> | |
| 12 | + <span className="hidden text-[11px] uppercase tracking-[0.08em] text-ink-3 md:inline">the global index of cancer</span> | |
| 13 | + </Link> | |
| 14 | + <nav aria-label="Primary" className="ml-2 hidden flex-1 items-center gap-4 lg:flex"> | |
| 15 | + {NAV.map((n) => ( | |
| 16 | + <Link key={n.href} href={n.href} className="text-[13.5px] text-ink-2 no-underline hover:text-accent"> | |
| 17 | + {n.label} | |
| 18 | + </Link> | |
| 19 | + ))} | |
| 20 | + </nav> | |
| 21 | + <div className="ml-auto flex items-center gap-2"> | |
| 22 | + <CommandPalette /> | |
| 23 | + <MobileNav /> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + </header> | |
| 27 | + ); | |
| 28 | +} | |
added
apps/web/src/components/ui/badge.tsx
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | + | |
| 3 | +type Tone = 'neutral' | 'accent' | 'warn' | 'danger' | 'ok' | 'outline'; | |
| 4 | + | |
| 5 | +const TONES: Record<Tone, string> = { | |
| 6 | + neutral: 'bg-paper-3 text-ink-2 border-transparent', | |
| 7 | + accent: 'bg-accent-soft text-accent-2 border-transparent', | |
| 8 | + warn: 'bg-warn-soft text-warn border-transparent', | |
| 9 | + danger: 'bg-danger-soft text-danger border-transparent', | |
| 10 | + ok: 'bg-ok-soft text-ok border-transparent', | |
| 11 | + outline: 'bg-transparent text-ink-2 border-rule-strong', | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export function Badge({ children, tone = 'neutral', title, className = '', mono = false }: { children: ReactNode; tone?: Tone; title?: string; className?: string; mono?: boolean }) { | |
| 15 | + return ( | |
| 16 | + <span title={title} className={`inline-flex items-center gap-1 rounded-sm border px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide ${mono ? 'ci-mono' : ''} ${TONES[tone]} ${className}`}> | |
| 17 | + {children} | |
| 18 | + </span> | |
| 19 | + ); | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Scientific safety label (CLAUDE.md §3). The seven categories are never merged. | |
| 24 | + */ | |
| 25 | +export type ClaimKind = 'observed' | 'published' | 'curated' | 'regulatory' | 'guideline' | 'computed' | 'ai'; | |
| 26 | + | |
| 27 | +const CLAIM: Record<ClaimKind, { label: string; title: string; tone: Tone }> = { | |
| 28 | + observed: { label: 'Observed', title: 'Observed data — registry or measured counts reported by the source', tone: 'neutral' }, | |
| 29 | + published: { label: 'Published', title: 'Published evidence — peer-reviewed literature or trial registration', tone: 'neutral' }, | |
| 30 | + curated: { label: 'Curated', title: 'Curated evidence — expert-curated knowledge base (e.g. CIViC, NCIt)', tone: 'neutral' }, | |
| 31 | + regulatory: { label: 'Regulatory', title: 'Regulatory status — approval or label decision by a named authority and jurisdiction', tone: 'neutral' }, | |
| 32 | + guideline: { label: 'Guideline', title: 'Clinical guideline statement', tone: 'neutral' }, | |
| 33 | + computed: { label: 'Computed', title: 'Computed metric — derived by CancerIndex with a versioned formula', tone: 'accent' }, | |
| 34 | + ai: { label: 'AI-generated', title: 'AI-generated synthesis — not enabled in Phase 1', tone: 'warn' }, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export function ClaimBadge({ kind, className = '' }: { kind: ClaimKind; className?: string }) { | |
| 38 | + const c = CLAIM[kind]; | |
| 39 | + return ( | |
| 40 | + <Badge tone={c.tone} title={c.title} className={className}> | |
| 41 | + {c.label} | |
| 42 | + </Badge> | |
| 43 | + ); | |
| 44 | +} | |
| 45 | + | |
| 46 | +export function claimKindFromCategory(cat: string | null | undefined): ClaimKind { | |
| 47 | + switch (cat) { | |
| 48 | + case 'observed_data': | |
| 49 | + return 'observed'; | |
| 50 | + case 'published_evidence': | |
| 51 | + return 'published'; | |
| 52 | + case 'regulatory_status': | |
| 53 | + return 'regulatory'; | |
| 54 | + case 'clinical_guideline': | |
| 55 | + return 'guideline'; | |
| 56 | + case 'computed_metric': | |
| 57 | + return 'computed'; | |
| 58 | + case 'ai_generated_synthesis': | |
| 59 | + return 'ai'; | |
| 60 | + default: | |
| 61 | + return 'curated'; | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA'; | |
| 66 | + | |
| 67 | +export function ConfidenceBadge({ level, className = '' }: { level: string | null | undefined; className?: string }) { | |
| 68 | + const l = (level ?? 'INSUFFICIENT_DATA').toUpperCase() as Confidence; | |
| 69 | + const map: Record<Confidence, { tone: Tone; label: string; title: string }> = { | |
| 70 | + HIGH: { tone: 'ok', label: 'High confidence', title: 'Observed data from a primary source with complete coverage for the scope' }, | |
| 71 | + MEDIUM: { tone: 'neutral', label: 'Medium confidence', title: 'Estimated, modelled or partially covered data' }, | |
| 72 | + LOW: { tone: 'warn', label: 'Low confidence', title: 'Small denominators, heterogeneous definitions or indirect mapping' }, | |
| 73 | + INSUFFICIENT_DATA: { tone: 'outline', label: 'Insufficient data', title: 'Not enough data to assign a confidence level' }, | |
| 74 | + }; | |
| 75 | + const m = map[l] ?? map.INSUFFICIENT_DATA; | |
| 76 | + return ( | |
| 77 | + <Badge tone={m.tone} title={m.title} className={className}> | |
| 78 | + {m.label} | |
| 79 | + </Badge> | |
| 80 | + ); | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** Entity-mapping confidence (CLAUDE.md §221). */ | |
| 84 | +export function MatchBadge({ matchType, className = '' }: { matchType: string | null | undefined; className?: string }) { | |
| 85 | + const m = matchType ?? 'UNRESOLVED'; | |
| 86 | + const tone: Tone = m === 'EXACT_IDENTIFIER' || m === 'CURATED_EXACT' || m === 'ONTOLOGY_EXACT' ? 'ok' : m === 'UNRESOLVED' ? 'danger' : m === 'PROBABILISTIC' ? 'warn' : 'neutral'; | |
| 87 | + const titles: Record<string, string> = { | |
| 88 | + EXACT_IDENTIFIER: 'Mapped through a shared identifier (e.g. NCIt code)', | |
| 89 | + CURATED_EXACT: 'Curated one-to-one mapping', | |
| 90 | + ONTOLOGY_EXACT: 'Exact match through an ontology cross-reference', | |
| 91 | + CURATED_BROADER: 'Mapped to a broader concept by a curator', | |
| 92 | + CURATED_NARROWER: 'Mapped to a narrower concept by a curator', | |
| 93 | + ALIAS: 'Matched on a known alias after normalization', | |
| 94 | + PROBABILISTIC: 'Probabilistic string match — treat with caution', | |
| 95 | + UNRESOLVED: 'Not mapped to a CancerIndex entity', | |
| 96 | + }; | |
| 97 | + return ( | |
| 98 | + <Badge tone={tone} mono title={titles[m] ?? m} className={className}> | |
| 99 | + {m} | |
| 100 | + </Badge> | |
| 101 | + ); | |
| 102 | +} | |
| 103 | + | |
| 104 | +export function StatusBadge({ status, className = '' }: { status: string | null | undefined; className?: string }) { | |
| 105 | + const s = (status ?? 'unknown').toLowerCase(); | |
| 106 | + const tone: Tone = ['healthy', 'succeeded', 'active', 'approved', 'recruiting', 'accepted', 'validated'].includes(s) | |
| 107 | + ? 'ok' | |
| 108 | + : ['failed', 'failing', 'blocked', 'retracted', 'withdrawn', 'rejected', 'aborted'].includes(s) | |
| 109 | + ? 'danger' | |
| 110 | + : ['degraded', 'partial', 'review', 'awaiting_credentials', 'restricted', 'paused', 'candidate', 'submitted'].includes(s) | |
| 111 | + ? 'warn' | |
| 112 | + : 'neutral'; | |
| 113 | + return ( | |
| 114 | + <Badge tone={tone} className={className}> | |
| 115 | + {s.replace(/_/g, ' ')} | |
| 116 | + </Badge> | |
| 117 | + ); | |
| 118 | +} | |
added
apps/web/src/components/ui/completeness.tsx
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +/** | |
| 2 | + * Completeness dots: one dot per data domain, filled when the entity has ≥1 record in it. | |
| 3 | + * Domains: taxonomy, epidemiology, survival, genomics, evidence, drugs, trials, literature. | |
| 4 | + */ | |
| 5 | +export const COMPLETENESS_DOMAINS = ['taxonomy', 'epidemiology', 'survival', 'genomics', 'evidence', 'drugs', 'trials', 'literature'] as const; | |
| 6 | + | |
| 7 | +export function CompletenessDots({ filled, className = '' }: { filled: Record<string, boolean | number>; className?: string }) { | |
| 8 | + const on = COMPLETENESS_DOMAINS.filter((d) => Boolean(filled[d])); | |
| 9 | + return ( | |
| 10 | + <span className={`ci-dots ${className}`} role="img" aria-label={`Data completeness: ${on.length} of ${COMPLETENESS_DOMAINS.length} domains — ${on.join(', ') || 'none'}`} title={COMPLETENESS_DOMAINS.map((d) => `${filled[d] ? '●' : '○'} ${d}`).join(' ')}> | |
| 11 | + {COMPLETENESS_DOMAINS.map((d) => ( | |
| 12 | + <span key={d} className={`ci-dot ${filled[d] ? 'on' : ''}`} /> | |
| 13 | + ))} | |
| 14 | + </span> | |
| 15 | + ); | |
| 16 | +} | |
added
apps/web/src/components/ui/empty-state.tsx
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | +import { Database } from 'lucide-react'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * "Data not yet available" block (CLAUDE.md §281-283, §337). Never a placeholder number. | |
| 6 | + * `knows` lists what CancerIndex does know instead, so the block is informative rather than blank. | |
| 7 | + */ | |
| 8 | +export function EmptyState({ | |
| 9 | + title = 'Data not yet available', | |
| 10 | + children, | |
| 11 | + knows, | |
| 12 | + compact = false, | |
| 13 | +}: { | |
| 14 | + title?: string; | |
| 15 | + children?: ReactNode; | |
| 16 | + knows?: Array<{ label: string; href?: string }>; | |
| 17 | + compact?: boolean; | |
| 18 | +}) { | |
| 19 | + return ( | |
| 20 | + <div role="status" className={`border border-dashed border-rule-strong bg-paper-2 ${compact ? 'px-3 py-2.5' : 'px-4 py-5'}`}> | |
| 21 | + <div className="flex items-start gap-2.5"> | |
| 22 | + <Database aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-ink-4" /> | |
| 23 | + <div className="min-w-0"> | |
| 24 | + <p className={`font-medium text-ink-2 ${compact ? 'text-[13px]' : 'text-sm'}`}>{title}</p> | |
| 25 | + {children ? <div className="mt-1 text-[13px] leading-relaxed text-ink-3">{children}</div> : null} | |
| 26 | + {knows && knows.length > 0 ? ( | |
| 27 | + <div className="mt-2 text-[12.5px] text-ink-3"> | |
| 28 | + <span className="ci-kicker mr-2">What CancerIndex knows</span> | |
| 29 | + <ul className="mt-1 flex flex-wrap gap-x-3 gap-y-1"> | |
| 30 | + {knows.map((k) => ( | |
| 31 | + <li key={k.label}> | |
| 32 | + {k.href ? ( | |
| 33 | + <a className="ci-link" href={k.href}> | |
| 34 | + {k.label} | |
| 35 | + </a> | |
| 36 | + ) : ( | |
| 37 | + k.label | |
| 38 | + )} | |
| 39 | + </li> | |
| 40 | + ))} | |
| 41 | + </ul> | |
| 42 | + </div> | |
| 43 | + ) : null} | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | +} | |
added
apps/web/src/components/ui/freshness.tsx
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +import { fmtDate, fmtDateTime, relativeTime } from '@/lib/format'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Freshness line under each data module: "Data updated <relative> · Source updated <date>". | |
| 5 | + * `dataUpdatedAt` is when CancerIndex last refreshed/ingested; `sourceUpdatedAt` is the upstream | |
| 6 | + * dataset date (version or published date). Both are optional and render "unknown" honestly. | |
| 7 | + */ | |
| 8 | +export function Freshness({ | |
| 9 | + dataUpdatedAt, | |
| 10 | + sourceUpdatedAt, | |
| 11 | + sourceVersion, | |
| 12 | + extra, | |
| 13 | + className = '', | |
| 14 | +}: { | |
| 15 | + dataUpdatedAt?: Date | string | null; | |
| 16 | + sourceUpdatedAt?: Date | string | null; | |
| 17 | + sourceVersion?: string | null; | |
| 18 | + extra?: string; | |
| 19 | + className?: string; | |
| 20 | +}) { | |
| 21 | + return ( | |
| 22 | + <p className={`mt-2 text-[12px] text-ink-3 ${className}`}> | |
| 23 | + <span title={dataUpdatedAt ? fmtDateTime(dataUpdatedAt) : undefined}>Data updated {dataUpdatedAt ? relativeTime(dataUpdatedAt) : 'unknown'}</span> | |
| 24 | + <span aria-hidden> · </span> | |
| 25 | + <span> | |
| 26 | + Source updated {sourceUpdatedAt ? fmtDate(sourceUpdatedAt) : sourceVersion ? <span className="ci-mono">{sourceVersion}</span> : 'unknown'} | |
| 27 | + </span> | |
| 28 | + {extra ? ( | |
| 29 | + <> | |
| 30 | + <span aria-hidden> · </span> | |
| 31 | + <span>{extra}</span> | |
| 32 | + </> | |
| 33 | + ) : null} | |
| 34 | + </p> | |
| 35 | + ); | |
| 36 | +} | |
added
apps/web/src/components/ui/json-view.tsx
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +/** Render a JSON object as a readable nested definition list (used for ranking inputs, cursors, drift). */ | |
| 2 | +export function JsonView({ data, depth = 0 }: { data: unknown; depth?: number }) { | |
| 3 | + if (data == null) return <span className="text-ink-4">null</span>; | |
| 4 | + if (typeof data !== 'object') { | |
| 5 | + return <span className={typeof data === 'number' ? 'ci-num ci-mono' : 'ci-mono'}>{String(data)}</span>; | |
| 6 | + } | |
| 7 | + if (Array.isArray(data)) { | |
| 8 | + if (data.length === 0) return <span className="text-ink-4">[]</span>; | |
| 9 | + return ( | |
| 10 | + <ol className="ml-3 list-decimal space-y-0.5 pl-3 text-[12.5px]"> | |
| 11 | + {data.map((v, i) => ( | |
| 12 | + <li key={i}> | |
| 13 | + <JsonView data={v} depth={depth + 1} /> | |
| 14 | + </li> | |
| 15 | + ))} | |
| 16 | + </ol> | |
| 17 | + ); | |
| 18 | + } | |
| 19 | + const entries = Object.entries(data as Record<string, unknown>); | |
| 20 | + if (entries.length === 0) return <span className="text-ink-4">{'{}'}</span>; | |
| 21 | + return ( | |
| 22 | + <dl className={`grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12.5px] ${depth > 0 ? 'ml-2 border-l border-rule pl-2' : ''}`}> | |
| 23 | + {entries.map(([k, v]) => ( | |
| 24 | + <div key={k} className="contents"> | |
| 25 | + <dt className="ci-mono text-ink-3">{k}</dt> | |
| 26 | + <dd className="min-w-0 break-words"> | |
| 27 | + <JsonView data={v} depth={depth + 1} /> | |
| 28 | + </dd> | |
| 29 | + </div> | |
| 30 | + ))} | |
| 31 | + </dl> | |
| 32 | + ); | |
| 33 | +} | |
added
apps/web/src/components/ui/pagination.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { fmtInt } from '@/lib/format'; | |
| 3 | + | |
| 4 | +export function Pagination({ page, pageSize, total, hrefFor }: { page: number; pageSize: number; total: number; hrefFor: (page: number) => string }) { | |
| 5 | + const pages = Math.max(1, Math.ceil(total / pageSize)); | |
| 6 | + const from = total === 0 ? 0 : (page - 1) * pageSize + 1; | |
| 7 | + const to = Math.min(total, page * pageSize); | |
| 8 | + return ( | |
| 9 | + <nav aria-label="Pagination" className="mt-3 flex flex-wrap items-center justify-between gap-2 text-[13px] text-ink-2"> | |
| 10 | + <p> | |
| 11 | + Showing <span className="ci-num">{fmtInt(from)}</span>–<span className="ci-num">{fmtInt(to)}</span> of <span className="ci-num">{fmtInt(total)}</span> | |
| 12 | + </p> | |
| 13 | + <div className="flex items-center gap-1"> | |
| 14 | + {page > 1 ? ( | |
| 15 | + <Link className="border border-rule px-2 py-1 hover:border-accent hover:text-accent" href={hrefFor(page - 1)} rel="prev"> | |
| 16 | + ← Previous | |
| 17 | + </Link> | |
| 18 | + ) : ( | |
| 19 | + <span className="border border-rule px-2 py-1 text-ink-4">← Previous</span> | |
| 20 | + )} | |
| 21 | + <span className="px-2"> | |
| 22 | + Page <span className="ci-num">{page}</span> / <span className="ci-num">{pages}</span> | |
| 23 | + </span> | |
| 24 | + {page < pages ? ( | |
| 25 | + <Link className="border border-rule px-2 py-1 hover:border-accent hover:text-accent" href={hrefFor(page + 1)} rel="next"> | |
| 26 | + Next → | |
| 27 | + </Link> | |
| 28 | + ) : ( | |
| 29 | + <span className="border border-rule px-2 py-1 text-ink-4">Next →</span> | |
| 30 | + )} | |
| 31 | + </div> | |
| 32 | + </nav> | |
| 33 | + ); | |
| 34 | +} | |
added
apps/web/src/components/ui/section.tsx
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +import type { ReactNode } from 'react'; | |
| 2 | + | |
| 3 | +/** Editorial section: kicker, serif title, optional description and right-aligned actions. */ | |
| 4 | +export function Section({ | |
| 5 | + id, | |
| 6 | + kicker, | |
| 7 | + title, | |
| 8 | + description, | |
| 9 | + actions, | |
| 10 | + children, | |
| 11 | + className = '', | |
| 12 | + level = 2, | |
| 13 | +}: { | |
| 14 | + id?: string; | |
| 15 | + kicker?: string; | |
| 16 | + title: ReactNode; | |
| 17 | + description?: ReactNode; | |
| 18 | + actions?: ReactNode; | |
| 19 | + children: ReactNode; | |
| 20 | + className?: string; | |
| 21 | + level?: 2 | 3; | |
| 22 | +}) { | |
| 23 | + const H = level === 3 ? 'h3' : 'h2'; | |
| 24 | + return ( | |
| 25 | + <section id={id} aria-labelledby={id ? `${id}-title` : undefined} className={`ci-rule pt-5 ${className}`}> | |
| 26 | + <div className="mb-3 flex flex-wrap items-end justify-between gap-2"> | |
| 27 | + <div className="min-w-0"> | |
| 28 | + {kicker ? <p className="ci-kicker mb-1">{kicker}</p> : null} | |
| 29 | + <H id={id ? `${id}-title` : undefined} className={level === 3 ? 'text-lg' : 'text-xl sm:text-2xl'}> | |
| 30 | + {title} | |
| 31 | + </H> | |
| 32 | + {description ? <p className="mt-1 max-w-3xl text-[13.5px] text-ink-2">{description}</p> : null} | |
| 33 | + </div> | |
| 34 | + {actions ? <div className="flex shrink-0 items-center gap-2 text-[13px]">{actions}</div> : null} | |
| 35 | + </div> | |
| 36 | + {children} | |
| 37 | + </section> | |
| 38 | + ); | |
| 39 | +} | |
| 40 | + | |
| 41 | +export function PageHeader({ kicker, title, lede, children, className = '' }: { kicker?: string; title: ReactNode; lede?: ReactNode; children?: ReactNode; className?: string }) { | |
| 42 | + return ( | |
| 43 | + <header className={`pb-4 pt-6 sm:pt-8 ${className}`}> | |
| 44 | + {kicker ? <p className="ci-kicker mb-2">{kicker}</p> : null} | |
| 45 | + <h1 className="text-3xl leading-tight sm:text-4xl">{title}</h1> | |
| 46 | + {lede ? <p className="mt-2 max-w-3xl text-[15px] leading-relaxed text-ink-2">{lede}</p> : null} | |
| 47 | + {children} | |
| 48 | + </header> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Definition list in two columns, dense. */ | |
| 53 | +export function KV({ items, className = '' }: { items: Array<{ k: ReactNode; v: ReactNode }>; className?: string }) { | |
| 54 | + const rows = items.filter((i) => i.v !== null && i.v !== undefined && i.v !== ''); | |
| 55 | + if (rows.length === 0) return null; | |
| 56 | + return ( | |
| 57 | + <dl className={`grid grid-cols-[minmax(110px,auto)_1fr] gap-x-4 gap-y-1.5 text-[13.5px] ${className}`}> | |
| 58 | + {rows.map((r, i) => ( | |
| 59 | + <div key={i} className="contents"> | |
| 60 | + <dt className="text-ink-3">{r.k}</dt> | |
| 61 | + <dd className="min-w-0 break-words text-ink">{r.v}</dd> | |
| 62 | + </div> | |
| 63 | + ))} | |
| 64 | + </dl> | |
| 65 | + ); | |
| 66 | +} | |
| 67 | + | |
| 68 | +export function Note({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'warn' }) { | |
| 69 | + return <p className={`border-l-2 pl-3 text-[12.5px] leading-relaxed ${tone === 'warn' ? 'border-warn text-warn' : 'border-rule-strong text-ink-3'}`}>{children}</p>; | |
| 70 | +} | |
added
apps/web/src/components/ui/source-badge.tsx
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { ExternalLink } from 'lucide-react'; | |
| 3 | +import { fmtDate } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export interface ProvenanceInfo { | |
| 6 | + sourceSlug: string; | |
| 7 | + sourceName?: string | null; | |
| 8 | + dataset?: string | null; | |
| 9 | + datasetVersion?: string | null; | |
| 10 | + retrievedAt?: Date | string | null; | |
| 11 | + sourceUrl?: string | null; | |
| 12 | + layer?: 'raw' | 'normalized' | 'canonical' | 'derived' | 'ranked'; | |
| 13 | + license?: string | null; | |
| 14 | + evidenceType?: string | null; | |
| 15 | + pmid?: string | null; | |
| 16 | + ingestRunId?: string | null; | |
| 17 | + note?: string | null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Small source badge; hover / focus reveals the provenance popover (source, dataset, version, | |
| 22 | + * retrieved date, raw vs normalized, link). CSS-only so it works inside server components. | |
| 23 | + */ | |
| 24 | +export function SourceBadge({ p, className = '' }: { p: ProvenanceInfo; className?: string }) { | |
| 25 | + return ( | |
| 26 | + <span className={`ci-pop ${className}`}> | |
| 27 | + <Link | |
| 28 | + href={`/source/${p.sourceSlug}`} | |
| 29 | + className="ci-mono inline-flex items-center rounded-sm border border-rule-strong bg-paper px-1 py-[0px] text-[10.5px] leading-4 text-ink-2 no-underline hover:border-accent hover:text-accent focus:border-accent" | |
| 30 | + aria-label={`Source: ${p.sourceName ?? p.sourceSlug}. Open provenance.`} | |
| 31 | + > | |
| 32 | + {p.sourceSlug} | |
| 33 | + </Link> | |
| 34 | + <span className="ci-pop-panel" role="tooltip"> | |
| 35 | + <span className="ci-kicker block">Provenance</span> | |
| 36 | + <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5"> | |
| 37 | + <dt className="text-ink-3">Source</dt> | |
| 38 | + <dd>{p.sourceName ?? p.sourceSlug}</dd> | |
| 39 | + {p.dataset ? ( | |
| 40 | + <> | |
| 41 | + <dt className="text-ink-3">Dataset</dt> | |
| 42 | + <dd>{p.dataset}</dd> | |
| 43 | + </> | |
| 44 | + ) : null} | |
| 45 | + {p.datasetVersion ? ( | |
| 46 | + <> | |
| 47 | + <dt className="text-ink-3">Version</dt> | |
| 48 | + <dd className="ci-mono">{p.datasetVersion}</dd> | |
| 49 | + </> | |
| 50 | + ) : null} | |
| 51 | + {p.retrievedAt ? ( | |
| 52 | + <> | |
| 53 | + <dt className="text-ink-3">Retrieved</dt> | |
| 54 | + <dd>{fmtDate(p.retrievedAt)}</dd> | |
| 55 | + </> | |
| 56 | + ) : null} | |
| 57 | + <dt className="text-ink-3">Layer</dt> | |
| 58 | + <dd>{p.layer === 'derived' || p.layer === 'ranked' ? `${p.layer} (computed by CancerIndex)` : p.layer === 'raw' ? 'raw (as published)' : `${p.layer ?? 'normalized'} (units and labels harmonized; values unchanged)`}</dd> | |
| 59 | + {p.evidenceType ? ( | |
| 60 | + <> | |
| 61 | + <dt className="text-ink-3">Evidence</dt> | |
| 62 | + <dd>{p.evidenceType.replace(/_/g, ' ')}</dd> | |
| 63 | + </> | |
| 64 | + ) : null} | |
| 65 | + {p.license ? ( | |
| 66 | + <> | |
| 67 | + <dt className="text-ink-3">License</dt> | |
| 68 | + <dd>{p.license}</dd> | |
| 69 | + </> | |
| 70 | + ) : null} | |
| 71 | + {p.pmid ? ( | |
| 72 | + <> | |
| 73 | + <dt className="text-ink-3">PMID</dt> | |
| 74 | + <dd className="ci-mono">{p.pmid}</dd> | |
| 75 | + </> | |
| 76 | + ) : null} | |
| 77 | + {p.ingestRunId ? ( | |
| 78 | + <> | |
| 79 | + <dt className="text-ink-3">Run</dt> | |
| 80 | + <dd className="ci-mono break-all">{p.ingestRunId}</dd> | |
| 81 | + </> | |
| 82 | + ) : null} | |
| 83 | + </dl> | |
| 84 | + {p.note ? <span className="mt-1 block text-ink-3">{p.note}</span> : null} | |
| 85 | + {p.sourceUrl ? ( | |
| 86 | + <a className="ci-link mt-1.5 inline-flex items-center gap-1" href={p.sourceUrl} target="_blank" rel="noopener noreferrer"> | |
| 87 | + Open at source <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 88 | + </a> | |
| 89 | + ) : null} | |
| 90 | + </span> | |
| 91 | + </span> | |
| 92 | + ); | |
| 93 | +} | |
added
apps/web/src/components/ui/tabs.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | + | |
| 3 | +export interface TabDef { | |
| 4 | + key: string; | |
| 5 | + label: string; | |
| 6 | + href: string; | |
| 7 | + count?: number | null; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Sticky, horizontally scrollable tab bar (server component; the current tab is a route segment). */ | |
| 11 | +export function Tabs({ tabs, current, ariaLabel }: { tabs: TabDef[]; current: string; ariaLabel: string }) { | |
| 12 | + return ( | |
| 13 | + <nav aria-label={ariaLabel} className="ci-tabs -mx-4 px-4 sm:-mx-6 sm:px-6"> | |
| 14 | + <div className="flex"> | |
| 15 | + {tabs.map((t) => ( | |
| 16 | + <Link key={t.key} href={t.href} className="ci-tab" aria-current={t.key === current ? 'page' : undefined} scroll={false}> | |
| 17 | + {t.label} | |
| 18 | + {t.count != null && t.count > 0 ? <span className="ci-num ml-1 text-[11px] text-ink-3">{t.count}</span> : null} | |
| 19 | + </Link> | |
| 20 | + ))} | |
| 21 | + </div> | |
| 22 | + </nav> | |
| 23 | + ); | |
| 24 | +} | |
added
apps/web/src/components/ui/value.tsx
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +import { fmtValue, unitLabel } from '@/lib/format'; | |
| 2 | +import { SourceBadge, type ProvenanceInfo } from './source-badge'; | |
| 3 | +import { ClaimBadge, type ClaimKind } from './badge'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Every numeric value on the site renders through <Value>: number + unit, source badge with | |
| 7 | + * provenance popover, and a population/period caption (CLAUDE.md UI rules). | |
| 8 | + */ | |
| 9 | +export function Value({ | |
| 10 | + value, | |
| 11 | + unit, | |
| 12 | + provenance, | |
| 13 | + caption, | |
| 14 | + claim, | |
| 15 | + size = 'md', | |
| 16 | + ci, | |
| 17 | + estimateType, | |
| 18 | + className = '', | |
| 19 | +}: { | |
| 20 | + value: number | string | null | undefined; | |
| 21 | + unit?: string | null; | |
| 22 | + provenance?: ProvenanceInfo | null; | |
| 23 | + caption?: string | null; | |
| 24 | + claim?: ClaimKind; | |
| 25 | + size?: 'sm' | 'md' | 'lg'; | |
| 26 | + ci?: [number | null | undefined, number | null | undefined]; | |
| 27 | + estimateType?: string | null; | |
| 28 | + className?: string; | |
| 29 | +}) { | |
| 30 | + const sizeCls = size === 'lg' ? 'text-2xl font-display' : size === 'sm' ? 'text-[13.5px]' : 'text-base'; | |
| 31 | + const hasCi = ci && ci[0] != null && ci[1] != null; | |
| 32 | + return ( | |
| 33 | + <span className={`inline-flex flex-col items-start ${className}`}> | |
| 34 | + <span className="inline-flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5"> | |
| 35 | + <span className={`ci-num ${sizeCls} text-ink`}>{fmtValue(value, unit)}</span> | |
| 36 | + {unit && unit !== 'probability' ? <span className="text-[11.5px] text-ink-3">{unitLabel(unit)}</span> : null} | |
| 37 | + {hasCi ? ( | |
| 38 | + <span className="ci-num text-[11.5px] text-ink-3" title="95% confidence interval as published by the source"> | |
| 39 | + ({fmtValue(ci![0], unit)}–{fmtValue(ci![1], unit)}) | |
| 40 | + </span> | |
| 41 | + ) : null} | |
| 42 | + {estimateType && estimateType !== 'observed' ? <span className="text-[11px] italic text-warn">{estimateType}</span> : null} | |
| 43 | + {provenance ? <SourceBadge p={provenance} /> : null} | |
| 44 | + {claim ? <ClaimBadge kind={claim} /> : null} | |
| 45 | + </span> | |
| 46 | + {caption ? <span className="text-[11.5px] leading-4 text-ink-3">{caption}</span> : null} | |
| 47 | + </span> | |
| 48 | + ); | |
| 49 | +} | |
added
apps/web/src/lib/admin/actions.ts
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { redirect } from 'next/navigation'; | |
| 5 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 6 | +import { db, sql } from '@/lib/db'; | |
| 7 | +import { isAdmin } from './auth'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Admin mutations (server actions). Every mutation writes audit_log (§348). | |
| 11 | + * Resolve: add alias on the target entity, mark the label mapped, log. Reject: mark rejected, log. | |
| 12 | + */ | |
| 13 | +export type ActionResult = { ok: true; message: string } | { ok: false; message: string }; | |
| 14 | + | |
| 15 | +async function guard(): Promise<ActionResult | null> { | |
| 16 | + if (!(await isAdmin())) return { ok: false, message: 'Not authorized' }; | |
| 17 | + return null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export async function resolveUnresolved(formData: FormData): Promise<ActionResult> { | |
| 21 | + const denied = await guard(); | |
| 22 | + if (denied) return denied; | |
| 23 | + const id = Number(formData.get('id')); | |
| 24 | + const targetId = String(formData.get('targetId') ?? '').trim(); | |
| 25 | + const reason = String(formData.get('reason') ?? '').trim() || null; | |
| 26 | + const actor = 'admin'; | |
| 27 | + if (!Number.isFinite(id) || !/^CI-[A-Z]+-\d{8,}$/.test(targetId)) return { ok: false, message: 'Invalid id or target' }; | |
| 28 | + | |
| 29 | + try { | |
| 30 | + await db().transaction(async (tx) => { | |
| 31 | + const [row] = await tx.execute<{ id: number; entity_kind: string; source_text: string; normalized: string; source_id: string; status: string }>(sql`SELECT id, entity_kind, source_text, normalized, source_id, status FROM unresolved_labels WHERE id = ${id} FOR UPDATE`); | |
| 32 | + if (!row) throw new Error('label not found'); | |
| 33 | + if (row.entity_kind === 'cancer') { | |
| 34 | + const [c] = await tx.execute<{ id: string }>(sql`SELECT id FROM cancers WHERE id = ${targetId}`); | |
| 35 | + if (!c) throw new Error('target cancer not found'); | |
| 36 | + await tx.execute(sql`INSERT INTO cancer_aliases (cancer_id, alias, normalized, alias_type, source_id, source_terminology) VALUES (${targetId}, ${row.source_text}, ${normalizeLabel(row.source_text)}, 'synonym', ${row.source_id}, 'curator') ON CONFLICT DO NOTHING`); | |
| 37 | + } else if (row.entity_kind === 'drug') { | |
| 38 | + const [d] = await tx.execute<{ id: string }>(sql`SELECT id FROM drugs WHERE id = ${targetId}`); | |
| 39 | + if (!d) throw new Error('target drug not found'); | |
| 40 | + await tx.execute(sql`INSERT INTO drug_aliases (drug_id, alias, normalized, alias_type, source_id) VALUES (${targetId}, ${row.source_text}, ${normalizeLabel(row.source_text)}, 'synonym', ${row.source_id}) ON CONFLICT DO NOTHING`); | |
| 41 | + } else if (row.entity_kind === 'gene') { | |
| 42 | + const [g] = await tx.execute<{ id: string }>(sql`SELECT id FROM genes WHERE id = ${targetId}`); | |
| 43 | + if (!g) throw new Error('target gene not found'); | |
| 44 | + await tx.execute(sql`INSERT INTO gene_aliases (gene_id, alias, alias_type, source_id) VALUES (${targetId}, ${row.source_text}, 'alias_symbol', ${row.source_id}) ON CONFLICT DO NOTHING`); | |
| 45 | + } else { | |
| 46 | + throw new Error(`unsupported entity kind ${row.entity_kind}`); | |
| 47 | + } | |
| 48 | + await tx.execute(sql`UPDATE unresolved_labels SET status = 'mapped', resolved_id = ${targetId}, resolved_by = ${actor}, updated_at = now() WHERE id = ${id}`); | |
| 49 | + await tx.execute(sql`INSERT INTO audit_log (actor, action, entity_type, entity_id, before, after, reason) VALUES (${actor}, 'unresolved.resolve', 'unresolved_label', ${String(id)}, ${JSON.stringify({ status: row.status, sourceText: row.source_text })}::jsonb, ${JSON.stringify({ status: 'mapped', resolvedId: targetId, aliasAdded: true })}::jsonb, ${reason})`); | |
| 50 | + }); | |
| 51 | + revalidatePath('/admin/unresolved'); | |
| 52 | + return { ok: true, message: `Label #${id} mapped to ${targetId}` }; | |
| 53 | + } catch (e) { | |
| 54 | + return { ok: false, message: (e as Error).message }; | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +export async function rejectUnresolved(formData: FormData): Promise<ActionResult> { | |
| 59 | + const denied = await guard(); | |
| 60 | + if (denied) return denied; | |
| 61 | + const id = Number(formData.get('id')); | |
| 62 | + const reason = String(formData.get('reason') ?? '').trim() || null; | |
| 63 | + const status = String(formData.get('status') ?? 'rejected') === 'ignored' ? 'ignored' : 'rejected'; | |
| 64 | + if (!Number.isFinite(id)) return { ok: false, message: 'Invalid id' }; | |
| 65 | + try { | |
| 66 | + await db().transaction(async (tx) => { | |
| 67 | + const [row] = await tx.execute<{ status: string; source_text: string }>(sql`SELECT status, source_text FROM unresolved_labels WHERE id = ${id} FOR UPDATE`); | |
| 68 | + if (!row) throw new Error('label not found'); | |
| 69 | + await tx.execute(sql`UPDATE unresolved_labels SET status = ${status}, resolved_by = 'admin', updated_at = now() WHERE id = ${id}`); | |
| 70 | + await tx.execute(sql`INSERT INTO audit_log (actor, action, entity_type, entity_id, before, after, reason) VALUES ('admin', ${`unresolved.${status}`}, 'unresolved_label', ${String(id)}, ${JSON.stringify({ status: row.status, sourceText: row.source_text })}::jsonb, ${JSON.stringify({ status })}::jsonb, ${reason})`); | |
| 71 | + }); | |
| 72 | + revalidatePath('/admin/unresolved'); | |
| 73 | + return { ok: true, message: `Label #${id} marked ${status}` }; | |
| 74 | + } catch (e) { | |
| 75 | + return { ok: false, message: (e as Error).message }; | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** Form-compatible wrappers: run the mutation, then redirect back with the outcome in the URL. */ | |
| 80 | +export async function resolveAction(formData: FormData): Promise<void> { | |
| 81 | + const r = await resolveUnresolved(formData); | |
| 82 | + redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`); | |
| 83 | +} | |
| 84 | + | |
| 85 | +export async function rejectAction(formData: FormData): Promise<void> { | |
| 86 | + const r = await rejectUnresolved(formData); | |
| 87 | + redirect(`/admin/unresolved?${new URLSearchParams({ ok: r.ok ? '1' : '0', msg: r.message }).toString()}`); | |
| 88 | +} | |
added
apps/web/src/lib/admin/auth.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { cookies } from 'next/headers'; | |
| 3 | +import { createHash, timingSafeEqual } from 'node:crypto'; | |
| 4 | + | |
| 5 | +export const ADMIN_COOKIE = 'ci_admin'; | |
| 6 | + | |
| 7 | +function expected(): string | null { | |
| 8 | + const t = process.env.ADMIN_TOKEN; | |
| 9 | + return t && t.length > 0 ? t : null; | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** Cookie value = sha256(token) so the raw token never sits in the browser. */ | |
| 13 | +export function cookieValueFor(token: string): string { | |
| 14 | + return createHash('sha256').update(token).digest('hex'); | |
| 15 | +} | |
| 16 | + | |
| 17 | +function safeEqual(a: string, b: string): boolean { | |
| 18 | + const ab = Buffer.from(a); | |
| 19 | + const bb = Buffer.from(b); | |
| 20 | + return ab.length === bb.length && timingSafeEqual(ab, bb); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function tokenMatches(token: string | null | undefined): boolean { | |
| 24 | + const exp = expected(); | |
| 25 | + return !!exp && !!token && safeEqual(token, exp); | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** True when the request carries a valid admin cookie (§140-141). */ | |
| 29 | +export async function isAdmin(): Promise<boolean> { | |
| 30 | + const exp = expected(); | |
| 31 | + if (!exp) return false; | |
| 32 | + const c = (await cookies()).get(ADMIN_COOKIE)?.value; | |
| 33 | + return !!c && safeEqual(c, cookieValueFor(exp)); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export function adminConfigured(): boolean { | |
| 37 | + return expected() != null; | |
| 38 | +} | |
added
apps/web/src/lib/db.ts
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { getDb, getSql, closeDb, schema } from '@cancerindex/database'; | |
| 3 | + | |
| 4 | +/** Server-side database handle for server components, route handlers and server actions. */ | |
| 5 | +export const db = () => getDb(); | |
| 6 | +export { getDb, getSql, closeDb, schema }; | |
| 7 | +export type { Database } from '@cancerindex/database'; | |
| 8 | +export { sql, eq, and, or, desc, asc, gte, lte, lt, gt, inArray, isNull, isNotNull, ilike, count, ne } from 'drizzle-orm'; | |
| 9 | +import type { SQL } from 'drizzle-orm'; | |
| 10 | + | |
| 11 | +// Tables re-exported explicitly (webpack handles `export *` chains, but explicit names keep the web | |
| 12 | +// bundle's dependency graph obvious and typecheck failures local). | |
| 13 | +export const { | |
| 14 | + cancers, | |
| 15 | + cancerAliases, | |
| 16 | + cancerHierarchy, | |
| 17 | + cancerCodes, | |
| 18 | + anatomicalSites, | |
| 19 | + cancerAnatomy, | |
| 20 | + geographies, | |
| 21 | + sources, | |
| 22 | + ingestRuns, | |
| 23 | + connectorCursors, | |
| 24 | + sourceRecords, | |
| 25 | + provenance, | |
| 26 | + unresolvedLabels, | |
| 27 | + changeEvents, | |
| 28 | + auditLog, | |
| 29 | + genes, | |
| 30 | + geneAliases, | |
| 31 | + variants, | |
| 32 | + variantClinicalSignificance, | |
| 33 | + genomicCohorts, | |
| 34 | + cancerGeneFrequencies, | |
| 35 | + drugs, | |
| 36 | + drugAliases, | |
| 37 | + drugApprovals, | |
| 38 | + clinicalTrials, | |
| 39 | + trialConditions, | |
| 40 | + trialInterventions, | |
| 41 | + trialLocations, | |
| 42 | + publications, | |
| 43 | + publicationEntityEdges, | |
| 44 | + literatureCounts, | |
| 45 | + civicEvidenceItems, | |
| 46 | + knowledgeEdges, | |
| 47 | + epidemiologyObservations, | |
| 48 | + survivalObservations, | |
| 49 | + metricDefinitions, | |
| 50 | + rankingSnapshots, | |
| 51 | + rankings, | |
| 52 | + entityCounters, | |
| 53 | +} = schema; | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * Run a query and swallow "relation does not exist" style errors so pages render an EmptyState | |
| 57 | + * instead of crashing when a table has not been created on this environment yet. | |
| 58 | + */ | |
| 59 | +export async function safe<T>(fn: () => Promise<T>, fallback: T): Promise<T> { | |
| 60 | + try { | |
| 61 | + return await fn(); | |
| 62 | + } catch (err) { | |
| 63 | + if (process.env.NODE_ENV !== 'production') console.error('[cancerindex/web] query failed:', (err as Error).message); | |
| 64 | + return fallback; | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** Execute a raw SQL query and return plain typed rows (drops the RowList metadata wrapper). */ | |
| 69 | +export async function run<T>(query: SQL): Promise<T[]> { | |
| 70 | + const res = await getDb().execute(query); | |
| 71 | + return Array.from(res as unknown as Iterable<T>); | |
| 72 | +} | |
added
apps/web/src/lib/format.ts
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +/** Formatting helpers — pure, shared by server and client components. */ | |
| 2 | + | |
| 3 | +const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); | |
| 4 | +const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); | |
| 5 | +const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); | |
| 6 | +const nf3 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 3 }); | |
| 7 | + | |
| 8 | +export function fmtInt(n: number | string | null | undefined): string { | |
| 9 | + if (n == null || n === '') return '—'; | |
| 10 | + const v = Number(n); | |
| 11 | + return Number.isFinite(v) ? nf0.format(v) : '—'; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function fmtNum(n: number | string | null | undefined, digits = 1): string { | |
| 15 | + if (n == null || n === '') return '—'; | |
| 16 | + const v = Number(n); | |
| 17 | + if (!Number.isFinite(v)) return '—'; | |
| 18 | + const f = digits <= 0 ? nf0 : digits === 1 ? nf1 : digits === 2 ? nf2 : nf3; | |
| 19 | + return f.format(v); | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function fmtPct(p: number | null | undefined, digits = 1): string { | |
| 23 | + if (p == null || !Number.isFinite(p)) return '—'; | |
| 24 | + return `${fmtNum(p * 100, digits)}%`; | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** Format a value according to the unit vocabulary used by metric_definitions / observations. */ | |
| 28 | +export function fmtValue(value: number | string | null | undefined, unit: string | null | undefined): string { | |
| 29 | + if (value == null) return '—'; | |
| 30 | + const v = Number(value); | |
| 31 | + if (!Number.isFinite(v)) return '—'; | |
| 32 | + switch (unit) { | |
| 33 | + case 'count': | |
| 34 | + return fmtInt(v); | |
| 35 | + case 'per_100k': | |
| 36 | + return fmtNum(v, 1); | |
| 37 | + case 'ratio': | |
| 38 | + return fmtNum(v, 2); | |
| 39 | + case 'probability': | |
| 40 | + return fmtPct(v, 1); | |
| 41 | + case 'percentile_points': | |
| 42 | + return `${v > 0 ? '+' : ''}${fmtNum(v, 1)}`; | |
| 43 | + default: | |
| 44 | + return Math.abs(v) >= 1000 ? fmtInt(v) : fmtNum(v, 2); | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function unitLabel(unit: string | null | undefined): string { | |
| 49 | + switch (unit) { | |
| 50 | + case 'count': | |
| 51 | + return 'count'; | |
| 52 | + case 'per_100k': | |
| 53 | + return 'per 100,000'; | |
| 54 | + case 'ratio': | |
| 55 | + return 'ratio'; | |
| 56 | + case 'probability': | |
| 57 | + return '%'; | |
| 58 | + case 'percentile_points': | |
| 59 | + return 'percentile points'; | |
| 60 | + default: | |
| 61 | + return unit ?? ''; | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** | |
| 66 | + * Coerce a driver value to a Date. Drizzle's postgres-js adapter returns timestamps from raw | |
| 67 | + * `execute()` as strings ("2026-09-08 05:07:09.317-04"), so every date field may be a string. | |
| 68 | + */ | |
| 69 | +export function toDate(v: Date | string | number | null | undefined): Date | null { | |
| 70 | + if (v == null || v === '') return null; | |
| 71 | + if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v; | |
| 72 | + if (typeof v === 'number') return new Date(v); | |
| 73 | + let s = v.trim(); | |
| 74 | + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) s = `${s}T00:00:00Z`; | |
| 75 | + else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) { | |
| 76 | + s = s.replace(' ', 'T'); | |
| 77 | + const m = /([+-]\d{2})$/.exec(s); | |
| 78 | + if (m) s = `${s}:00`; | |
| 79 | + else if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) s = `${s}Z`; | |
| 80 | + } | |
| 81 | + const d = new Date(s); | |
| 82 | + return Number.isNaN(d.getTime()) ? null : d; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export function isoDate(v: Date | string | null | undefined): string { | |
| 86 | + const d = toDate(v); | |
| 87 | + return d ? d.toISOString().slice(0, 10) : '—'; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export function fmtDate(d: Date | string | null | undefined, opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }): string { | |
| 91 | + if (!d) return '—'; | |
| 92 | + const date = toDate(d); | |
| 93 | + if (!date) return typeof d === 'string' ? d : '—'; | |
| 94 | + return new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', ...opts }).format(date); | |
| 95 | +} | |
| 96 | + | |
| 97 | +export function fmtDateTime(d: Date | string | null | undefined): string { | |
| 98 | + return fmtDate(d, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }); | |
| 99 | +} | |
| 100 | + | |
| 101 | +export function relativeTime(d: Date | string | null | undefined, now = new Date()): string { | |
| 102 | + const date = toDate(d); | |
| 103 | + if (!date) return 'unknown'; | |
| 104 | + const diff = (now.getTime() - date.getTime()) / 1000; | |
| 105 | + const abs = Math.abs(diff); | |
| 106 | + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); | |
| 107 | + const sign = diff >= 0 ? -1 : 1; | |
| 108 | + if (abs < 60) return 'just now'; | |
| 109 | + if (abs < 3600) return rtf.format(sign * Math.round(abs / 60), 'minute'); | |
| 110 | + if (abs < 86400) return rtf.format(sign * Math.round(abs / 3600), 'hour'); | |
| 111 | + if (abs < 86400 * 30) return rtf.format(sign * Math.round(abs / 86400), 'day'); | |
| 112 | + if (abs < 86400 * 365) return rtf.format(sign * Math.round(abs / (86400 * 30)), 'month'); | |
| 113 | + return rtf.format(sign * Math.round(abs / (86400 * 365)), 'year'); | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function fmtDuration(ms: number | null | undefined): string { | |
| 117 | + if (ms == null) return '—'; | |
| 118 | + if (ms < 1000) return `${ms} ms`; | |
| 119 | + const s = ms / 1000; | |
| 120 | + if (s < 90) return `${fmtNum(s, 1)} s`; | |
| 121 | + const m = s / 60; | |
| 122 | + if (m < 90) return `${fmtNum(m, 1)} min`; | |
| 123 | + return `${fmtNum(m / 60, 1)} h`; | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** Human labels for enumerations stored as UPPER_SNAKE / lower_snake. */ | |
| 127 | +export function humanize(s: string | null | undefined): string { | |
| 128 | + if (!s) return '—'; | |
| 129 | + return s | |
| 130 | + .replace(/_/g, ' ') | |
| 131 | + .toLowerCase() | |
| 132 | + .replace(/\b(\w)/g, (c) => c.toUpperCase()) | |
| 133 | + .replace(/\bNa\b/, 'N/A') | |
| 134 | + .replace(/\bNos\b/, 'NOS'); | |
| 135 | +} | |
| 136 | + | |
| 137 | +export function phaseLabel(p: string): string { | |
| 138 | + const map: Record<string, string> = { EARLY_PHASE1: 'Early Phase 1', PHASE1: 'Phase 1', PHASE2: 'Phase 2', PHASE3: 'Phase 3', PHASE4: 'Phase 4', NA: 'N/A' }; | |
| 139 | + return map[p] ?? humanize(p); | |
| 140 | +} | |
| 141 | + | |
| 142 | +export function truncate(s: string | null | undefined, n: number): string { | |
| 143 | + if (!s) return ''; | |
| 144 | + if (s.length <= n) return s; | |
| 145 | + const cut = s.slice(0, n); | |
| 146 | + const i = cut.lastIndexOf(' '); | |
| 147 | + return `${cut.slice(0, i > n * 0.6 ? i : n).trimEnd()}…`; | |
| 148 | +} | |
| 149 | + | |
| 150 | +export function pluralize(n: number, one: string, many = `${one}s`): string { | |
| 151 | + return n === 1 ? one : many; | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** Parse the ranking scope key "geo=WORLD|sex=all|age=all|year=latest|level=top" into a record. */ | |
| 155 | +export function parseScopeKey(key: string): Record<string, string> { | |
| 156 | + const out: Record<string, string> = {}; | |
| 157 | + for (const part of key.split('|')) { | |
| 158 | + const [k, v] = part.split('='); | |
| 159 | + if (k && v != null) out[k] = v; | |
| 160 | + } | |
| 161 | + return out; | |
| 162 | +} | |
| 163 | + | |
| 164 | +export function scopeLabel(key: string): string { | |
| 165 | + const s = parseScopeKey(key); | |
| 166 | + const bits: string[] = []; | |
| 167 | + bits.push(s.geo === 'WORLD' ? 'World' : (s.geo ?? '?')); | |
| 168 | + if (s.sex && s.sex !== 'all') bits.push(humanize(s.sex)); | |
| 169 | + else bits.push('both sexes'); | |
| 170 | + if (s.age && s.age !== 'all') bits.push(`ages ${s.age}`); | |
| 171 | + else bits.push('all ages'); | |
| 172 | + bits.push(s.year && s.year !== 'latest' ? s.year : 'latest'); | |
| 173 | + bits.push(s.level === 'top' ? 'top-level cancers' : s.level === 'all' ? 'all malignant entities' : `${s.level} level`); | |
| 174 | + return bits.join(' · '); | |
| 175 | +} | |
added
apps/web/src/lib/queries/admin.ts
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface AdminOverview { | |
| 5 | + tables: Array<{ table: string; n: number }>; | |
| 6 | + runs24h: number; | |
| 7 | + failedRuns7d: number; | |
| 8 | + openUnresolved: number; | |
| 9 | + currentSnapshots: number; | |
| 10 | + auditRecent: Array<{ id: number; actor: string; action: string; entity_type: string | null; entity_id: string | null; reason: string | null; created_at: Date }>; | |
| 11 | +} | |
| 12 | + | |
| 13 | +const COUNTED_TABLES = ['cancers', 'cancer_aliases', 'cancer_hierarchy', 'cancer_codes', 'anatomical_sites', 'geographies', 'sources', 'ingest_runs', 'source_records', 'provenance', 'genes', 'variants', 'drugs', 'drug_approvals', 'clinical_trials', 'trial_conditions', 'publications', 'literature_counts', 'civic_evidence_items', 'genomic_cohorts', 'cancer_gene_frequencies', 'epidemiology_observations', 'survival_observations', 'ranking_snapshots', 'rankings', 'entity_counters', 'unresolved_labels', 'change_events', 'audit_log']; | |
| 14 | + | |
| 15 | +export async function adminOverview(): Promise<AdminOverview> { | |
| 16 | + const tables = await safe( | |
| 17 | + () => run<{ table: string; n: string }>(sql.join(COUNTED_TABLES.map((t) => sql`SELECT ${t}::text AS "table", (SELECT count(*) FROM ${sql.identifier(t)}) AS n`), sql` UNION ALL `)), | |
| 18 | + [] as Array<{ table: string; n: string }>, | |
| 19 | + ); | |
| 20 | + const [misc] = await safe( | |
| 21 | + () => | |
| 22 | + run<{ runs24h: string; failed7d: string; open_unresolved: string; snaps: string }>(sql` | |
| 23 | + SELECT (SELECT count(*) FROM ingest_runs WHERE started_at > now() - interval '24 hours') AS runs24h, | |
| 24 | + (SELECT count(*) FROM ingest_runs WHERE status IN ('failed','aborted') AND started_at > now() - interval '7 days') AS failed7d, | |
| 25 | + (SELECT count(*) FROM unresolved_labels WHERE status = 'open') AS open_unresolved, | |
| 26 | + (SELECT count(*) FROM ranking_snapshots WHERE is_current) AS snaps`), | |
| 27 | + [{ runs24h: '0', failed7d: '0', open_unresolved: '0', snaps: '0' }], | |
| 28 | + ); | |
| 29 | + const auditRecent = await safe(() => run<AdminOverview['auditRecent'][number]>(sql`SELECT id, actor, action, entity_type, entity_id, reason, created_at FROM audit_log ORDER BY created_at DESC LIMIT 15`), []); | |
| 30 | + return { tables: tables.map((t) => ({ table: t.table, n: Number(t.n) })), runs24h: Number(misc?.runs24h ?? 0), failedRuns7d: Number(misc?.failed7d ?? 0), openUnresolved: Number(misc?.open_unresolved ?? 0), currentSnapshots: Number(misc?.snaps ?? 0), auditRecent }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface UnresolvedRow { | |
| 34 | + id: number; | |
| 35 | + source_id: string; | |
| 36 | + source_slug: string; | |
| 37 | + entity_kind: string; | |
| 38 | + source_text: string; | |
| 39 | + normalized: string; | |
| 40 | + context: Record<string, unknown>; | |
| 41 | + count: number; | |
| 42 | + status: string; | |
| 43 | + suggested_id: string | null; | |
| 44 | + suggested_name: string | null; | |
| 45 | + suggested_match_type: string | null; | |
| 46 | + suggested_score: number | null; | |
| 47 | + resolved_id: string | null; | |
| 48 | + resolved_by: string | null; | |
| 49 | + created_at: Date; | |
| 50 | + updated_at: Date; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export async function listUnresolved(opts: { entityKind: string; status: string; page: number; pageSize: number }): Promise<{ rows: UnresolvedRow[]; total: number; kinds: Array<{ k: string; n: number }> }> { | |
| 54 | + const where = sql`${opts.entityKind ? sql`u.entity_kind = ${opts.entityKind}` : sql`true`} AND ${opts.status ? sql`u.status = ${opts.status}` : sql`true`}`; | |
| 55 | + const total = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM unresolved_labels u WHERE ${where}`), [{ n: '0' }]); | |
| 56 | + const rows = await safe( | |
| 57 | + () => | |
| 58 | + run<UnresolvedRow>(sql` | |
| 59 | + SELECT u.*, s.slug AS source_slug, | |
| 60 | + CASE u.entity_kind WHEN 'cancer' THEN (SELECT canonical_name FROM cancers WHERE id = u.suggested_id) WHEN 'drug' THEN (SELECT name FROM drugs WHERE id = u.suggested_id) WHEN 'gene' THEN (SELECT symbol FROM genes WHERE id = u.suggested_id) END AS suggested_name | |
| 61 | + FROM unresolved_labels u JOIN sources s ON s.id = u.source_id WHERE ${where} ORDER BY u.count DESC, u.updated_at DESC LIMIT ${opts.pageSize} OFFSET ${(opts.page - 1) * opts.pageSize}`), | |
| 62 | + [] as UnresolvedRow[], | |
| 63 | + ); | |
| 64 | + const kinds = await safe(() => run<{ k: string; n: string }>(sql`SELECT entity_kind AS k, count(*) AS n FROM unresolved_labels WHERE status = 'open' GROUP BY 1 ORDER BY n DESC`), [] as Array<{ k: string; n: string }>); | |
| 65 | + return { rows, total: Number(total[0]?.n ?? 0), kinds: kinds.map((k) => ({ k: k.k, n: Number(k.n) })) }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export interface ConnectorHealthRow { | |
| 69 | + slug: string; | |
| 70 | + name: string; | |
| 71 | + category: string; | |
| 72 | + status: string; | |
| 73 | + license_status: string; | |
| 74 | + health: string | null; | |
| 75 | + health_detail: string | null; | |
| 76 | + paused: boolean | null; | |
| 77 | + last_success_at: Date | null; | |
| 78 | + last_attempt_at: Date | null; | |
| 79 | + last_run_id: string | null; | |
| 80 | + last_run_status: string | null; | |
| 81 | + records_fetched: number | null; | |
| 82 | + records_created: number | null; | |
| 83 | + records_updated: number | null; | |
| 84 | + records_rejected: number | null; | |
| 85 | + schema_drift: unknown[] | null; | |
| 86 | + runs_total: number; | |
| 87 | + drift_fields: number; | |
| 88 | +} | |
| 89 | +export async function connectorHealth(): Promise<ConnectorHealthRow[]> { | |
| 90 | + return safe( | |
| 91 | + () => | |
| 92 | + run<ConnectorHealthRow>(sql` | |
| 93 | + SELECT s.slug, s.name, s.category, s.status, s.license_status, cc.health, cc.health_detail, cc.paused, cc.last_success_at, cc.last_attempt_at, | |
| 94 | + lr.id AS last_run_id, lr.status AS last_run_status, lr.records_fetched, lr.records_created, lr.records_updated, lr.records_rejected, lr.schema_drift, | |
| 95 | + (SELECT count(*) FROM ingest_runs r WHERE r.source_id = s.id)::int AS runs_total, | |
| 96 | + (SELECT count(*) FROM connector_field_stats f WHERE f.connector_id = s.slug)::int AS drift_fields | |
| 97 | + FROM sources s LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug | |
| 98 | + LEFT JOIN LATERAL (SELECT * FROM ingest_runs r WHERE r.source_id = s.id ORDER BY r.started_at DESC LIMIT 1) lr ON true | |
| 99 | + ORDER BY s.tier, s.name`), | |
| 100 | + [] as ConnectorHealthRow[], | |
| 101 | + ); | |
| 102 | +} | |
added
apps/web/src/lib/queries/cancers.ts
+318 −0
@@ -0,0 +1,318 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface CancerCore { | |
| 5 | + id: string; | |
| 6 | + slug: string; | |
| 7 | + canonical_name: string; | |
| 8 | + short_name: string | null; | |
| 9 | + entity_type: string; | |
| 10 | + malignant: boolean; | |
| 11 | + solid_tumor: boolean; | |
| 12 | + hematologic: boolean; | |
| 13 | + pediatric_relevant: boolean; | |
| 14 | + rare_cancer: boolean | null; | |
| 15 | + top_level: boolean; | |
| 16 | + description: string | null; | |
| 17 | + description_provenance_id: number | null; | |
| 18 | + primary_ncit_code: string | null; | |
| 19 | + primary_oncotree_code: string | null; | |
| 20 | + depth: number; | |
| 21 | + status: string; | |
| 22 | + merged_into: string | null; | |
| 23 | + deprecated_reason: string | null; | |
| 24 | + classification_version: string | null; | |
| 25 | + semantic_types: string[]; | |
| 26 | + created_at: Date; | |
| 27 | + updated_at: Date; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export interface Counters { | |
| 31 | + trial_count: number; | |
| 32 | + active_trial_count: number; | |
| 33 | + recruiting_trial_count: number; | |
| 34 | + phase3_trial_count: number; | |
| 35 | + publication_count: number; | |
| 36 | + publication_count_5y: number; | |
| 37 | + publication_count_12m: number; | |
| 38 | + gene_count: number; | |
| 39 | + variant_count: number; | |
| 40 | + drug_count: number; | |
| 41 | + approved_drug_count: number; | |
| 42 | + evidence_count: number; | |
| 43 | + cohort_count: number; | |
| 44 | + subtype_count: number; | |
| 45 | + descendant_count: number; | |
| 46 | + epidemiology_obs_count: number; | |
| 47 | + survival_obs_count: number; | |
| 48 | + completeness: Record<string, number>; | |
| 49 | + updated_at: Date; // schema field `computedAt` is declared with the updatedAt() helper → column `updated_at` | |
| 50 | +} | |
| 51 | + | |
| 52 | +export const ENTITY_TYPES = ['cancer', 'cancer_family', 'histology', 'subtype', 'molecular_subtype', 'hematologic_malignancy', 'precursor_condition', 'other'] as const; | |
| 53 | +export const SORTS = ['name', 'trials', 'publications', 'evidence', 'descendants'] as const; | |
| 54 | +export type Sort = (typeof SORTS)[number]; | |
| 55 | + | |
| 56 | +export interface ExplorerFilters { | |
| 57 | + q: string; | |
| 58 | + level: 'top' | 'all'; | |
| 59 | + entityType: string; | |
| 60 | + malignant: boolean | null; | |
| 61 | + hematologic: boolean | null; | |
| 62 | + pediatric: boolean | null; | |
| 63 | + site: string; // anatomical site slug | |
| 64 | + sort: Sort; | |
| 65 | + page: number; | |
| 66 | + pageSize: number; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface ExplorerRow extends CancerCore { | |
| 70 | + parents: Array<{ slug: string; name: string }> | null; | |
| 71 | + active_trial_count: number | null; | |
| 72 | + publication_count_5y: number | null; | |
| 73 | + evidence_count: number | null; | |
| 74 | + descendant_count: number | null; | |
| 75 | + epidemiology_obs_count: number | null; | |
| 76 | + survival_obs_count: number | null; | |
| 77 | + gene_count: number | null; | |
| 78 | + drug_count: number | null; | |
| 79 | + child_count: number; | |
| 80 | +} | |
| 81 | + | |
| 82 | +function explorerWhere(f: ExplorerFilters) { | |
| 83 | + const parts = [sql`c.status = 'active'`]; | |
| 84 | + if (f.level === 'top') parts.push(sql`c.top_level`); | |
| 85 | + if (f.q) { | |
| 86 | + const q = f.q.trim(); | |
| 87 | + parts.push(sql`(c.canonical_name ILIKE ${'%' + q + '%'} OR c.slug ILIKE ${'%' + q + '%'} OR c.primary_oncotree_code ILIKE ${q} OR c.primary_ncit_code ILIKE ${q} OR c.id = ${q} | |
| 88 | + OR EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND a.alias ILIKE ${'%' + q + '%'}) | |
| 89 | + OR EXISTS (SELECT 1 FROM cancer_codes k WHERE k.cancer_id = c.id AND k.code ILIKE ${q}))`); | |
| 90 | + } | |
| 91 | + if (f.entityType) parts.push(sql`c.entity_type = ${f.entityType}`); | |
| 92 | + if (f.malignant != null) parts.push(sql`c.malignant = ${f.malignant}`); | |
| 93 | + if (f.hematologic != null) parts.push(sql`c.hematologic = ${f.hematologic}`); | |
| 94 | + if (f.pediatric != null) parts.push(sql`c.pediatric_relevant = ${f.pediatric}`); | |
| 95 | + if (f.site) parts.push(sql`EXISTS (SELECT 1 FROM cancer_anatomy ca JOIN anatomical_sites s ON s.id = ca.site_id WHERE ca.cancer_id = c.id AND s.slug = ${f.site})`); | |
| 96 | + return sql.join(parts, sql` AND `); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function explorerCount(f: ExplorerFilters): Promise<number> { | |
| 100 | + const rows = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM cancers c WHERE ${explorerWhere(f)}`), [{ n: '0' }]); | |
| 101 | + return Number(rows[0]?.n ?? 0); | |
| 102 | +} | |
| 103 | + | |
| 104 | +export async function explorerRows(f: ExplorerFilters): Promise<ExplorerRow[]> { | |
| 105 | + const order = | |
| 106 | + f.sort === 'trials' | |
| 107 | + ? sql`coalesce(ec.active_trial_count, 0) DESC, c.canonical_name` | |
| 108 | + : f.sort === 'publications' | |
| 109 | + ? sql`coalesce(ec.publication_count_5y, 0) DESC, c.canonical_name` | |
| 110 | + : f.sort === 'evidence' | |
| 111 | + ? sql`coalesce(ec.evidence_count, 0) DESC, c.canonical_name` | |
| 112 | + : f.sort === 'descendants' | |
| 113 | + ? sql`coalesce(ec.descendant_count, child.n) DESC, c.canonical_name` | |
| 114 | + : sql`c.canonical_name`; | |
| 115 | + return safe( | |
| 116 | + () => | |
| 117 | + run<ExplorerRow>(sql` | |
| 118 | + SELECT c.*, ec.active_trial_count, ec.publication_count_5y, ec.evidence_count, ec.descendant_count, ec.epidemiology_obs_count, ec.survival_obs_count, ec.gene_count, ec.drug_count, | |
| 119 | + child.n AS child_count, | |
| 120 | + (SELECT json_agg(json_build_object('slug', p.slug, 'name', p.canonical_name) ORDER BY p.canonical_name) | |
| 121 | + FROM (SELECT DISTINCT pc.slug, pc.canonical_name FROM cancer_hierarchy h JOIN cancers pc ON pc.id = h.parent_id WHERE h.child_id = c.id LIMIT 3) p) AS parents | |
| 122 | + FROM cancers c | |
| 123 | + LEFT JOIN entity_counters ec ON ec.entity_type = 'cancer' AND ec.entity_id = c.id | |
| 124 | + LEFT JOIN LATERAL (SELECT count(*) AS n FROM cancer_hierarchy h WHERE h.parent_id = c.id) child ON true | |
| 125 | + WHERE ${explorerWhere(f)} | |
| 126 | + ORDER BY ${order} | |
| 127 | + LIMIT ${f.pageSize} OFFSET ${(f.page - 1) * f.pageSize}`), | |
| 128 | + [] as ExplorerRow[], | |
| 129 | + ); | |
| 130 | +} | |
| 131 | + | |
| 132 | +export async function listAnatomicalSites(): Promise<Array<{ id: string; slug: string; name: string; system: string | null; n: number }>> { | |
| 133 | + const rows = await safe( | |
| 134 | + () => run<{ id: string; slug: string; name: string; system: string | null; n: string }>(sql`SELECT s.id, s.slug, s.name, s.system, (SELECT count(*) FROM cancer_anatomy ca WHERE ca.site_id = s.id) AS n FROM anatomical_sites s ORDER BY s.name`), | |
| 135 | + [] as Array<{ id: string; slug: string; name: string; system: string | null; n: string }>, | |
| 136 | + ); | |
| 137 | + return rows.map((r) => ({ ...r, n: Number(r.n) })); | |
| 138 | +} | |
| 139 | + | |
| 140 | +// ---------- Detail ---------- | |
| 141 | + | |
| 142 | +export async function getCancerBySlug(slug: string): Promise<CancerCore | null> { | |
| 143 | + const rows = await safe(() => run<CancerCore>(sql`SELECT * FROM cancers WHERE slug = ${slug} LIMIT 1`), [] as CancerCore[]); | |
| 144 | + return rows[0] ?? null; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export async function getCancerById(id: string): Promise<CancerCore | null> { | |
| 148 | + const rows = await safe(() => run<CancerCore>(sql`SELECT * FROM cancers WHERE id = ${id} LIMIT 1`), [] as CancerCore[]); | |
| 149 | + return rows[0] ?? null; | |
| 150 | +} | |
| 151 | + | |
| 152 | +export async function getCounters(cancerId: string): Promise<Counters | null> { | |
| 153 | + const rows = await safe(() => run<Counters>(sql`SELECT * FROM entity_counters WHERE entity_type = 'cancer' AND entity_id = ${cancerId}`), [] as Counters[]); | |
| 154 | + return rows[0] ?? null; | |
| 155 | +} | |
| 156 | + | |
| 157 | +export interface CodeRow { | |
| 158 | + system: string; | |
| 159 | + code: string; | |
| 160 | + match_type: string; | |
| 161 | + source_slug: string | null; | |
| 162 | +} | |
| 163 | +export async function getCodes(cancerId: string): Promise<CodeRow[]> { | |
| 164 | + return safe(() => run<CodeRow>(sql`SELECT k.system, k.code, k.match_type, s.slug AS source_slug FROM cancer_codes k LEFT JOIN sources s ON s.id = k.source_id WHERE k.cancer_id = ${cancerId} ORDER BY k.system, k.code`), [] as CodeRow[]); | |
| 165 | +} | |
| 166 | + | |
| 167 | +export interface AliasRow { | |
| 168 | + alias: string; | |
| 169 | + alias_type: string; | |
| 170 | + source_terminology: string | null; | |
| 171 | + source_slug: string | null; | |
| 172 | +} | |
| 173 | +export async function getAliases(cancerId: string): Promise<AliasRow[]> { | |
| 174 | + return safe(() => run<AliasRow>(sql`SELECT a.alias, a.alias_type, a.source_terminology, s.slug AS source_slug FROM cancer_aliases a LEFT JOIN sources s ON s.id = a.source_id WHERE a.cancer_id = ${cancerId} ORDER BY a.alias_type, a.alias`), [] as AliasRow[]); | |
| 175 | +} | |
| 176 | + | |
| 177 | +export interface RelRow { | |
| 178 | + id: string; | |
| 179 | + slug: string; | |
| 180 | + canonical_name: string; | |
| 181 | + entity_type: string; | |
| 182 | + hierarchy_type: string; | |
| 183 | + malignant: boolean; | |
| 184 | + child_count: number; | |
| 185 | +} | |
| 186 | +export async function getParents(cancerId: string): Promise<RelRow[]> { | |
| 187 | + return safe( | |
| 188 | + () => | |
| 189 | + run<RelRow>(sql` | |
| 190 | + SELECT p.id, p.slug, p.canonical_name, p.entity_type, h.hierarchy_type, p.malignant, (SELECT count(*) FROM cancer_hierarchy x WHERE x.parent_id = p.id)::int AS child_count | |
| 191 | + FROM cancer_hierarchy h JOIN cancers p ON p.id = h.parent_id WHERE h.child_id = ${cancerId} ORDER BY h.hierarchy_type, p.canonical_name`), | |
| 192 | + [] as RelRow[], | |
| 193 | + ); | |
| 194 | +} | |
| 195 | +export async function getChildren(cancerId: string, limit = 500): Promise<RelRow[]> { | |
| 196 | + return safe( | |
| 197 | + () => | |
| 198 | + run<RelRow>(sql` | |
| 199 | + SELECT c.id, c.slug, c.canonical_name, c.entity_type, h.hierarchy_type, c.malignant, (SELECT count(*) FROM cancer_hierarchy x WHERE x.parent_id = c.id)::int AS child_count | |
| 200 | + FROM cancer_hierarchy h JOIN cancers c ON c.id = h.child_id WHERE h.parent_id = ${cancerId} ORDER BY h.hierarchy_type, c.canonical_name LIMIT ${limit}`), | |
| 201 | + [] as RelRow[], | |
| 202 | + ); | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** Shortest path from a root (node without parent) to this cancer, preferring NCIt, then OncoTree. */ | |
| 206 | +export async function getBreadcrumbPath(cancerId: string): Promise<Array<{ id: string; slug: string; canonical_name: string }>> { | |
| 207 | + const rows = await safe( | |
| 208 | + () => | |
| 209 | + run<{ path_ids: string[]; hierarchy_type: string }>(sql` | |
| 210 | + WITH RECURSIVE up AS ( | |
| 211 | + SELECT c.id, ARRAY[c.id]::varchar[] AS path_ids, NULL::text AS hierarchy_type, 0 AS depth FROM cancers c WHERE c.id = ${cancerId} | |
| 212 | + UNION ALL | |
| 213 | + SELECT h.parent_id, h.parent_id || up.path_ids, coalesce(up.hierarchy_type, h.hierarchy_type), up.depth + 1 | |
| 214 | + FROM up JOIN cancer_hierarchy h ON h.child_id = up.id | |
| 215 | + WHERE up.depth < 14 AND NOT (h.parent_id = ANY(up.path_ids)) AND (up.hierarchy_type IS NULL OR h.hierarchy_type = up.hierarchy_type) | |
| 216 | + ) | |
| 217 | + SELECT path_ids, hierarchy_type FROM up | |
| 218 | + WHERE NOT EXISTS (SELECT 1 FROM cancer_hierarchy h2 WHERE h2.child_id = up.id AND (up.hierarchy_type IS NULL OR h2.hierarchy_type = up.hierarchy_type)) | |
| 219 | + ORDER BY (hierarchy_type = 'ncit') DESC, array_length(path_ids, 1) ASC LIMIT 1`), | |
| 220 | + [] as Array<{ path_ids: string[]; hierarchy_type: string }>, | |
| 221 | + ); | |
| 222 | + const ids = rows[0]?.path_ids ?? []; | |
| 223 | + if (ids.length <= 1) return []; | |
| 224 | + const nodes = await safe( | |
| 225 | + () => run<{ id: string; slug: string; canonical_name: string }>(sql`SELECT id, slug, canonical_name FROM cancers WHERE id IN (${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`), | |
| 226 | + [] as Array<{ id: string; slug: string; canonical_name: string }>, | |
| 227 | + ); | |
| 228 | + const byId = new Map(nodes.map((n) => [n.id, n])); | |
| 229 | + return ids.map((i) => byId.get(i)).filter((n): n is { id: string; slug: string; canonical_name: string } => !!n); | |
| 230 | +} | |
| 231 | + | |
| 232 | +/** All descendant ids (inclusive) across hierarchy types — used for trial/evidence roll-ups. */ | |
| 233 | +export async function getDescendantIds(cancerId: string): Promise<string[]> { | |
| 234 | + const rows = await safe( | |
| 235 | + () => | |
| 236 | + run<{ id: string }>(sql` | |
| 237 | + WITH RECURSIVE d AS ( | |
| 238 | + SELECT ${cancerId}::varchar AS id, 0 AS depth | |
| 239 | + UNION | |
| 240 | + SELECT h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.id WHERE d.depth < 12 | |
| 241 | + ) SELECT DISTINCT id FROM d`), | |
| 242 | + [{ id: cancerId }], | |
| 243 | + ); | |
| 244 | + return rows.map((r) => r.id); | |
| 245 | +} | |
| 246 | + | |
| 247 | +export interface AnatomyRow { | |
| 248 | + site_id: string; | |
| 249 | + slug: string; | |
| 250 | + name: string; | |
| 251 | + system: string | null; | |
| 252 | + relation: string; | |
| 253 | + ncit_code: string | null; | |
| 254 | +} | |
| 255 | +export async function getAnatomy(cancerId: string): Promise<AnatomyRow[]> { | |
| 256 | + return safe(() => run<AnatomyRow>(sql`SELECT s.id AS site_id, s.slug, s.name, s.system, ca.relation, s.ncit_code FROM cancer_anatomy ca JOIN anatomical_sites s ON s.id = ca.site_id WHERE ca.cancer_id = ${cancerId} ORDER BY ca.relation, s.name`), [] as AnatomyRow[]); | |
| 257 | +} | |
| 258 | + | |
| 259 | +export interface ChangeEvent { | |
| 260 | + id: number; | |
| 261 | + kind: string; | |
| 262 | + summary: string; | |
| 263 | + before: unknown; | |
| 264 | + after: unknown; | |
| 265 | + ingest_run_id: string | null; | |
| 266 | + created_at: Date; | |
| 267 | +} | |
| 268 | +export async function getChangeEvents(entityType: string, entityId: string, limit = 50): Promise<ChangeEvent[]> { | |
| 269 | + return safe(() => run<ChangeEvent>(sql`SELECT id, kind, summary, before, after, ingest_run_id, created_at FROM change_events WHERE entity_type = ${entityType} AND entity_id = ${entityId} ORDER BY created_at DESC LIMIT ${limit}`), [] as ChangeEvent[]); | |
| 270 | +} | |
| 271 | + | |
| 272 | +/** Sources (with run ids) that contributed anything to this cancer — union across tables. */ | |
| 273 | +export async function getContributingSources(cancerId: string, descendantIds: string[]): Promise<Array<{ slug: string; name: string; license_status: string; kinds: string[]; run_ids: string[]; last_retrieved: Date | null }>> { | |
| 274 | + const ids = sql.join(descendantIds.map((i) => sql`${i}`), sql`, `); | |
| 275 | + return safe( | |
| 276 | + () => | |
| 277 | + run<{ slug: string; name: string; license_status: string; kinds: string[]; run_ids: string[]; last_retrieved: Date | null }>(sql` | |
| 278 | + WITH contrib AS ( | |
| 279 | + SELECT k.source_id, 'codes' AS kind, NULL::text AS run_id, NULL::timestamptz AS at FROM cancer_codes k WHERE k.cancer_id = ${cancerId} AND k.source_id IS NOT NULL | |
| 280 | + UNION ALL SELECT a.source_id, 'aliases', NULL, NULL FROM cancer_aliases a WHERE a.cancer_id = ${cancerId} AND a.source_id IS NOT NULL | |
| 281 | + UNION ALL SELECT h.source_id, 'hierarchy', NULL, NULL FROM cancer_hierarchy h WHERE (h.child_id = ${cancerId} OR h.parent_id = ${cancerId}) AND h.source_id IS NOT NULL | |
| 282 | + UNION ALL SELECT r.source_id, 'source_records', r.last_seen_run, r.retrieved_at FROM source_records r WHERE r.canonical_type = 'cancer' AND r.canonical_id = ${cancerId} | |
| 283 | + UNION ALL SELECT o.source_id, 'epidemiology', o.ingest_run_id, o.updated_at FROM epidemiology_observations o WHERE o.cancer_id = ${cancerId} | |
| 284 | + UNION ALL SELECT o.source_id, 'survival', o.ingest_run_id, o.updated_at FROM survival_observations o WHERE o.cancer_id = ${cancerId} | |
| 285 | + UNION ALL SELECT p.source_id, 'evidence', e.ingest_run_id, e.updated_at FROM civic_evidence_items e JOIN provenance p ON p.id = e.provenance_id WHERE e.cancer_id IN (${ids}) | |
| 286 | + UNION ALL SELECT gc.source_id, 'genomic_cohorts', NULL, gc.updated_at FROM genomic_cohorts gc WHERE gc.cancer_id IN (${ids}) | |
| 287 | + UNION ALL SELECT p.source_id, 'literature_counts', p.ingest_run_id, lc.updated_at FROM literature_counts lc JOIN provenance p ON p.id = lc.provenance_id WHERE lc.cancer_id = ${cancerId} | |
| 288 | + UNION ALL SELECT da.source_id, 'approvals', NULL, da.updated_at FROM drug_approvals da WHERE da.cancer_id IN (${ids}) | |
| 289 | + UNION ALL SELECT p.source_id, 'description', p.ingest_run_id, p.retrieved_at FROM cancers c JOIN provenance p ON p.id = c.description_provenance_id WHERE c.id = ${cancerId} | |
| 290 | + ) | |
| 291 | + SELECT s.slug, s.name, s.license_status, array_agg(DISTINCT c.kind) AS kinds, array_remove(array_agg(DISTINCT c.run_id), NULL) AS run_ids, max(c.at) AS last_retrieved | |
| 292 | + FROM contrib c JOIN sources s ON s.id = c.source_id GROUP BY s.id ORDER BY s.name`), | |
| 293 | + [], | |
| 294 | + ); | |
| 295 | +} | |
| 296 | + | |
| 297 | +/** Top-level ranking set: TOP_LEVEL_CANCERS resolved through NCIt codes (or the top_level flag). */ | |
| 298 | +export async function resolveTopLevel(ncitCodes: string[]): Promise<Map<string, { id: string; slug: string; canonical_name: string; descendant_count: number | null; active_trial_count: number | null; publication_count_5y: number | null }>> { | |
| 299 | + if (ncitCodes.length === 0) return new Map(); | |
| 300 | + const rows = await safe( | |
| 301 | + () => | |
| 302 | + run<{ ncit: string; id: string; slug: string; canonical_name: string; descendant_count: number | null; active_trial_count: number | null; publication_count_5y: number | null }>(sql` | |
| 303 | + SELECT k.code AS ncit, c.id, c.slug, c.canonical_name, ec.descendant_count, ec.active_trial_count, ec.publication_count_5y | |
| 304 | + FROM cancer_codes k JOIN cancers c ON c.id = k.cancer_id | |
| 305 | + LEFT JOIN entity_counters ec ON ec.entity_type = 'cancer' AND ec.entity_id = c.id | |
| 306 | + WHERE k.system = 'ncit' AND k.code IN (${sql.join(ncitCodes.map((c) => sql`${c}`), sql`, `)}) AND c.status = 'active'`), | |
| 307 | + [], | |
| 308 | + ); | |
| 309 | + return new Map(rows.map((r) => [r.ncit, r])); | |
| 310 | +} | |
| 311 | + | |
| 312 | +export async function cancerSlugsForSitemap(offset: number, limit: number): Promise<Array<{ slug: string; updated_at: Date }>> { | |
| 313 | + return safe(() => run<{ slug: string; updated_at: Date }>(sql`SELECT slug, updated_at FROM cancers WHERE status = 'active' ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []); | |
| 314 | +} | |
| 315 | +export async function countActiveCancers(): Promise<number> { | |
| 316 | + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM cancers WHERE status = 'active'`), [{ n: '0' }]); | |
| 317 | + return Number(r[0]?.n ?? 0); | |
| 318 | +} | |
added
apps/web/src/lib/queries/drugs.ts
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface DrugRow { | |
| 5 | + id: string; | |
| 6 | + slug: string; | |
| 7 | + name: string; | |
| 8 | + kind: string | null; | |
| 9 | + ncit_code: string | null; | |
| 10 | + chembl_id: string | null; | |
| 11 | + civic_therapy_id: number | null; | |
| 12 | + drugbank_id: string | null; | |
| 13 | + pubchem_cid: string | null; | |
| 14 | + unii: string | null; | |
| 15 | + mechanism: string | null; | |
| 16 | + target_gene_ids: string[]; | |
| 17 | + development_status: string | null; | |
| 18 | + description: string | null; | |
| 19 | + updated_at: Date; | |
| 20 | + approval_count?: number; | |
| 21 | + evidence_count?: number; | |
| 22 | + trial_count?: number; | |
| 23 | + aliases?: string[] | null; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export async function getDrugBySlug(slug: string): Promise<DrugRow | null> { | |
| 27 | + const rows = await safe( | |
| 28 | + () => | |
| 29 | + run<DrugRow>(sql` | |
| 30 | + SELECT d.*, (SELECT count(*) FROM drug_approvals a WHERE a.drug_id = d.id)::int AS approval_count, | |
| 31 | + (SELECT count(*) FROM civic_evidence_items e WHERE d.id = ANY(e.therapy_ids))::int AS evidence_count, | |
| 32 | + (SELECT count(DISTINCT ti.trial_id) FROM trial_interventions ti WHERE ti.drug_id = d.id)::int AS trial_count, | |
| 33 | + (SELECT array_agg(DISTINCT a.alias ORDER BY a.alias) FROM drug_aliases a WHERE a.drug_id = d.id) AS aliases | |
| 34 | + FROM drugs d WHERE d.slug = ${slug} LIMIT 1`), | |
| 35 | + [] as DrugRow[], | |
| 36 | + ); | |
| 37 | + return rows[0] ?? null; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export async function listDrugs(opts: { q: string; kind: string; page: number; pageSize: number }): Promise<{ rows: DrugRow[]; total: number; kinds: Array<{ kind: string; n: number }> }> { | |
| 41 | + const where = sql`${opts.kind ? sql`d.kind = ${opts.kind}` : sql`true`} AND ${opts.q ? sql`(d.name ILIKE ${'%' + opts.q + '%'} OR EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND a.alias ILIKE ${'%' + opts.q + '%'}))` : sql`true`}`; | |
| 42 | + const total = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drugs d WHERE ${where}`), [{ n: '0' }]); | |
| 43 | + const rows = await safe( | |
| 44 | + () => | |
| 45 | + run<DrugRow>(sql` | |
| 46 | + SELECT d.*, (SELECT count(*) FROM drug_approvals a WHERE a.drug_id = d.id)::int AS approval_count, | |
| 47 | + (SELECT count(*) FROM civic_evidence_items e WHERE d.id = ANY(e.therapy_ids))::int AS evidence_count, | |
| 48 | + (SELECT count(DISTINCT ti.trial_id) FROM trial_interventions ti WHERE ti.drug_id = d.id)::int AS trial_count | |
| 49 | + FROM drugs d WHERE ${where} ORDER BY approval_count DESC, evidence_count DESC, d.name LIMIT ${opts.pageSize} OFFSET ${(opts.page - 1) * opts.pageSize}`), | |
| 50 | + [] as DrugRow[], | |
| 51 | + ); | |
| 52 | + const kinds = await safe(() => run<{ kind: string; n: string }>(sql`SELECT kind, count(*) AS n FROM drugs WHERE kind IS NOT NULL GROUP BY kind ORDER BY n DESC`), [] as Array<{ kind: string; n: string }>); | |
| 53 | + return { rows, total: Number(total[0]?.n ?? 0), kinds: kinds.map((k) => ({ kind: k.kind, n: Number(k.n) })) }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export interface ApprovalRow { | |
| 57 | + id: number; | |
| 58 | + drug_id: string; | |
| 59 | + drug_slug: string; | |
| 60 | + drug_name: string; | |
| 61 | + cancer_id: string | null; | |
| 62 | + cancer_slug: string | null; | |
| 63 | + cancer_name: string | null; | |
| 64 | + biomarker_ids: string[]; | |
| 65 | + tumor_agnostic: boolean; | |
| 66 | + jurisdiction: string; | |
| 67 | + authority: string; | |
| 68 | + indication: string; | |
| 69 | + line_of_therapy: string | null; | |
| 70 | + disease_stage: string | null; | |
| 71 | + approval_type: string | null; | |
| 72 | + accelerated: boolean | null; | |
| 73 | + conditional: boolean | null; | |
| 74 | + approval_date: string | null; | |
| 75 | + withdrawal_date: string | null; | |
| 76 | + status: string; | |
| 77 | + application_number: string | null; | |
| 78 | + source_slug: string; | |
| 79 | + source_name: string; | |
| 80 | + provenance_id: number; | |
| 81 | + updated_at: Date; | |
| 82 | +} | |
| 83 | + | |
| 84 | +const APPROVAL_SELECT = sql` | |
| 85 | + SELECT a.*, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name | |
| 86 | + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id`; | |
| 87 | + | |
| 88 | +export async function approvalsForDrug(drugId: string): Promise<ApprovalRow[]> { | |
| 89 | + return safe(() => run<ApprovalRow>(sql`${APPROVAL_SELECT} WHERE a.drug_id = ${drugId} ORDER BY a.jurisdiction, a.approval_date DESC NULLS LAST`), [] as ApprovalRow[]); | |
| 90 | +} | |
| 91 | + | |
| 92 | +export async function approvalsForCancer(cancerIds: string[]): Promise<ApprovalRow[]> { | |
| 93 | + if (cancerIds.length === 0) return []; | |
| 94 | + return safe(() => run<ApprovalRow>(sql`${APPROVAL_SELECT} WHERE a.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) OR a.tumor_agnostic ORDER BY d.name, a.jurisdiction, a.approval_date DESC NULLS LAST LIMIT 500`), [] as ApprovalRow[]); | |
| 95 | +} | |
| 96 | + | |
| 97 | +export async function drugSlugsForSitemap(offset: number, limit: number): Promise<Array<{ slug: string; updated_at: Date }>> { | |
| 98 | + return safe(() => run<{ slug: string; updated_at: Date }>(sql`SELECT slug, updated_at FROM drugs ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []); | |
| 99 | +} | |
| 100 | +export async function countDrugs(): Promise<number> { | |
| 101 | + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM drugs`), [{ n: '0' }]); | |
| 102 | + return Number(r[0]?.n ?? 0); | |
| 103 | +} | |
added
apps/web/src/lib/queries/epidemiology.ts
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface EpiObs { | |
| 5 | + id: number; | |
| 6 | + geography_id: string; | |
| 7 | + geography_name: string; | |
| 8 | + geography_slug: string; | |
| 9 | + iso3: string | null; | |
| 10 | + year: number; | |
| 11 | + year_end: number | null; | |
| 12 | + sex: string; | |
| 13 | + age_group: string; | |
| 14 | + metric: string; | |
| 15 | + value: number; | |
| 16 | + unit: string; | |
| 17 | + lower_ci: number | null; | |
| 18 | + upper_ci: number | null; | |
| 19 | + standard_population: string | null; | |
| 20 | + estimate_type: string; | |
| 21 | + site_definition: string | null; | |
| 22 | + source_id: string; | |
| 23 | + source_slug: string; | |
| 24 | + source_name: string; | |
| 25 | + provenance_id: number; | |
| 26 | + ingest_run_id: string | null; | |
| 27 | + updated_at: Date; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export async function epidemiologyFor(cancerId: string, limit = 2000): Promise<EpiObs[]> { | |
| 31 | + return safe( | |
| 32 | + () => | |
| 33 | + run<EpiObs>(sql` | |
| 34 | + SELECT o.*, g.name AS geography_name, g.slug AS geography_slug, g.iso3, s.slug AS source_slug, s.name AS source_name | |
| 35 | + FROM epidemiology_observations o JOIN geographies g ON g.id = o.geography_id JOIN sources s ON s.id = o.source_id | |
| 36 | + WHERE o.cancer_id = ${cancerId} | |
| 37 | + ORDER BY g.kind, g.name, o.metric, o.sex, o.age_group, o.year LIMIT ${limit}`), | |
| 38 | + [] as EpiObs[], | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface SurvObs { | |
| 43 | + id: number; | |
| 44 | + geography_name: string | null; | |
| 45 | + stage: string | null; | |
| 46 | + staging_system: string | null; | |
| 47 | + sex: string; | |
| 48 | + age_group: string; | |
| 49 | + diagnosis_period: string | null; | |
| 50 | + survival_type: string; | |
| 51 | + duration_months: number; | |
| 52 | + probability: number | null; | |
| 53 | + median_months: number | null; | |
| 54 | + cohort_size: number | null; | |
| 55 | + lower_ci: number | null; | |
| 56 | + upper_ci: number | null; | |
| 57 | + method: string | null; | |
| 58 | + source_slug: string; | |
| 59 | + source_name: string; | |
| 60 | + provenance_id: number; | |
| 61 | + updated_at: Date; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export async function survivalFor(cancerId: string, limit = 1000): Promise<SurvObs[]> { | |
| 65 | + return safe( | |
| 66 | + () => | |
| 67 | + run<SurvObs>(sql` | |
| 68 | + SELECT o.*, g.name AS geography_name, s.slug AS source_slug, s.name AS source_name | |
| 69 | + FROM survival_observations o LEFT JOIN geographies g ON g.id = o.geography_id JOIN sources s ON s.id = o.source_id | |
| 70 | + WHERE o.cancer_id = ${cancerId} | |
| 71 | + ORDER BY o.survival_type, coalesce(o.stage, ''), o.diagnosis_period, o.duration_months LIMIT ${limit}`), | |
| 72 | + [] as SurvObs[], | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export const EPI_METRIC_LABEL: Record<string, string> = { | |
| 77 | + incidence_count: 'New cases', | |
| 78 | + incidence_rate: 'Incidence rate (crude)', | |
| 79 | + as_incidence_rate: 'Incidence rate (age-standardized)', | |
| 80 | + mortality_count: 'Deaths', | |
| 81 | + mortality_rate: 'Mortality rate (crude)', | |
| 82 | + as_mortality_rate: 'Mortality rate (age-standardized)', | |
| 83 | + prevalence: 'Prevalence', | |
| 84 | + prevalence_5y: '5-year prevalence', | |
| 85 | +}; | |
added
apps/web/src/lib/queries/evidence.ts
+94 −0
@@ -0,0 +1,94 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface EvidenceItem { | |
| 5 | + id: number; | |
| 6 | + civic_id: number; | |
| 7 | + name: string | null; | |
| 8 | + molecular_profile_id: number | null; | |
| 9 | + molecular_profile_name: string | null; | |
| 10 | + gene_symbols: string[]; | |
| 11 | + gene_ids: string[]; | |
| 12 | + variant_ids: string[]; | |
| 13 | + civic_variant_ids: number[]; | |
| 14 | + disease_name: string | null; | |
| 15 | + doid: string | null; | |
| 16 | + cancer_id: string | null; | |
| 17 | + cancer_slug: string | null; | |
| 18 | + cancer_name: string | null; | |
| 19 | + cancer_match_type: string | null; | |
| 20 | + therapy_names: string[]; | |
| 21 | + therapy_ids: string[]; | |
| 22 | + therapy_interaction_type: string | null; | |
| 23 | + evidence_type: string | null; | |
| 24 | + evidence_level: string | null; | |
| 25 | + evidence_direction: string | null; | |
| 26 | + significance: string | null; | |
| 27 | + evidence_rating: number | null; | |
| 28 | + status: string | null; | |
| 29 | + description: string | null; | |
| 30 | + pmid: string | null; | |
| 31 | + source_citation: string | null; | |
| 32 | + phenotypes: string[]; | |
| 33 | + provenance_id: number; | |
| 34 | + ingest_run_id: string | null; | |
| 35 | + updated_at: Date; | |
| 36 | + variant_slugs: string[] | null; | |
| 37 | + variant_names: string[] | null; | |
| 38 | + therapy_slugs: string[] | null; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const SELECT = sql` | |
| 42 | + SELECT e.*, c.slug AS cancer_slug, c.canonical_name AS cancer_name, | |
| 43 | + (SELECT array_agg(v.slug ORDER BY v.slug) FROM variants v WHERE v.id = ANY(e.variant_ids)) AS variant_slugs, | |
| 44 | + (SELECT array_agg(coalesce(v.gene_symbol || ' ', '') || v.name ORDER BY v.slug) FROM variants v WHERE v.id = ANY(e.variant_ids)) AS variant_names, | |
| 45 | + (SELECT array_agg(d.slug ORDER BY d.slug) FROM drugs d WHERE d.id = ANY(e.therapy_ids)) AS therapy_slugs | |
| 46 | + FROM civic_evidence_items e LEFT JOIN cancers c ON c.id = e.cancer_id`; | |
| 47 | + | |
| 48 | +export async function evidenceForCancer(cancerIds: string[], limit = 1000): Promise<EvidenceItem[]> { | |
| 49 | + if (cancerIds.length === 0) return []; | |
| 50 | + return safe(() => run<EvidenceItem>(sql`${SELECT} WHERE e.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) ORDER BY e.evidence_level NULLS LAST, e.evidence_rating DESC NULLS LAST, e.civic_id LIMIT ${limit}`), [] as EvidenceItem[]); | |
| 51 | +} | |
| 52 | + | |
| 53 | +export async function evidenceForVariant(variantId: string, limit = 500): Promise<EvidenceItem[]> { | |
| 54 | + return safe(() => run<EvidenceItem>(sql`${SELECT} WHERE ${variantId} = ANY(e.variant_ids) ORDER BY c.canonical_name NULLS LAST, e.evidence_level NULLS LAST, e.civic_id LIMIT ${limit}`), [] as EvidenceItem[]); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export async function evidenceForGene(geneId: string, symbol: string, limit = 500): Promise<EvidenceItem[]> { | |
| 58 | + return safe(() => run<EvidenceItem>(sql`${SELECT} WHERE ${geneId} = ANY(e.gene_ids) OR ${symbol} = ANY(e.gene_symbols) ORDER BY c.canonical_name NULLS LAST, e.evidence_level NULLS LAST, e.civic_id LIMIT ${limit}`), [] as EvidenceItem[]); | |
| 59 | +} | |
| 60 | + | |
| 61 | +export async function evidenceForDrug(drugId: string, limit = 500): Promise<EvidenceItem[]> { | |
| 62 | + return safe(() => run<EvidenceItem>(sql`${SELECT} WHERE ${drugId} = ANY(e.therapy_ids) ORDER BY c.canonical_name NULLS LAST, e.evidence_level NULLS LAST, e.civic_id LIMIT ${limit}`), [] as EvidenceItem[]); | |
| 63 | +} | |
| 64 | + | |
| 65 | +export async function evidenceForPublication(pmid: string, limit = 200): Promise<EvidenceItem[]> { | |
| 66 | + return safe(() => run<EvidenceItem>(sql`${SELECT} WHERE e.pmid = ${pmid} ORDER BY e.civic_id LIMIT ${limit}`), [] as EvidenceItem[]); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export const EVIDENCE_LEVEL_LABEL: Record<string, string> = { | |
| 70 | + A: 'A — Validated association', | |
| 71 | + B: 'B — Clinical evidence', | |
| 72 | + C: 'C — Case study', | |
| 73 | + D: 'D — Preclinical evidence', | |
| 74 | + E: 'E — Inferential association', | |
| 75 | +}; | |
| 76 | + | |
| 77 | +/** Group evidence items by variant → therapy (never collapsed to works/doesn't work). */ | |
| 78 | +export function groupEvidence(items: EvidenceItem[]) { | |
| 79 | + const byVariant = new Map<string, { key: string; label: string; slug: string | null; genes: string[]; therapies: Map<string, EvidenceItem[]> }>(); | |
| 80 | + for (const e of items) { | |
| 81 | + const vLabel = e.variant_names?.join(' + ') ?? e.molecular_profile_name ?? e.name ?? `CIViC EID${e.civic_id}`; | |
| 82 | + const vKey = e.variant_ids.length ? e.variant_ids.join('+') : `mp-${e.molecular_profile_id ?? e.civic_id}`; | |
| 83 | + if (!byVariant.has(vKey)) byVariant.set(vKey, { key: vKey, label: vLabel, slug: e.variant_slugs?.length === 1 ? e.variant_slugs[0]! : null, genes: e.gene_symbols, therapies: new Map() }); | |
| 84 | + const g = byVariant.get(vKey)!; | |
| 85 | + const tKey = e.therapy_names.length ? e.therapy_names.join(' + ') : e.evidence_type === 'PREDICTIVE' ? 'Unspecified therapy' : `(${(e.evidence_type ?? 'evidence').toLowerCase()})`; | |
| 86 | + if (!g.therapies.has(tKey)) g.therapies.set(tKey, []); | |
| 87 | + g.therapies.get(tKey)!.push(e); | |
| 88 | + } | |
| 89 | + return [...byVariant.values()].sort((a, b) => { | |
| 90 | + const na = [...a.therapies.values()].reduce((s, x) => s + x.length, 0); | |
| 91 | + const nb = [...b.therapies.values()].reduce((s, x) => s + x.length, 0); | |
| 92 | + return nb - na || a.label.localeCompare(b.label); | |
| 93 | + }); | |
| 94 | +} | |
added
apps/web/src/lib/queries/genomics.ts
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface GeneRow { | |
| 5 | + id: string; | |
| 6 | + hgnc_id: string | null; | |
| 7 | + symbol: string; | |
| 8 | + name: string | null; | |
| 9 | + locus_type: string | null; | |
| 10 | + locus_group: string | null; | |
| 11 | + location: string | null; | |
| 12 | + chromosome: string | null; | |
| 13 | + ensembl_gene_id: string | null; | |
| 14 | + ncbi_gene_id: string | null; | |
| 15 | + omim_ids: string[]; | |
| 16 | + uniprot_ids: string[]; | |
| 17 | + refseq_accession: string | null; | |
| 18 | + prev_symbols: string[]; | |
| 19 | + alias_symbols: string[]; | |
| 20 | + gene_families: string[]; | |
| 21 | + status: string; | |
| 22 | + is_cancer_gene: boolean; | |
| 23 | + civic_gene_id: number | null; | |
| 24 | + description: string | null; | |
| 25 | + updated_at: Date; | |
| 26 | + evidence_count?: number; | |
| 27 | + variant_count?: number; | |
| 28 | + cohort_count?: number; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export async function getGeneBySymbol(symbol: string): Promise<GeneRow | null> { | |
| 32 | + const rows = await safe( | |
| 33 | + () => | |
| 34 | + run<GeneRow>(sql` | |
| 35 | + SELECT g.*, (SELECT count(*) FROM civic_evidence_items e WHERE g.id = ANY(e.gene_ids) OR g.symbol = ANY(e.gene_symbols))::int AS evidence_count, | |
| 36 | + (SELECT count(*) FROM variants v WHERE v.gene_id = g.id)::int AS variant_count, | |
| 37 | + (SELECT count(DISTINCT f.cohort_id) FROM cancer_gene_frequencies f WHERE f.gene_id = g.id OR f.gene_symbol = g.symbol)::int AS cohort_count | |
| 38 | + FROM genes g WHERE upper(g.symbol) = upper(${symbol}) LIMIT 1`), | |
| 39 | + [] as GeneRow[], | |
| 40 | + ); | |
| 41 | + return rows[0] ?? null; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export async function listGenes(opts: { q: string; cancerOnly: boolean; page: number; pageSize: number }): Promise<{ rows: GeneRow[]; total: number }> { | |
| 45 | + const where = sql`${opts.cancerOnly ? sql`g.is_cancer_gene` : sql`true`} AND ${opts.q ? sql`(g.symbol ILIKE ${opts.q + '%'} OR g.name ILIKE ${'%' + opts.q + '%'} OR ${opts.q.toUpperCase()} = ANY(g.alias_symbols) OR ${opts.q.toUpperCase()} = ANY(g.prev_symbols))` : sql`true`}`; | |
| 46 | + const total = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM genes g WHERE ${where}`), [{ n: '0' }]); | |
| 47 | + const rows = await safe( | |
| 48 | + () => | |
| 49 | + run<GeneRow>(sql` | |
| 50 | + SELECT g.*, (SELECT count(*) FROM civic_evidence_items e WHERE g.id = ANY(e.gene_ids))::int AS evidence_count, | |
| 51 | + (SELECT count(*) FROM variants v WHERE v.gene_id = g.id)::int AS variant_count | |
| 52 | + FROM genes g WHERE ${where} ORDER BY g.is_cancer_gene DESC, g.symbol LIMIT ${opts.pageSize} OFFSET ${(opts.page - 1) * opts.pageSize}`), | |
| 53 | + [] as GeneRow[], | |
| 54 | + ); | |
| 55 | + return { rows, total: Number(total[0]?.n ?? 0) }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +export interface FreqRow { | |
| 59 | + id: number; | |
| 60 | + cohort_id: string; | |
| 61 | + study_id: string; | |
| 62 | + cohort_name: string; | |
| 63 | + program: string | null; | |
| 64 | + data_release: string | null; | |
| 65 | + cases_with_ssm: number | null; | |
| 66 | + case_count: number | null; | |
| 67 | + cancer_id: string | null; | |
| 68 | + cancer_slug: string | null; | |
| 69 | + cancer_name: string | null; | |
| 70 | + cancer_match_type: string | null; | |
| 71 | + gene_id: string | null; | |
| 72 | + gene_symbol: string; | |
| 73 | + alteration_type: string; | |
| 74 | + cases_affected: number; | |
| 75 | + cases_profiled: number; | |
| 76 | + frequency: number; | |
| 77 | + rank: number | null; | |
| 78 | + provenance_id: number; | |
| 79 | + source_slug: string; | |
| 80 | + source_name: string; | |
| 81 | + updated_at: Date; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Gene frequencies for a cancer (and descendants), grouped per cohort by the caller (§260-261). */ | |
| 85 | +export async function frequenciesForCancer(cancerIds: string[], limitPerCohort = 30): Promise<FreqRow[]> { | |
| 86 | + if (cancerIds.length === 0) return []; | |
| 87 | + return safe( | |
| 88 | + () => | |
| 89 | + run<FreqRow>(sql` | |
| 90 | + SELECT * FROM ( | |
| 91 | + SELECT f.*, gc.study_id, gc.name AS cohort_name, gc.program, gc.cases_with_ssm, gc.case_count, gc.cancer_match_type, | |
| 92 | + c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name, | |
| 93 | + row_number() OVER (PARTITION BY f.cohort_id, f.alteration_type ORDER BY f.frequency DESC) AS rn | |
| 94 | + FROM cancer_gene_frequencies f JOIN genomic_cohorts gc ON gc.id = f.cohort_id JOIN sources s ON s.id = gc.source_id | |
| 95 | + LEFT JOIN cancers c ON c.id = gc.cancer_id | |
| 96 | + WHERE gc.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) | |
| 97 | + ) x WHERE rn <= ${limitPerCohort} ORDER BY cohort_name, alteration_type, frequency DESC`), | |
| 98 | + [] as FreqRow[], | |
| 99 | + ); | |
| 100 | +} | |
| 101 | + | |
| 102 | +export async function frequenciesForGene(geneId: string, symbol: string): Promise<FreqRow[]> { | |
| 103 | + return safe( | |
| 104 | + () => | |
| 105 | + run<FreqRow>(sql` | |
| 106 | + SELECT f.*, gc.study_id, gc.name AS cohort_name, gc.program, gc.cases_with_ssm, gc.case_count, gc.cancer_match_type, | |
| 107 | + c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name | |
| 108 | + FROM cancer_gene_frequencies f JOIN genomic_cohorts gc ON gc.id = f.cohort_id JOIN sources s ON s.id = gc.source_id | |
| 109 | + LEFT JOIN cancers c ON c.id = gc.cancer_id | |
| 110 | + WHERE f.gene_id = ${geneId} OR f.gene_symbol = ${symbol} ORDER BY f.frequency DESC LIMIT 200`), | |
| 111 | + [] as FreqRow[], | |
| 112 | + ); | |
| 113 | +} | |
| 114 | + | |
| 115 | +export interface VariantRow { | |
| 116 | + id: string; | |
| 117 | + slug: string; | |
| 118 | + gene_id: string | null; | |
| 119 | + gene_symbol: string | null; | |
| 120 | + name: string; | |
| 121 | + variant_type: string | null; | |
| 122 | + hgvs_g: string | null; | |
| 123 | + hgvs_c: string | null; | |
| 124 | + hgvs_p: string | null; | |
| 125 | + assembly: string | null; | |
| 126 | + chromosome: string | null; | |
| 127 | + start: number | null; | |
| 128 | + end: number | null; | |
| 129 | + reference_bases: string | null; | |
| 130 | + alternate_bases: string | null; | |
| 131 | + coordinates: Array<Record<string, unknown>>; | |
| 132 | + clinvar_variation_id: string | null; | |
| 133 | + civic_variant_id: number | null; | |
| 134 | + dbsnp_ids: string[]; | |
| 135 | + fusion_partners: string[]; | |
| 136 | + updated_at: Date; | |
| 137 | + evidence_count?: number; | |
| 138 | +} | |
| 139 | + | |
| 140 | +export async function getVariantBySlug(slug: string): Promise<VariantRow | null> { | |
| 141 | + const rows = await safe(() => run<VariantRow>(sql`SELECT v.*, (SELECT count(*) FROM civic_evidence_items e WHERE v.id = ANY(e.variant_ids))::int AS evidence_count FROM variants v WHERE v.slug = ${slug} LIMIT 1`), [] as VariantRow[]); | |
| 142 | + return rows[0] ?? null; | |
| 143 | +} | |
| 144 | + | |
| 145 | +export async function variantsForGene(geneId: string, limit = 300): Promise<VariantRow[]> { | |
| 146 | + return safe(() => run<VariantRow>(sql`SELECT v.*, (SELECT count(*) FROM civic_evidence_items e WHERE v.id = ANY(e.variant_ids))::int AS evidence_count FROM variants v WHERE v.gene_id = ${geneId} ORDER BY evidence_count DESC, v.name LIMIT ${limit}`), [] as VariantRow[]); | |
| 147 | +} | |
| 148 | + | |
| 149 | +export async function variantAliases(variantId: string): Promise<string[]> { | |
| 150 | + const rows = await safe(() => run<{ alias: string }>(sql`SELECT alias FROM variant_aliases WHERE variant_id = ${variantId} ORDER BY alias`), []); | |
| 151 | + return rows.map((r) => r.alias); | |
| 152 | +} | |
| 153 | + | |
| 154 | +export interface ClinSigRow { | |
| 155 | + id: number; | |
| 156 | + clinvar_variation_id: string; | |
| 157 | + clinical_significance: string; | |
| 158 | + review_status: string | null; | |
| 159 | + star_rating: number | null; | |
| 160 | + last_evaluated: string | null; | |
| 161 | + conditions: string[]; | |
| 162 | + condition_cancer_ids: string[]; | |
| 163 | + origin_simple: string | null; | |
| 164 | + number_submitters: number | null; | |
| 165 | + provenance_id: number; | |
| 166 | + updated_at: Date; | |
| 167 | +} | |
| 168 | +export async function clinicalSignificanceFor(variantId: string): Promise<ClinSigRow[]> { | |
| 169 | + return safe(() => run<ClinSigRow>(sql`SELECT * FROM variant_clinical_significance WHERE variant_id = ${variantId} ORDER BY last_evaluated DESC NULLS LAST`), [] as ClinSigRow[]); | |
| 170 | +} | |
| 171 | + | |
| 172 | +export async function geneSymbolsForSitemap(offset: number, limit: number): Promise<Array<{ symbol: string; updated_at: Date }>> { | |
| 173 | + return safe(() => run<{ symbol: string; updated_at: Date }>(sql`SELECT symbol, updated_at FROM genes ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []); | |
| 174 | +} | |
| 175 | +export async function countGenes(): Promise<number> { | |
| 176 | + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM genes`), [{ n: '0' }]); | |
| 177 | + return Number(r[0]?.n ?? 0); | |
| 178 | +} | |
added
apps/web/src/lib/queries/provenance.ts
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import type { ProvenanceInfo } from '@/components/ui/source-badge'; | |
| 4 | + | |
| 5 | +export interface ProvRow { | |
| 6 | + id: number; | |
| 7 | + source_id: string; | |
| 8 | + source_slug: string; | |
| 9 | + source_name: string; | |
| 10 | + source_url: string | null; | |
| 11 | + dataset: string | null; | |
| 12 | + dataset_version: string | null; | |
| 13 | + retrieved_at: Date; | |
| 14 | + evidence_type: string; | |
| 15 | + license: string | null; | |
| 16 | + pmid: string | null; | |
| 17 | + ingest_run_id: string | null; | |
| 18 | + source_record_id: string | null; | |
| 19 | + methodology: string | null; | |
| 20 | + population: string | null; | |
| 21 | + geography: string | null; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Load provenance rows (joined with the source) for a set of ids, keyed by id. */ | |
| 25 | +export async function loadProvenance(ids: Array<number | null | undefined>): Promise<Map<number, ProvRow>> { | |
| 26 | + const uniq = [...new Set(ids.filter((i): i is number => typeof i === 'number' && Number.isFinite(i)))]; | |
| 27 | + if (uniq.length === 0) return new Map(); | |
| 28 | + const rows = await safe( | |
| 29 | + () => | |
| 30 | + run<ProvRow>(sql` | |
| 31 | + SELECT p.id, p.source_id, s.slug AS source_slug, s.name AS source_name, p.source_url, p.dataset, p.dataset_version, | |
| 32 | + p.retrieved_at, p.evidence_type, coalesce(p.license, s.license) AS license, p.pmid, p.ingest_run_id, p.source_record_id, | |
| 33 | + p.methodology, p.population, p.geography | |
| 34 | + FROM provenance p JOIN sources s ON s.id = p.source_id | |
| 35 | + WHERE p.id IN ${sql`(${sql.join(uniq.map((i) => sql`${i}`), sql`, `)})`}`), | |
| 36 | + [] as ProvRow[], | |
| 37 | + ); | |
| 38 | + return new Map(rows.map((r) => [Number(r.id), r])); | |
| 39 | +} | |
| 40 | + | |
| 41 | +export function toInfo(p: ProvRow | undefined, layer: ProvenanceInfo['layer'] = 'normalized'): ProvenanceInfo | null { | |
| 42 | + if (!p) return null; | |
| 43 | + return { | |
| 44 | + sourceSlug: p.source_slug, | |
| 45 | + sourceName: p.source_name, | |
| 46 | + dataset: p.dataset, | |
| 47 | + datasetVersion: p.dataset_version, | |
| 48 | + retrievedAt: p.retrieved_at, | |
| 49 | + sourceUrl: p.source_url, | |
| 50 | + layer, | |
| 51 | + license: p.license, | |
| 52 | + evidenceType: p.evidence_type, | |
| 53 | + pmid: p.pmid, | |
| 54 | + ingestRunId: p.ingest_run_id, | |
| 55 | + }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** Minimal source lookup for badges when only a source_id is known. */ | |
| 59 | +export async function sourceInfoById(ids: Array<string | null | undefined>): Promise<Map<string, { slug: string; name: string; license: string | null }>> { | |
| 60 | + const uniq = [...new Set(ids.filter((i): i is string => !!i))]; | |
| 61 | + if (uniq.length === 0) return new Map(); | |
| 62 | + const rows = await safe( | |
| 63 | + () => run<{ id: string; slug: string; name: string; license: string | null }>(sql`SELECT id, slug, name, license FROM sources WHERE id IN (${sql.join(uniq.map((i) => sql`${i}`), sql`, `)})`), | |
| 64 | + [] as Array<{ id: string; slug: string; name: string; license: string | null }>, | |
| 65 | + ); | |
| 66 | + return new Map(rows.map((r) => [r.id, { slug: r.slug, name: r.name, license: r.license }])); | |
| 67 | +} | |
added
apps/web/src/lib/queries/publications.ts
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface PublicationRow { | |
| 5 | + id: string; | |
| 6 | + pmid: string | null; | |
| 7 | + doi: string | null; | |
| 8 | + pmcid: string | null; | |
| 9 | + title: string; | |
| 10 | + abstract: string | null; | |
| 11 | + journal: string | null; | |
| 12 | + journal_iso: string | null; | |
| 13 | + pub_date: string | null; | |
| 14 | + pub_year: number | null; | |
| 15 | + publication_types: string[]; | |
| 16 | + mesh_terms: Array<{ descriptor: string; ui?: string; major: boolean; qualifiers?: string[] }>; | |
| 17 | + authors: Array<{ name: string; affiliation?: string; orcid?: string }>; | |
| 18 | + language: string | null; | |
| 19 | + is_preprint: boolean; | |
| 20 | + retracted: boolean; | |
| 21 | + retraction_notice: string | null; | |
| 22 | + nct_ids: string[]; | |
| 23 | + cited_by_count: number | null; | |
| 24 | + ingest_run_id: string | null; | |
| 25 | + updated_at: Date; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export async function getPublicationByPmid(pmid: string): Promise<PublicationRow | null> { | |
| 29 | + const rows = await safe(() => run<PublicationRow>(sql`SELECT * FROM publications WHERE pmid = ${pmid} LIMIT 1`), [] as PublicationRow[]); | |
| 30 | + return rows[0] ?? null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface PubEdge { | |
| 34 | + entity_type: string; | |
| 35 | + entity_id: string; | |
| 36 | + method: string; | |
| 37 | + confidence: number | null; | |
| 38 | + status: string; | |
| 39 | + label: string | null; | |
| 40 | + href: string | null; | |
| 41 | +} | |
| 42 | +export async function publicationEntities(publicationId: string): Promise<PubEdge[]> { | |
| 43 | + return safe( | |
| 44 | + () => | |
| 45 | + run<PubEdge>(sql` | |
| 46 | + SELECT e.entity_type, e.entity_id, e.method, e.confidence, e.status, | |
| 47 | + CASE e.entity_type WHEN 'cancer' THEN (SELECT canonical_name FROM cancers WHERE id = e.entity_id) | |
| 48 | + WHEN 'gene' THEN (SELECT symbol FROM genes WHERE id = e.entity_id) | |
| 49 | + WHEN 'variant' THEN (SELECT coalesce(gene_symbol || ' ', '') || name FROM variants WHERE id = e.entity_id) | |
| 50 | + WHEN 'drug' THEN (SELECT name FROM drugs WHERE id = e.entity_id) | |
| 51 | + WHEN 'trial' THEN (SELECT nct_id FROM clinical_trials WHERE id = e.entity_id) END AS label, | |
| 52 | + CASE e.entity_type WHEN 'cancer' THEN (SELECT '/cancer/' || slug FROM cancers WHERE id = e.entity_id) | |
| 53 | + WHEN 'gene' THEN (SELECT '/gene/' || symbol FROM genes WHERE id = e.entity_id) | |
| 54 | + WHEN 'variant' THEN (SELECT '/variant/' || slug FROM variants WHERE id = e.entity_id) | |
| 55 | + WHEN 'drug' THEN (SELECT '/drug/' || slug FROM drugs WHERE id = e.entity_id) | |
| 56 | + WHEN 'trial' THEN (SELECT '/trial/' || nct_id FROM clinical_trials WHERE id = e.entity_id) END AS href | |
| 57 | + FROM publication_entity_edges e WHERE e.publication_id = ${publicationId} ORDER BY e.status, e.entity_type, label`), | |
| 58 | + [] as PubEdge[], | |
| 59 | + ); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export interface LitCount { | |
| 63 | + id: number; | |
| 64 | + window_key: string; | |
| 65 | + window_start: string | null; | |
| 66 | + window_end: string | null; | |
| 67 | + query: string; | |
| 68 | + count: number; | |
| 69 | + provenance_id: number; | |
| 70 | + updated_at: Date; // schema `computedAt` uses the updatedAt() helper → column `updated_at` | |
| 71 | +} | |
| 72 | +export async function literatureCountsFor(cancerId: string): Promise<LitCount[]> { | |
| 73 | + return safe(() => run<LitCount>(sql`SELECT * FROM literature_counts WHERE cancer_id = ${cancerId} ORDER BY CASE window_key WHEN 'all' THEN 0 WHEN '10y' THEN 1 WHEN '5y' THEN 2 WHEN '5y_prior' THEN 3 WHEN '12m' THEN 4 ELSE 5 END, window_key`), [] as LitCount[]); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export async function recentPublicationsFor(entityType: string, entityIds: string[], limit = 25): Promise<Array<PublicationRow & { method: string; edge_status: string }>> { | |
| 77 | + if (entityIds.length === 0) return []; | |
| 78 | + return safe( | |
| 79 | + () => | |
| 80 | + run<PublicationRow & { method: string; edge_status: string }>(sql` | |
| 81 | + SELECT DISTINCT ON (p.id) p.*, e.method, e.status AS edge_status FROM publication_entity_edges e JOIN publications p ON p.id = e.publication_id | |
| 82 | + WHERE e.entity_type = ${entityType} AND e.entity_id IN (${sql.join(entityIds.map((i) => sql`${i}`), sql`, `)}) AND e.status <> 'rejected' | |
| 83 | + ORDER BY p.id, e.status`), | |
| 84 | + [] as Array<PublicationRow & { method: string; edge_status: string }>, | |
| 85 | + ).then((rows) => rows.sort((a, b) => (b.pub_date ?? '').localeCompare(a.pub_date ?? '')).slice(0, limit)); | |
| 86 | +} | |
| 87 | + | |
| 88 | +export async function publicationsForTrial(nctId: string, limit = 50): Promise<PublicationRow[]> { | |
| 89 | + return safe(() => run<PublicationRow>(sql`SELECT * FROM publications WHERE ${nctId} = ANY(nct_ids) ORDER BY pub_date DESC NULLS LAST LIMIT ${limit}`), [] as PublicationRow[]); | |
| 90 | +} | |
added
apps/web/src/lib/queries/rankings.ts
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface MetricDef { | |
| 5 | + id: string; | |
| 6 | + slug: string; | |
| 7 | + name: string; | |
| 8 | + description: string; | |
| 9 | + formula: string; | |
| 10 | + formula_version: string; | |
| 11 | + unit: string; | |
| 12 | + higher_is_worse: boolean | null; | |
| 13 | + aggregation: string | null; | |
| 14 | + valid_dimensions: string[]; | |
| 15 | + source_slugs: string[]; | |
| 16 | + category: string; | |
| 17 | + eligibility: Record<string, unknown>; | |
| 18 | + experimental: boolean; | |
| 19 | + snapshot_count: number; | |
| 20 | +} | |
| 21 | + | |
| 22 | +// NOTE: `rankings`/`ranking_snapshots` use generated_at; `entity_counters` and `literature_counts` | |
| 23 | +// declare computedAt via the updatedAt() helper, so their physical column is `updated_at`. | |
| 24 | +export async function listMetrics(): Promise<MetricDef[]> { | |
| 25 | + const rows = await safe( | |
| 26 | + () => | |
| 27 | + run<MetricDef>(sql` | |
| 28 | + SELECT m.*, (SELECT count(*) FROM ranking_snapshots s WHERE s.metric_slug = m.slug AND s.is_current)::int AS snapshot_count | |
| 29 | + FROM metric_definitions m ORDER BY m.category, m.name`), | |
| 30 | + [] as MetricDef[], | |
| 31 | + ); | |
| 32 | + return rows; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export async function getMetric(slug: string): Promise<MetricDef | null> { | |
| 36 | + const rows = await safe(() => run<MetricDef>(sql`SELECT m.*, (SELECT count(*) FROM ranking_snapshots s WHERE s.metric_slug = m.slug AND s.is_current)::int AS snapshot_count FROM metric_definitions m WHERE m.slug = ${slug}`), [] as MetricDef[]); | |
| 37 | + return rows[0] ?? null; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface Snapshot { | |
| 41 | + id: number; | |
| 42 | + metric_id: string; | |
| 43 | + metric_slug: string; | |
| 44 | + scope_key: string; | |
| 45 | + geography: string; | |
| 46 | + sex: string; | |
| 47 | + age_group: string; | |
| 48 | + year: number | null; | |
| 49 | + entity_level: string; | |
| 50 | + formula_version: string; | |
| 51 | + eligible_entities: number; | |
| 52 | + inputs_hash: string; | |
| 53 | + source_ids: string[]; | |
| 54 | + is_current: boolean; | |
| 55 | + generated_at: Date; | |
| 56 | +} | |
| 57 | + | |
| 58 | +export async function snapshotsForMetric(slug: string): Promise<Snapshot[]> { | |
| 59 | + return safe(() => run<Snapshot>(sql`SELECT * FROM ranking_snapshots WHERE metric_slug = ${slug} AND is_current ORDER BY (entity_level = 'top') DESC, geography, year DESC NULLS LAST, sex`), [] as Snapshot[]); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export async function getSnapshot(slug: string, scopeKey: string | null): Promise<Snapshot | null> { | |
| 63 | + const rows = await safe( | |
| 64 | + () => | |
| 65 | + run<Snapshot>(sql`SELECT * FROM ranking_snapshots WHERE metric_slug = ${slug} AND is_current AND ${scopeKey ? sql`scope_key = ${scopeKey}` : sql`true`} | |
| 66 | + ORDER BY (entity_level = 'top') DESC, (geography = 'WORLD') DESC, year DESC NULLS LAST LIMIT 1`), | |
| 67 | + [] as Snapshot[], | |
| 68 | + ); | |
| 69 | + return rows[0] ?? null; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface RankingRow { | |
| 73 | + id: number; | |
| 74 | + rank: number; | |
| 75 | + previous_rank: number | null; | |
| 76 | + cancer_id: string; | |
| 77 | + slug: string; | |
| 78 | + canonical_name: string; | |
| 79 | + value: number; | |
| 80 | + unit: string; | |
| 81 | + confidence: string; | |
| 82 | + percentile: number; | |
| 83 | + eligible_entities: number; | |
| 84 | + inputs: Record<string, unknown>; | |
| 85 | +} | |
| 86 | + | |
| 87 | +export async function rankingRows(snapshotId: number, limit = 1000): Promise<RankingRow[]> { | |
| 88 | + return safe( | |
| 89 | + () => | |
| 90 | + run<RankingRow>(sql` | |
| 91 | + SELECT r.id, r.rank, r.previous_rank, r.cancer_id, c.slug, c.canonical_name, r.value, r.unit, r.confidence, r.percentile, r.eligible_entities, r.inputs | |
| 92 | + FROM rankings r JOIN cancers c ON c.id = r.cancer_id WHERE r.snapshot_id = ${snapshotId} ORDER BY r.rank, c.canonical_name LIMIT ${limit}`), | |
| 93 | + [] as RankingRow[], | |
| 94 | + ); | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** Every current ranking row for one cancer — the "Why this rank?" panel (§183). */ | |
| 98 | +export interface CancerRanking { | |
| 99 | + id: number; | |
| 100 | + metric_slug: string; | |
| 101 | + metric_name: string; | |
| 102 | + unit: string; | |
| 103 | + scope_key: string; | |
| 104 | + formula_version: string; | |
| 105 | + formula: string; | |
| 106 | + rank: number; | |
| 107 | + previous_rank: number | null; | |
| 108 | + eligible_entities: number; | |
| 109 | + value: number; | |
| 110 | + confidence: string; | |
| 111 | + percentile: number; | |
| 112 | + inputs: Record<string, unknown>; | |
| 113 | + generated_at: Date; | |
| 114 | + inputs_hash: string; | |
| 115 | + source_ids: string[]; | |
| 116 | +} | |
| 117 | +export async function rankingsForCancer(cancerId: string): Promise<CancerRanking[]> { | |
| 118 | + return safe( | |
| 119 | + () => | |
| 120 | + run<CancerRanking>(sql` | |
| 121 | + SELECT r.id, r.metric_slug, m.name AS metric_name, r.unit, r.scope_key, s.formula_version, m.formula, r.rank, r.previous_rank, r.eligible_entities, r.value, r.confidence, r.percentile, r.inputs, s.generated_at, s.inputs_hash, s.source_ids | |
| 122 | + FROM rankings r JOIN ranking_snapshots s ON s.id = r.snapshot_id AND s.is_current JOIN metric_definitions m ON m.slug = r.metric_slug | |
| 123 | + WHERE r.cancer_id = ${cancerId} ORDER BY m.category, m.name, s.scope_key`), | |
| 124 | + [] as CancerRanking[], | |
| 125 | + ); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** Home preview: the best available current top-level snapshot for a list of preferred metrics. */ | |
| 129 | +export async function previewSnapshot(preferred: string[]): Promise<{ snapshot: Snapshot; metric: MetricDef; rows: RankingRow[] } | null> { | |
| 130 | + for (const slug of preferred) { | |
| 131 | + const snap = await getSnapshot(slug, null); | |
| 132 | + if (!snap) continue; | |
| 133 | + const metric = await getMetric(slug); | |
| 134 | + if (!metric) continue; | |
| 135 | + const rows = await rankingRows(snap.id, 10); | |
| 136 | + if (rows.length) return { snapshot: snap, metric, rows }; | |
| 137 | + } | |
| 138 | + return null; | |
| 139 | +} | |
| 140 | + | |
| 141 | +export async function listSnapshots(limit = 200): Promise<Array<Snapshot & { metric_name: string; row_count: number }>> { | |
| 142 | + return safe( | |
| 143 | + () => run<Snapshot & { metric_name: string; row_count: number }>(sql`SELECT s.*, m.name AS metric_name, (SELECT count(*) FROM rankings r WHERE r.snapshot_id = s.id)::int AS row_count FROM ranking_snapshots s JOIN metric_definitions m ON m.id = s.metric_id ORDER BY s.generated_at DESC LIMIT ${limit}`), | |
| 144 | + [], | |
| 145 | + ); | |
| 146 | +} | |
added
apps/web/src/lib/queries/search.ts
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import { normalizeLabel } from '@cancerindex/shared'; | |
| 4 | +import type { SearchHit } from '@/components/layout/command-palette'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Entity search (§294, §312). Ordering: exact canonical name > exact alias/identifier > prefix > | |
| 8 | + * trigram similarity. Each entity type is queried separately, then merged by tier and score. | |
| 9 | + */ | |
| 10 | +export async function searchEntities(qRaw: string, limit = 20): Promise<SearchHit[]> { | |
| 11 | + const q = qRaw.trim(); | |
| 12 | + if (q.length < 2) return []; | |
| 13 | + const norm = normalizeLabel(q); | |
| 14 | + const lower = q.toLowerCase(); | |
| 15 | + const prefix = `${lower}%`; | |
| 16 | + const upper = q.toUpperCase(); | |
| 17 | + | |
| 18 | + type Row = { type: SearchHit['type']; id: string; title: string; subtitle: string | null; href: string; tier: number; score: number }; | |
| 19 | + const rows = await safe( | |
| 20 | + () => | |
| 21 | + run<Row>(sql` | |
| 22 | + WITH cand AS ( | |
| 23 | + -- cancers: name / alias / codes | |
| 24 | + SELECT 'cancer'::text AS type, c.id, c.canonical_name AS title, c.entity_type || coalesce(' · ' || c.primary_oncotree_code, '') AS subtitle, '/cancer/' || c.slug AS href, | |
| 25 | + CASE WHEN lower(c.canonical_name) = ${lower} THEN 0 | |
| 26 | + WHEN EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND a.normalized = ${norm}) THEN 1 | |
| 27 | + WHEN EXISTS (SELECT 1 FROM cancer_codes k WHERE k.cancer_id = c.id AND upper(k.code) = ${upper}) OR c.id = ${upper} THEN 1 | |
| 28 | + WHEN lower(c.canonical_name) LIKE ${prefix} THEN 2 | |
| 29 | + WHEN EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND a.normalized LIKE ${norm + '%'}) THEN 2 | |
| 30 | + ELSE 3 END AS tier, | |
| 31 | + greatest(similarity(c.canonical_name, ${q}), (SELECT coalesce(max(similarity(a.alias, ${q})), 0) FROM cancer_aliases a WHERE a.cancer_id = c.id)) AS score | |
| 32 | + FROM cancers c | |
| 33 | + WHERE c.status = 'active' AND ( | |
| 34 | + c.canonical_name ILIKE ${'%' + q + '%'} OR c.canonical_name % ${q} OR c.id = ${upper} | |
| 35 | + OR EXISTS (SELECT 1 FROM cancer_aliases a WHERE a.cancer_id = c.id AND (a.normalized LIKE ${norm + '%'} OR a.alias % ${q})) | |
| 36 | + OR EXISTS (SELECT 1 FROM cancer_codes k WHERE k.cancer_id = c.id AND upper(k.code) = ${upper})) | |
| 37 | + UNION ALL | |
| 38 | + SELECT 'gene', g.id, g.symbol, g.name, '/gene/' || g.symbol, | |
| 39 | + CASE WHEN upper(g.symbol) = ${upper} THEN 0 WHEN ${upper} = ANY(g.alias_symbols) OR ${upper} = ANY(g.prev_symbols) OR g.hgnc_id = ${upper} THEN 1 WHEN upper(g.symbol) LIKE ${upper + '%'} THEN 2 ELSE 3 END, | |
| 40 | + similarity(g.symbol, ${q}) | |
| 41 | + FROM genes g WHERE upper(g.symbol) LIKE ${upper + '%'} OR ${upper} = ANY(g.alias_symbols) OR ${upper} = ANY(g.prev_symbols) OR g.hgnc_id = ${upper} OR g.name ILIKE ${'%' + q + '%'} | |
| 42 | + UNION ALL | |
| 43 | + SELECT 'variant', v.id, coalesce(v.gene_symbol || ' ', '') || v.name, v.variant_type, '/variant/' || v.slug, | |
| 44 | + CASE WHEN lower(coalesce(v.gene_symbol || ' ', '') || v.name) = ${lower} OR lower(v.name) = ${lower} THEN 0 | |
| 45 | + WHEN v.hgvs_p = ${q} OR v.hgvs_c = ${q} OR v.clinvar_variation_id = ${q} OR ${q} = ANY(v.dbsnp_ids) THEN 1 | |
| 46 | + WHEN lower(coalesce(v.gene_symbol || ' ', '') || v.name) LIKE ${prefix} THEN 2 ELSE 3 END, | |
| 47 | + similarity(coalesce(v.gene_symbol || ' ', '') || v.name, ${q}) | |
| 48 | + FROM variants v WHERE (coalesce(v.gene_symbol || ' ', '') || v.name) ILIKE ${'%' + q + '%'} OR v.hgvs_p = ${q} OR v.hgvs_c = ${q} OR v.clinvar_variation_id = ${q} OR ${q} = ANY(v.dbsnp_ids) | |
| 49 | + UNION ALL | |
| 50 | + SELECT 'drug', d.id, d.name, d.kind, '/drug/' || d.slug, | |
| 51 | + CASE WHEN lower(d.name) = ${lower} THEN 0 WHEN EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND a.normalized = ${norm}) THEN 1 WHEN lower(d.name) LIKE ${prefix} THEN 2 ELSE 3 END, | |
| 52 | + greatest(similarity(d.name, ${q}), (SELECT coalesce(max(similarity(a.alias, ${q})), 0) FROM drug_aliases a WHERE a.drug_id = d.id)) | |
| 53 | + FROM drugs d WHERE d.name ILIKE ${'%' + q + '%'} OR d.name % ${q} OR EXISTS (SELECT 1 FROM drug_aliases a WHERE a.drug_id = d.id AND (a.normalized LIKE ${norm + '%'} OR a.alias % ${q})) | |
| 54 | + UNION ALL | |
| 55 | + SELECT 'trial', t.id, t.nct_id || ' — ' || t.brief_title, t.overall_status, '/trial/' || t.nct_id, | |
| 56 | + CASE WHEN upper(t.nct_id) = ${upper} THEN 0 WHEN upper(t.acronym) = ${upper} THEN 1 WHEN upper(t.nct_id) LIKE ${upper + '%'} THEN 2 ELSE 3 END, | |
| 57 | + similarity(t.brief_title, ${q}) | |
| 58 | + FROM clinical_trials t WHERE upper(t.nct_id) LIKE ${upper + '%'} OR upper(t.acronym) = ${upper} OR (length(${q}) >= 4 AND t.brief_title ILIKE ${'%' + q + '%'}) | |
| 59 | + UNION ALL | |
| 60 | + SELECT 'publication', p.id, p.title, coalesce(p.journal_iso, p.journal) || coalesce(' · ' || p.pub_year::text, ''), '/publication/' || p.pmid, | |
| 61 | + CASE WHEN p.pmid = ${q} OR lower(p.doi) = ${lower} THEN 0 ELSE 3 END, similarity(p.title, ${q}) | |
| 62 | + FROM publications p WHERE p.pmid IS NOT NULL AND (p.pmid = ${q} OR lower(p.doi) = ${lower} OR (length(${q}) >= 5 AND p.title ILIKE ${'%' + q + '%'})) | |
| 63 | + UNION ALL | |
| 64 | + SELECT 'source', s.id, s.name, s.category, '/source/' || s.slug, | |
| 65 | + CASE WHEN lower(s.name) = ${lower} OR s.slug = ${lower} THEN 0 WHEN lower(s.name) LIKE ${prefix} THEN 2 ELSE 3 END, similarity(s.name, ${q}) | |
| 66 | + FROM sources s WHERE s.name ILIKE ${'%' + q + '%'} OR s.slug ILIKE ${'%' + lower + '%'} | |
| 67 | + ) | |
| 68 | + SELECT * FROM cand ORDER BY tier, score DESC, title LIMIT ${limit}`), | |
| 69 | + [] as Row[], | |
| 70 | + ); | |
| 71 | + return rows.map((r) => ({ type: r.type, id: r.id, title: r.title, subtitle: r.subtitle, href: r.href, match: r.tier === 0 ? 'exact' : r.tier === 1 ? (r.type === 'gene' || r.type === 'trial' || r.type === 'publication' || r.type === 'variant' ? 'identifier' : 'alias') : r.tier === 2 ? 'prefix' : 'trigram' })); | |
| 72 | +} | |
added
apps/web/src/lib/queries/sources.ts
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface SourceRow { | |
| 5 | + id: string; | |
| 6 | + slug: string; | |
| 7 | + name: string; | |
| 8 | + organization: string | null; | |
| 9 | + category: string; | |
| 10 | + description: string | null; | |
| 11 | + homepage: string | null; | |
| 12 | + docs_url: string | null; | |
| 13 | + terms_url: string | null; | |
| 14 | + access_type: string; | |
| 15 | + access_auth: string; | |
| 16 | + license: string | null; | |
| 17 | + license_status: string; | |
| 18 | + commercial_use: string; | |
| 19 | + redistribution: string; | |
| 20 | + attribution: string | null; | |
| 21 | + license_reviewed_at: Date | null; | |
| 22 | + approved_for_production: boolean; | |
| 23 | + update_frequency: string | null; | |
| 24 | + supports_incremental: boolean; | |
| 25 | + entities: string[]; | |
| 26 | + metrics: string[]; | |
| 27 | + rate_limit: string | null; | |
| 28 | + status: string; | |
| 29 | + tier: number; | |
| 30 | + manifest: Record<string, unknown>; | |
| 31 | + updated_at: Date; | |
| 32 | + // joined | |
| 33 | + health: string | null; | |
| 34 | + health_detail: string | null; | |
| 35 | + last_success_at: Date | null; | |
| 36 | + last_attempt_at: Date | null; | |
| 37 | + paused: boolean | null; | |
| 38 | + record_count: string | number | null; | |
| 39 | + last_run_id: string | null; | |
| 40 | + last_run_status: string | null; | |
| 41 | + last_run_finished_at: Date | null; | |
| 42 | + last_run_fetched: number | null; | |
| 43 | + last_run_created: number | null; | |
| 44 | + last_run_updated: number | null; | |
| 45 | + last_run_rejected: number | null; | |
| 46 | + last_run_drift: unknown[] | null; | |
| 47 | + last_dataset_version: string | null; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const SOURCE_SELECT = sql` | |
| 51 | + SELECT s.*, cc.health, cc.health_detail, cc.last_success_at, cc.last_attempt_at, cc.paused, | |
| 52 | + (SELECT count(*) FROM source_records r WHERE r.source_id = s.id) AS record_count, | |
| 53 | + lr.id AS last_run_id, lr.status AS last_run_status, lr.finished_at AS last_run_finished_at, | |
| 54 | + lr.records_fetched AS last_run_fetched, lr.records_created AS last_run_created, lr.records_updated AS last_run_updated, | |
| 55 | + lr.records_rejected AS last_run_rejected, lr.schema_drift AS last_run_drift, lr.dataset_version AS last_dataset_version | |
| 56 | + FROM sources s | |
| 57 | + LEFT JOIN connector_cursors cc ON cc.connector_id = s.slug | |
| 58 | + LEFT JOIN LATERAL (SELECT * FROM ingest_runs ir WHERE ir.source_id = s.id ORDER BY ir.started_at DESC LIMIT 1) lr ON true`; | |
| 59 | + | |
| 60 | +export async function listSources(): Promise<SourceRow[]> { | |
| 61 | + return safe(() => run<SourceRow>(sql`${SOURCE_SELECT} ORDER BY s.tier, s.category, s.name`), [] as SourceRow[]); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export async function getSourceBySlug(slug: string): Promise<SourceRow | null> { | |
| 65 | + const rows = await safe(() => run<SourceRow>(sql`${SOURCE_SELECT} WHERE s.slug = ${slug} LIMIT 1`), [] as SourceRow[]); | |
| 66 | + return rows[0] ?? null; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface RunRow { | |
| 70 | + id: string; | |
| 71 | + connector_id: string; | |
| 72 | + source_id: string; | |
| 73 | + mode: string; | |
| 74 | + status: string; | |
| 75 | + started_at: Date; | |
| 76 | + finished_at: Date | null; | |
| 77 | + duration_ms: number | null; | |
| 78 | + records_fetched: number; | |
| 79 | + records_created: number; | |
| 80 | + records_updated: number; | |
| 81 | + records_unchanged: number; | |
| 82 | + records_rejected: number; | |
| 83 | + http_requests: number; | |
| 84 | + http_failures: number; | |
| 85 | + rate_limit_events: number; | |
| 86 | + validation_failures: number; | |
| 87 | + schema_drift: unknown[]; | |
| 88 | + cursor_before: unknown; | |
| 89 | + cursor_after: unknown; | |
| 90 | + error: string | null; | |
| 91 | + log: Array<{ t: string; level: string; msg: string }>; | |
| 92 | + dataset_version: string | null; | |
| 93 | + anomaly: string | null; | |
| 94 | + source_slug?: string; | |
| 95 | + source_name?: string; | |
| 96 | +} | |
| 97 | + | |
| 98 | +export async function listRuns(opts: { sourceId?: string; connectorId?: string; limit?: number } = {}): Promise<RunRow[]> { | |
| 99 | + const limit = opts.limit ?? 25; | |
| 100 | + return safe( | |
| 101 | + () => | |
| 102 | + run<RunRow>(sql` | |
| 103 | + SELECT r.*, s.slug AS source_slug, s.name AS source_name FROM ingest_runs r LEFT JOIN sources s ON s.id = r.source_id | |
| 104 | + WHERE ${opts.sourceId ? sql`r.source_id = ${opts.sourceId}` : sql`true`} AND ${opts.connectorId ? sql`r.connector_id = ${opts.connectorId}` : sql`true`} | |
| 105 | + ORDER BY r.started_at DESC LIMIT ${limit}`), | |
| 106 | + [] as RunRow[], | |
| 107 | + ); | |
| 108 | +} | |
| 109 | + | |
| 110 | +export async function getRun(id: string): Promise<RunRow | null> { | |
| 111 | + const rows = await safe(() => run<RunRow>(sql`SELECT r.*, s.slug AS source_slug, s.name AS source_name FROM ingest_runs r LEFT JOIN sources s ON s.id = r.source_id WHERE r.id = ${id}`), [] as RunRow[]); | |
| 112 | + return rows[0] ?? null; | |
| 113 | +} | |
| 114 | + | |
| 115 | +export async function recordCountsByKind(sourceId: string): Promise<Array<{ entity_kind: string; n: number; status: string; last_retrieved: Date | null }>> { | |
| 116 | + const rows = await safe( | |
| 117 | + () => run<{ entity_kind: string; n: string; status: string; last_retrieved: Date | null }>(sql`SELECT entity_kind, status, count(*) AS n, max(retrieved_at) AS last_retrieved FROM source_records WHERE source_id = ${sourceId} GROUP BY entity_kind, status ORDER BY entity_kind, status`), | |
| 118 | + [] as Array<{ entity_kind: string; n: string; status: string; last_retrieved: Date | null }>, | |
| 119 | + ); | |
| 120 | + return rows.map((r) => ({ ...r, n: Number(r.n) })); | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** Domains for the public coverage matrix (§307-308). */ | |
| 124 | +export const COVERAGE_DOMAINS = ['Taxonomy', 'Epidemiology', 'Genomics', 'Variants', 'Trials', 'Drugs', 'Literature'] as const; | |
| 125 | +export type CoverageDomain = (typeof COVERAGE_DOMAINS)[number]; | |
| 126 | + | |
| 127 | +/** Which domains a source contributes to, from its declared category/entities/metrics (manifest). */ | |
| 128 | +export function sourceDomains(s: Pick<SourceRow, 'category' | 'entities' | 'metrics'>): Set<CoverageDomain> { | |
| 129 | + const out = new Set<CoverageDomain>(); | |
| 130 | + const ents = new Set([...(s.entities ?? []), ...(s.metrics ?? [])].map((e) => e.toLowerCase())); | |
| 131 | + const has = (...keys: string[]) => keys.some((k) => [...ents].some((e) => e.includes(k))); | |
| 132 | + if (s.category === 'terminology' || has('cancer', 'tumor_type', 'concept', 'disease')) out.add('Taxonomy'); | |
| 133 | + if (s.category === 'epidemiology' || has('incidence', 'mortality', 'survival', 'prevalence')) out.add('Epidemiology'); | |
| 134 | + if (s.category === 'genomics' || has('gene', 'cohort', 'frequency', 'mutation')) out.add('Genomics'); | |
| 135 | + if (s.category === 'variants' || has('variant', 'evidence', 'clinical_significance')) out.add('Variants'); | |
| 136 | + if (s.category === 'trials' || has('trial', 'study')) out.add('Trials'); | |
| 137 | + if (s.category === 'drugs' || s.category === 'regulatory' || has('drug', 'therapy', 'approval')) out.add('Drugs'); | |
| 138 | + if (s.category === 'literature' || has('publication', 'literature', 'pubmed')) out.add('Literature'); | |
| 139 | + return out; | |
| 140 | +} | |
| 141 | + | |
| 142 | +/** Plain-language meaning of the license status (§308). */ | |
| 143 | +export function licenseMeaning(status: string, redistribution: string, commercial: string): string { | |
| 144 | + const base: Record<string, string> = { | |
| 145 | + approved: 'Reviewed: CancerIndex may ingest and display this data with attribution.', | |
| 146 | + review: 'Under review: terms are being assessed. No records are shown publicly until the review completes.', | |
| 147 | + restricted: 'Restricted: data may be displayed with limits (e.g. no bulk redistribution).', | |
| 148 | + blocked: 'Blocked: the license does not permit use by CancerIndex.', | |
| 149 | + }; | |
| 150 | + const red: Record<string, string> = { | |
| 151 | + allowed: 'Redistribution allowed.', | |
| 152 | + attribution: 'Redistribution allowed with attribution.', | |
| 153 | + restricted: 'Redistribution restricted — not included in downloads.', | |
| 154 | + prohibited: 'Redistribution prohibited — not included in downloads.', | |
| 155 | + unknown: 'Redistribution terms not yet determined.', | |
| 156 | + }; | |
| 157 | + const com: Record<string, string> = { allowed: 'Commercial use allowed.', restricted: 'Commercial use restricted.', prohibited: 'Commercial use prohibited.', unknown: 'Commercial-use terms not yet determined.' }; | |
| 158 | + return [base[status] ?? status, red[redistribution] ?? '', com[commercial] ?? ''].filter(Boolean).join(' '); | |
| 159 | +} | |
added
apps/web/src/lib/queries/stats.ts
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface SiteCounts { | |
| 5 | + cancers: number; // active malignant entities | |
| 6 | + subtypes: number; // active entities with entity_type in (subtype, molecular_subtype, histology) | |
| 7 | + allEntities: number; | |
| 8 | + genes: number; | |
| 9 | + variants: number; | |
| 10 | + trials: number; | |
| 11 | + publications: number; | |
| 12 | + drugs: number; | |
| 13 | + sources: number; | |
| 14 | + activeSources: number; | |
| 15 | + evidenceItems: number; | |
| 16 | + epidemiologyObservations: number; | |
| 17 | + lastIngestAt: Date | null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const EMPTY: SiteCounts = { cancers: 0, subtypes: 0, allEntities: 0, genes: 0, variants: 0, trials: 0, publications: 0, drugs: 0, sources: 0, activeSources: 0, evidenceItems: 0, epidemiologyObservations: 0, lastIngestAt: null }; | |
| 21 | + | |
| 22 | +/** Live counts for the home ticker. Each label on the page states exactly what is counted (§248-249). */ | |
| 23 | +export async function getSiteCounts(): Promise<SiteCounts> { | |
| 24 | + return safe(async () => { | |
| 25 | + const [r] = await run<Record<string, string | Date | null>>(sql` | |
| 26 | + SELECT | |
| 27 | + (SELECT count(*) FROM cancers WHERE status = 'active' AND malignant) AS cancers, | |
| 28 | + (SELECT count(*) FROM cancers WHERE status = 'active' AND entity_type IN ('subtype','molecular_subtype','histology')) AS subtypes, | |
| 29 | + (SELECT count(*) FROM cancers WHERE status = 'active') AS all_entities, | |
| 30 | + (SELECT count(*) FROM genes) AS genes, | |
| 31 | + (SELECT count(*) FROM variants) AS variants, | |
| 32 | + (SELECT count(*) FROM clinical_trials) AS trials, | |
| 33 | + (SELECT count(*) FROM publications) AS publications, | |
| 34 | + (SELECT count(*) FROM drugs) AS drugs, | |
| 35 | + (SELECT count(*) FROM sources) AS sources, | |
| 36 | + (SELECT count(*) FROM sources WHERE status = 'active') AS active_sources, | |
| 37 | + (SELECT count(*) FROM civic_evidence_items) AS evidence_items, | |
| 38 | + (SELECT count(*) FROM epidemiology_observations) AS epi, | |
| 39 | + (SELECT max(finished_at) FROM ingest_runs WHERE status IN ('succeeded','partial')) AS last_ingest | |
| 40 | + `); | |
| 41 | + if (!r) return EMPTY; | |
| 42 | + return { | |
| 43 | + cancers: Number(r.cancers), | |
| 44 | + subtypes: Number(r.subtypes), | |
| 45 | + allEntities: Number(r.all_entities), | |
| 46 | + genes: Number(r.genes), | |
| 47 | + variants: Number(r.variants), | |
| 48 | + trials: Number(r.trials), | |
| 49 | + publications: Number(r.publications), | |
| 50 | + drugs: Number(r.drugs), | |
| 51 | + sources: Number(r.sources), | |
| 52 | + activeSources: Number(r.active_sources), | |
| 53 | + evidenceItems: Number(r.evidence_items), | |
| 54 | + epidemiologyObservations: Number(r.epi), | |
| 55 | + lastIngestAt: r.last_ingest ? new Date(r.last_ingest as Date) : null, | |
| 56 | + }; | |
| 57 | + }, EMPTY); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** Deterministic daily pick among sparse-data malignant entities (seeded by UTC date). */ | |
| 61 | +export async function getRareSpotlight(): Promise<{ id: string; slug: string; canonicalName: string; entityType: string; primaryOncotreeCode: string | null; rareCancer: boolean | null; parentName: string | null; parentSlug: string | null } | null> { | |
| 62 | + return safe(async () => { | |
| 63 | + const cnt = await run<{ n: string }>(sql`SELECT count(*) AS n FROM cancers WHERE status = 'active' AND malignant`); | |
| 64 | + const total = Number(cnt[0]?.n ?? 0); | |
| 65 | + if (!total) return null; | |
| 66 | + // Seed: days since epoch (UTC). Same pick for everyone during one UTC day. | |
| 67 | + const day = Math.floor(Date.now() / 86_400_000); | |
| 68 | + const offset = ((day * 2654435761) >>> 0) % total; // Knuth multiplicative hash for spread | |
| 69 | + const [row] = await run<{ id: string; slug: string; canonical_name: string; entity_type: string; primary_oncotree_code: string | null; rare_cancer: boolean | null; parent_name: string | null; parent_slug: string | null }>(sql` | |
| 70 | + SELECT c.id, c.slug, c.canonical_name, c.entity_type, c.primary_oncotree_code, c.rare_cancer, | |
| 71 | + p.canonical_name AS parent_name, p.slug AS parent_slug | |
| 72 | + FROM cancers c | |
| 73 | + LEFT JOIN LATERAL (SELECT pc.canonical_name, pc.slug FROM cancer_hierarchy h JOIN cancers pc ON pc.id = h.parent_id WHERE h.child_id = c.id ORDER BY h.hierarchy_type LIMIT 1) p ON true | |
| 74 | + WHERE c.status = 'active' AND c.malignant | |
| 75 | + ORDER BY c.id OFFSET ${offset} LIMIT 1`); | |
| 76 | + if (!row) return null; | |
| 77 | + return { id: row.id, slug: row.slug, canonicalName: row.canonical_name, entityType: row.entity_type, primaryOncotreeCode: row.primary_oncotree_code, rareCancer: row.rare_cancer, parentName: row.parent_name, parentSlug: row.parent_slug }; | |
| 78 | + }, null); | |
| 79 | +} | |
added
apps/web/src/lib/queries/taxonomy.ts
+83 −0
@@ -0,0 +1,83 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export interface TreeNode { | |
| 5 | + id: string; | |
| 6 | + slug: string; | |
| 7 | + canonical_name: string; | |
| 8 | + entity_type: string; | |
| 9 | + malignant: boolean; | |
| 10 | + primary_oncotree_code: string | null; | |
| 11 | + primary_ncit_code: string | null; | |
| 12 | + child_count: number; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export async function hierarchyTypes(): Promise<Array<{ hierarchy_type: string; n: number }>> { | |
| 16 | + const rows = await safe(() => run<{ hierarchy_type: string; n: string }>(sql`SELECT hierarchy_type, count(*) AS n FROM cancer_hierarchy GROUP BY 1 ORDER BY 1`), []); | |
| 17 | + return rows.map((r) => ({ hierarchy_type: r.hierarchy_type, n: Number(r.n) })); | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Root nodes of a hierarchy: nodes that are parents but have no parent of the same type. */ | |
| 21 | +export async function rootsOf(hierarchyType: string, limit = 400): Promise<TreeNode[]> { | |
| 22 | + return safe( | |
| 23 | + () => | |
| 24 | + run<TreeNode>(sql` | |
| 25 | + SELECT c.id, c.slug, c.canonical_name, c.entity_type, c.malignant, c.primary_oncotree_code, c.primary_ncit_code, | |
| 26 | + (SELECT count(*) FROM cancer_hierarchy x WHERE x.parent_id = c.id AND x.hierarchy_type = ${hierarchyType})::int AS child_count | |
| 27 | + FROM cancers c | |
| 28 | + WHERE c.status = 'active' | |
| 29 | + AND EXISTS (SELECT 1 FROM cancer_hierarchy h WHERE h.parent_id = c.id AND h.hierarchy_type = ${hierarchyType}) | |
| 30 | + AND NOT EXISTS (SELECT 1 FROM cancer_hierarchy h WHERE h.child_id = c.id AND h.hierarchy_type = ${hierarchyType}) | |
| 31 | + ORDER BY c.canonical_name LIMIT ${limit}`), | |
| 32 | + [] as TreeNode[], | |
| 33 | + ); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export async function childrenOf(parentId: string, hierarchyType: string, limit = 500): Promise<TreeNode[]> { | |
| 37 | + return safe( | |
| 38 | + () => | |
| 39 | + run<TreeNode>(sql` | |
| 40 | + SELECT c.id, c.slug, c.canonical_name, c.entity_type, c.malignant, c.primary_oncotree_code, c.primary_ncit_code, | |
| 41 | + (SELECT count(*) FROM cancer_hierarchy x WHERE x.parent_id = c.id AND x.hierarchy_type = ${hierarchyType})::int AS child_count | |
| 42 | + FROM cancer_hierarchy h JOIN cancers c ON c.id = h.child_id | |
| 43 | + WHERE h.parent_id = ${parentId} AND h.hierarchy_type = ${hierarchyType} AND c.status = 'active' | |
| 44 | + ORDER BY c.canonical_name LIMIT ${limit}`), | |
| 45 | + [] as TreeNode[], | |
| 46 | + ); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Orphan nodes (no parent in the given hierarchy and no children) — listed separately so nothing is hidden. */ | |
| 50 | +export async function orphanCount(hierarchyType: string): Promise<number> { | |
| 51 | + const rows = await safe( | |
| 52 | + () => | |
| 53 | + run<{ n: string }>(sql` | |
| 54 | + SELECT count(*) AS n FROM cancers c WHERE c.status = 'active' | |
| 55 | + AND NOT EXISTS (SELECT 1 FROM cancer_hierarchy h WHERE (h.parent_id = c.id OR h.child_id = c.id) AND h.hierarchy_type = ${hierarchyType})`), | |
| 56 | + [{ n: '0' }], | |
| 57 | + ); | |
| 58 | + return Number(rows[0]?.n ?? 0); | |
| 59 | +} | |
| 60 | + | |
| 61 | +export interface SiteGroup { | |
| 62 | + id: string; | |
| 63 | + slug: string; | |
| 64 | + name: string; | |
| 65 | + system: string | null; | |
| 66 | + ncit_code: string | null; | |
| 67 | + cancers: Array<{ id: string; slug: string; canonical_name: string; entity_type: string; malignant: boolean; relation: string }>; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export async function anatomicalView(): Promise<SiteGroup[]> { | |
| 71 | + const sites = await safe(() => run<{ id: string; slug: string; name: string; system: string | null; ncit_code: string | null }>(sql`SELECT id, slug, name, system, ncit_code FROM anatomical_sites ORDER BY name`), []); | |
| 72 | + const links = await safe( | |
| 73 | + () => | |
| 74 | + run<{ site_id: string; id: string; slug: string; canonical_name: string; entity_type: string; malignant: boolean; relation: string }>(sql` | |
| 75 | + SELECT ca.site_id, c.id, c.slug, c.canonical_name, c.entity_type, c.malignant, ca.relation | |
| 76 | + FROM cancer_anatomy ca JOIN cancers c ON c.id = ca.cancer_id WHERE c.status = 'active' ORDER BY c.canonical_name`), | |
| 77 | + [], | |
| 78 | + ); | |
| 79 | + const bySite = new Map<string, SiteGroup>(); | |
| 80 | + for (const s of sites) bySite.set(s.id, { ...s, cancers: [] }); | |
| 81 | + for (const l of links) bySite.get(l.site_id)?.cancers.push(l); | |
| 82 | + return [...bySite.values()]; | |
| 83 | +} | |
added
apps/web/src/lib/queries/trials.ts
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +export const ACTIVE_STATUSES = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING']; | |
| 5 | + | |
| 6 | +export interface TrialRow { | |
| 7 | + id: string; | |
| 8 | + nct_id: string; | |
| 9 | + brief_title: string; | |
| 10 | + official_title: string | null; | |
| 11 | + acronym: string | null; | |
| 12 | + study_type: string | null; | |
| 13 | + phases: string[]; | |
| 14 | + overall_status: string | null; | |
| 15 | + why_stopped: string | null; | |
| 16 | + start_date: string | null; | |
| 17 | + primary_completion_date: string | null; | |
| 18 | + completion_date: string | null; | |
| 19 | + first_posted_date: string | null; | |
| 20 | + last_update_posted_date: string | null; | |
| 21 | + results_first_posted_date: string | null; | |
| 22 | + has_results: boolean; | |
| 23 | + enrollment_count: number | null; | |
| 24 | + enrollment_type: string | null; | |
| 25 | + lead_sponsor: string | null; | |
| 26 | + lead_sponsor_class: string | null; | |
| 27 | + collaborators: string[]; | |
| 28 | + conditions: string[]; | |
| 29 | + keywords: string[]; | |
| 30 | + interventions: Array<{ type: string; name: string; description?: string; otherNames?: string[] }>; | |
| 31 | + arms: Array<Record<string, unknown>>; | |
| 32 | + primary_outcomes: Array<Record<string, unknown>>; | |
| 33 | + secondary_outcomes: Array<Record<string, unknown>>; | |
| 34 | + eligibility: Record<string, unknown>; | |
| 35 | + sex: string | null; | |
| 36 | + minimum_age: string | null; | |
| 37 | + maximum_age: string | null; | |
| 38 | + countries: string[]; | |
| 39 | + locations_count: number; | |
| 40 | + references: Array<{ pmid?: string; type?: string; citation?: string }>; | |
| 41 | + brief_summary: string | null; | |
| 42 | + is_oncology: boolean; | |
| 43 | + source_record_id: number | null; | |
| 44 | + ingest_run_id: string | null; | |
| 45 | + created_at: Date; | |
| 46 | + updated_at: Date; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export interface TrialFilters { | |
| 50 | + q: string; | |
| 51 | + status: string; | |
| 52 | + phase: string; | |
| 53 | + country: string; | |
| 54 | + cancerIds: string[] | null; // null = no cancer filter | |
| 55 | + page: number; | |
| 56 | + pageSize: number; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function trialWhere(f: TrialFilters) { | |
| 60 | + const parts = [sql`true`]; | |
| 61 | + if (f.q) parts.push(sql`(t.nct_id ILIKE ${f.q + '%'} OR t.brief_title ILIKE ${'%' + f.q + '%'} OR t.acronym ILIKE ${f.q} OR t.lead_sponsor ILIKE ${'%' + f.q + '%'})`); | |
| 62 | + if (f.status === 'active') parts.push(sql`t.overall_status = ANY(${ACTIVE_STATUSES}::text[])`); | |
| 63 | + else if (f.status) parts.push(sql`t.overall_status = ${f.status}`); | |
| 64 | + if (f.phase) parts.push(sql`${f.phase} = ANY(t.phases)`); | |
| 65 | + if (f.country) parts.push(sql`${f.country} = ANY(t.countries)`); | |
| 66 | + if (f.cancerIds) parts.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id IN (${sql.join(f.cancerIds.map((i) => sql`${i}`), sql`, `)}))`); | |
| 67 | + return sql.join(parts, sql` AND `); | |
| 68 | +} | |
| 69 | + | |
| 70 | +export async function listTrials(f: TrialFilters): Promise<{ rows: TrialRow[]; total: number }> { | |
| 71 | + if (f.cancerIds && f.cancerIds.length === 0) return { rows: [], total: 0 }; | |
| 72 | + const total = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM clinical_trials t WHERE ${trialWhere(f)}`), [{ n: '0' }]); | |
| 73 | + const rows = await safe( | |
| 74 | + () => run<TrialRow>(sql`SELECT t.* FROM clinical_trials t WHERE ${trialWhere(f)} ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${f.pageSize} OFFSET ${(f.page - 1) * f.pageSize}`), | |
| 75 | + [] as TrialRow[], | |
| 76 | + ); | |
| 77 | + return { rows, total: Number(total[0]?.n ?? 0) }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +export async function trialFacets(cancerIds: string[] | null): Promise<{ statuses: Array<{ k: string; n: number }>; phases: Array<{ k: string; n: number }>; countries: Array<{ k: string; n: number }> }> { | |
| 81 | + const scope = cancerIds && cancerIds.length ? sql`WHERE EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}))` : sql``; | |
| 82 | + const statuses = await safe(() => run<{ k: string; n: string }>(sql`SELECT overall_status AS k, count(*) AS n FROM clinical_trials t ${scope} GROUP BY 1 ORDER BY n DESC`), [] as Array<{ k: string; n: string }>); | |
| 83 | + const phases = await safe(() => run<{ k: string; n: string }>(sql`SELECT p AS k, count(*) AS n FROM clinical_trials t, unnest(t.phases) p ${scope} GROUP BY 1 ORDER BY 1`), [] as Array<{ k: string; n: string }>); | |
| 84 | + const countries = await safe(() => run<{ k: string; n: string }>(sql`SELECT c AS k, count(*) AS n FROM clinical_trials t, unnest(t.countries) c ${scope} GROUP BY 1 ORDER BY n DESC LIMIT 60`), [] as Array<{ k: string; n: string }>); | |
| 85 | + const num = (a: Array<{ k: string; n: string }>) => a.filter((x) => x.k).map((x) => ({ k: x.k, n: Number(x.n) })); | |
| 86 | + return { statuses: num(statuses), phases: num(phases), countries: num(countries) }; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export async function getTrialByNct(nct: string): Promise<TrialRow | null> { | |
| 90 | + const rows = await safe(() => run<TrialRow>(sql`SELECT * FROM clinical_trials WHERE upper(nct_id) = upper(${nct}) LIMIT 1`), [] as TrialRow[]); | |
| 91 | + return rows[0] ?? null; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export interface TrialCondition { | |
| 95 | + condition_text: string; | |
| 96 | + normalized: string; | |
| 97 | + cancer_id: string | null; | |
| 98 | + cancer_slug: string | null; | |
| 99 | + cancer_name: string | null; | |
| 100 | + match_type: string; | |
| 101 | + confidence: number | null; | |
| 102 | +} | |
| 103 | +export async function trialConditionsFor(trialId: string): Promise<TrialCondition[]> { | |
| 104 | + return safe(() => run<TrialCondition>(sql`SELECT tc.condition_text, tc.normalized, tc.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, tc.match_type, tc.confidence FROM trial_conditions tc LEFT JOIN cancers c ON c.id = tc.cancer_id WHERE tc.trial_id = ${trialId} ORDER BY tc.condition_text`), [] as TrialCondition[]); | |
| 105 | +} | |
| 106 | + | |
| 107 | +export interface TrialIntervention { | |
| 108 | + name: string; | |
| 109 | + intervention_type: string | null; | |
| 110 | + drug_id: string | null; | |
| 111 | + drug_slug: string | null; | |
| 112 | + drug_name: string | null; | |
| 113 | + match_type: string; | |
| 114 | +} | |
| 115 | +export async function trialInterventionsFor(trialId: string): Promise<TrialIntervention[]> { | |
| 116 | + return safe(() => run<TrialIntervention>(sql`SELECT ti.name, ti.intervention_type, ti.drug_id, d.slug AS drug_slug, d.name AS drug_name, ti.match_type FROM trial_interventions ti LEFT JOIN drugs d ON d.id = ti.drug_id WHERE ti.trial_id = ${trialId} ORDER BY ti.name`), [] as TrialIntervention[]); | |
| 117 | +} | |
| 118 | + | |
| 119 | +export async function trialLocationsByCountry(trialId: string): Promise<Array<{ country: string; n: number; recruiting: number }>> { | |
| 120 | + const rows = await safe(() => run<{ country: string; n: string; recruiting: string }>(sql`SELECT coalesce(country, 'Unknown') AS country, count(*) AS n, count(*) FILTER (WHERE status = 'RECRUITING') AS recruiting FROM trial_locations WHERE trial_id = ${trialId} GROUP BY 1 ORDER BY n DESC`), [] as Array<{ country: string; n: string; recruiting: string }>); | |
| 121 | + return rows.map((r) => ({ country: r.country, n: Number(r.n), recruiting: Number(r.recruiting) })); | |
| 122 | +} | |
| 123 | + | |
| 124 | +export async function trialsForDrug(drugId: string, limit = 100): Promise<TrialRow[]> { | |
| 125 | + return safe(() => run<TrialRow>(sql`SELECT t.* FROM clinical_trials t WHERE EXISTS (SELECT 1 FROM trial_interventions ti WHERE ti.trial_id = t.id AND ti.drug_id = ${drugId}) ORDER BY t.last_update_posted_date DESC NULLS LAST LIMIT ${limit}`), [] as TrialRow[]); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** Aggregate view for the home "Most active clinical research" module. */ | |
| 129 | +export async function mostActiveResearch(limit = 10): Promise<Array<{ slug: string; canonical_name: string; active_trial_count: number; recruiting_trial_count: number; computed_at: Date }>> { | |
| 130 | + return safe( | |
| 131 | + () => | |
| 132 | + run<{ slug: string; canonical_name: string; active_trial_count: number; recruiting_trial_count: number; computed_at: Date }>(sql` | |
| 133 | + SELECT c.slug, c.canonical_name, ec.active_trial_count, ec.recruiting_trial_count, ec.updated_at AS computed_at FROM entity_counters ec JOIN cancers c ON c.id = ec.entity_id | |
| 134 | + WHERE ec.entity_type = 'cancer' AND c.status = 'active' AND c.malignant AND ec.active_trial_count > 0 ORDER BY ec.active_trial_count DESC, c.canonical_name LIMIT ${limit}`), | |
| 135 | + [], | |
| 136 | + ); | |
| 137 | +} | |
| 138 | + | |
| 139 | +export async function mostCuratedEvidence(limit = 10): Promise<Array<{ slug: string; canonical_name: string; evidence_count: number; gene_count: number; computed_at: Date }>> { | |
| 140 | + return safe( | |
| 141 | + () => | |
| 142 | + run<{ slug: string; canonical_name: string; evidence_count: number; gene_count: number; computed_at: Date }>(sql` | |
| 143 | + SELECT c.slug, c.canonical_name, ec.evidence_count, ec.gene_count, ec.updated_at AS computed_at FROM entity_counters ec JOIN cancers c ON c.id = ec.entity_id | |
| 144 | + WHERE ec.entity_type = 'cancer' AND c.status = 'active' AND c.malignant AND ec.evidence_count > 0 ORDER BY ec.evidence_count DESC, c.canonical_name LIMIT ${limit}`), | |
| 145 | + [], | |
| 146 | + ); | |
| 147 | +} | |
| 148 | + | |
| 149 | +export async function trialNctForSitemap(offset: number, limit: number): Promise<Array<{ nct_id: string; updated_at: Date }>> { | |
| 150 | + return safe(() => run<{ nct_id: string; updated_at: Date }>(sql`SELECT nct_id, updated_at FROM clinical_trials ORDER BY id LIMIT ${limit} OFFSET ${offset}`), []); | |
| 151 | +} | |
| 152 | +export async function countTrials(): Promise<number> { | |
| 153 | + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM clinical_trials`), [{ n: '0' }]); | |
| 154 | + return Number(r[0]?.n ?? 0); | |
| 155 | +} | |
added
apps/web/src/lib/search-params.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +/** URL state helpers for server-side filtered pages (§295). */ | |
| 2 | +export type SP = Record<string, string | string[] | undefined>; | |
| 3 | + | |
| 4 | +export function str(sp: SP, key: string, fallback = ''): string { | |
| 5 | + const v = sp[key]; | |
| 6 | + const s = Array.isArray(v) ? v[0] : v; | |
| 7 | + return (s ?? fallback).toString().trim(); | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function int(sp: SP, key: string, fallback: number, min = -Infinity, max = Infinity): number { | |
| 11 | + const n = Number.parseInt(str(sp, key, ''), 10); | |
| 12 | + if (!Number.isFinite(n)) return fallback; | |
| 13 | + return Math.min(max, Math.max(min, n)); | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function oneOf<T extends string>(sp: SP, key: string, allowed: readonly T[], fallback: T): T { | |
| 17 | + const v = str(sp, key, ''); | |
| 18 | + return (allowed as readonly string[]).includes(v) ? (v as T) : fallback; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function bool(sp: SP, key: string): boolean | null { | |
| 22 | + const v = str(sp, key, ''); | |
| 23 | + if (v === '1' || v === 'true' || v === 'yes') return true; | |
| 24 | + if (v === '0' || v === 'false' || v === 'no') return false; | |
| 25 | + return null; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Build a query string from the current params with overrides (drops empty values). */ | |
| 29 | +export function withParams(current: Record<string, string | number | null | undefined>, overrides: Record<string, string | number | null | undefined>): string { | |
| 30 | + const merged = { ...current, ...overrides }; | |
| 31 | + const qs = new URLSearchParams(); | |
| 32 | + for (const [k, v] of Object.entries(merged)) { | |
| 33 | + if (v == null || v === '' || v === 'all' && k !== 'level') continue; | |
| 34 | + qs.set(k, String(v)); | |
| 35 | + } | |
| 36 | + const s = qs.toString(); | |
| 37 | + return s ? `?${s}` : ''; | |
| 38 | +} | |
added
apps/web/src/lib/seo.ts
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +import { SITE_URL, SITE_NAME } from './site'; | |
| 2 | +import { toDate } from './format'; | |
| 3 | + | |
| 4 | +/** Serialize JSON-LD safely for a <script type="application/ld+json"> block. */ | |
| 5 | +export function jsonLd(obj: Record<string, unknown>): string { | |
| 6 | + return JSON.stringify(obj).replace(/</g, '\\u003c'); | |
| 7 | +} | |
| 8 | + | |
| 9 | +/** Schema.org MedicalCondition — only fields we actually hold (§148). */ | |
| 10 | +export function medicalConditionLd(c: { slug: string; canonicalName: string; description: string | null; aliases: string[]; codes: Array<{ system: string; code: string }> }) { | |
| 11 | + const codeSystemName: Record<string, string> = { ncit: 'NCIt', icd10: 'ICD-10', icd10cm: 'ICD-10-CM', oncotree: 'OncoTree', doid: 'DOID', umls: 'UMLS', mesh: 'MeSH', mondo: 'MONDO' }; | |
| 12 | + const ld: Record<string, unknown> = { | |
| 13 | + '@context': 'https://schema.org', | |
| 14 | + '@type': 'MedicalCondition', | |
| 15 | + name: c.canonicalName, | |
| 16 | + url: `${SITE_URL}/cancer/${c.slug}`, | |
| 17 | + }; | |
| 18 | + if (c.description) ld.description = c.description; | |
| 19 | + if (c.aliases.length) ld.alternateName = c.aliases.slice(0, 20); | |
| 20 | + const codes = c.codes.filter((k) => codeSystemName[k.system]).map((k) => ({ '@type': 'MedicalCode', codeValue: k.code, codingSystem: codeSystemName[k.system] })); | |
| 21 | + if (codes.length) ld.code = codes; | |
| 22 | + return ld; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function drugLd(d: { slug: string; name: string; description: string | null; mechanism: string | null; aliases: string[] }) { | |
| 26 | + const ld: Record<string, unknown> = { '@context': 'https://schema.org', '@type': 'Drug', name: d.name, url: `${SITE_URL}/drug/${d.slug}` }; | |
| 27 | + if (d.description) ld.description = d.description; | |
| 28 | + if (d.mechanism) ld.mechanismOfAction = d.mechanism; | |
| 29 | + if (d.aliases.length) ld.alternateName = d.aliases.slice(0, 20); | |
| 30 | + return ld; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function datasetLd(d: { name: string; description: string; url: string; distributionUrl: string; license: string; dateModified?: Date | string | null }) { | |
| 34 | + const ld: Record<string, unknown> = { | |
| 35 | + '@context': 'https://schema.org', | |
| 36 | + '@type': 'Dataset', | |
| 37 | + name: d.name, | |
| 38 | + description: d.description, | |
| 39 | + url: d.url, | |
| 40 | + license: d.license, | |
| 41 | + creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL }, | |
| 42 | + distribution: [{ '@type': 'DataDownload', encodingFormat: 'text/csv', contentUrl: d.distributionUrl }], | |
| 43 | + }; | |
| 44 | + const dm = toDate(d.dateModified); | |
| 45 | + if (dm) ld.dateModified = dm.toISOString(); | |
| 46 | + return ld; | |
| 47 | +} | |
added
apps/web/src/lib/site.ts
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.cancerindex.io'; | |
| 2 | +export const SITE_NAME = 'CancerIndex'; | |
| 3 | +export const TAGLINE = 'The global index of cancer.'; | |
| 4 | +export const DISCLAIMER = | |
| 5 | + 'CancerIndex is a research and information platform. It is not a physician, does not diagnose, and does not recommend treatment. Population statistics do not predict individual outcomes.'; | |
| 6 | +export const CONTACT_EMAIL = 'corrections@cancerindex.io'; | |
| 7 | + | |
| 8 | +export const NAV = [ | |
| 9 | + { href: '/cancers', label: 'Cancers' }, | |
| 10 | + { href: '/taxonomy', label: 'Taxonomy' }, | |
| 11 | + { href: '/rankings', label: 'Rankings' }, | |
| 12 | + { href: '/genes', label: 'Genes' }, | |
| 13 | + { href: '/drugs', label: 'Drugs' }, | |
| 14 | + { href: '/trials', label: 'Trials' }, | |
| 15 | + { href: '/sources', label: 'Sources' }, | |
| 16 | + { href: '/methodology', label: 'Methodology' }, | |
| 17 | +] as const; | |
| 18 | + | |
| 19 | +export const FOOTER_NAV = [ | |
| 20 | + { href: '/about', label: 'About' }, | |
| 21 | + { href: '/trust', label: 'Trust & policies' }, | |
| 22 | + { href: '/data', label: 'Data downloads' }, | |
| 23 | + { href: '/developers', label: 'Developers' }, | |
| 24 | + { href: '/sources', label: 'Sources' }, | |
| 25 | + { href: '/methodology', label: 'Methodology' }, | |
| 26 | +] as const; | |
| 27 | + | |
| 28 | +/** External identifier browsers (chips on cancer pages link out to the authority). */ | |
| 29 | +export function codeUrl(system: string, code: string): string | null { | |
| 30 | + switch (system) { | |
| 31 | + case 'ncit': | |
| 32 | + return `https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${encodeURIComponent(code)}`; | |
| 33 | + case 'oncotree': | |
| 34 | + return `https://oncotree.mskcc.org/?version=oncotree_latest_stable&field=CODE&search=${encodeURIComponent(code)}`; | |
| 35 | + case 'icd10': | |
| 36 | + case 'icd10cm': | |
| 37 | + return `https://icd.who.int/browse10/2019/en#/${encodeURIComponent(code)}`; | |
| 38 | + case 'doid': | |
| 39 | + return `https://disease-ontology.org/?id=${encodeURIComponent(code.startsWith('DOID:') ? code : `DOID:${code}`)}`; | |
| 40 | + case 'umls': | |
| 41 | + return `https://uts.nlm.nih.gov/uts/umls/concept/${encodeURIComponent(code)}`; | |
| 42 | + case 'mesh': | |
| 43 | + return `https://meshb.nlm.nih.gov/record/ui?ui=${encodeURIComponent(code)}`; | |
| 44 | + case 'mondo': | |
| 45 | + return `https://monarchinitiative.org/${encodeURIComponent(code)}`; | |
| 46 | + case 'efo': | |
| 47 | + return `https://www.ebi.ac.uk/efo/${encodeURIComponent(code)}`; | |
| 48 | + case 'orphanet': | |
| 49 | + return `https://www.orpha.net/en/disease/detail/${encodeURIComponent(code.replace(/^ORPHA:?/i, ''))}`; | |
| 50 | + case 'gdc_project': | |
| 51 | + return `https://portal.gdc.cancer.gov/projects/${encodeURIComponent(code)}`; | |
| 52 | + default: | |
| 53 | + return null; | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +export const CODE_SYSTEM_LABEL: Record<string, string> = { | |
| 58 | + ncit: 'NCIt', | |
| 59 | + oncotree: 'OncoTree', | |
| 60 | + icd10: 'ICD-10', | |
| 61 | + icd10cm: 'ICD-10-CM', | |
| 62 | + icdo_topography: 'ICD-O topography', | |
| 63 | + icdo_morphology: 'ICD-O morphology', | |
| 64 | + doid: 'DOID', | |
| 65 | + umls: 'UMLS CUI', | |
| 66 | + mesh: 'MeSH', | |
| 67 | + mondo: 'MONDO', | |
| 68 | + seer_site: 'SEER site', | |
| 69 | + efo: 'EFO', | |
| 70 | + orphanet: 'Orphanet', | |
| 71 | + gdc_project: 'GDC project', | |
| 72 | +}; | |
added
apps/web/src/lib/sitemap.ts
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { SITE_URL, NAV, FOOTER_NAV } from '@/lib/site'; | |
| 3 | +import { cancerSlugsForSitemap, countActiveCancers } from '@/lib/queries/cancers'; | |
| 4 | +import { geneSymbolsForSitemap, countGenes } from '@/lib/queries/genomics'; | |
| 5 | +import { drugSlugsForSitemap, countDrugs } from '@/lib/queries/drugs'; | |
| 6 | +import { trialNctForSitemap, countTrials } from '@/lib/queries/trials'; | |
| 7 | +import { listSources } from '@/lib/queries/sources'; | |
| 8 | +import { listMetrics } from '@/lib/queries/rankings'; | |
| 9 | +import { toDate } from '@/lib/format'; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Chunked sitemaps: chunk 0 = static pages + sources + rankings; then one chunk per 20k entities of | |
| 13 | + * each kind (cancers, genes, drugs, trials) so the index stays under the 50k-URL limit per file. | |
| 14 | + */ | |
| 15 | +export const PAGE = 20_000; | |
| 16 | +type Kind = 'cancer' | 'gene' | 'drug' | 'trial'; | |
| 17 | +export interface Chunk { | |
| 18 | + id: number; | |
| 19 | + kind: Kind; | |
| 20 | + offset: number; | |
| 21 | +} | |
| 22 | +export interface Entry { | |
| 23 | + url: string; | |
| 24 | + lastModified: Date; | |
| 25 | + changeFrequency: 'daily' | 'weekly'; | |
| 26 | + priority: number; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export async function sitemapChunks(): Promise<Chunk[]> { | |
| 30 | + const [c, g, d, t] = await Promise.all([countActiveCancers(), countGenes(), countDrugs(), countTrials()]); | |
| 31 | + const out: Chunk[] = []; | |
| 32 | + let id = 1; | |
| 33 | + for (const [kind, n] of [ | |
| 34 | + ['cancer', c], | |
| 35 | + ['gene', g], | |
| 36 | + ['drug', d], | |
| 37 | + ['trial', t], | |
| 38 | + ] as Array<[Kind, number]>) { | |
| 39 | + for (let off = 0; off < Math.max(n, 1); off += PAGE) out.push({ id: id++, kind, offset: off }); | |
| 40 | + } | |
| 41 | + return out; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export async function sitemapEntries(id: number): Promise<Entry[]> { | |
| 45 | + const now = new Date(); | |
| 46 | + if (id === 0) { | |
| 47 | + const [sources, metrics] = await Promise.all([listSources(), listMetrics()]); | |
| 48 | + return [ | |
| 49 | + { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1 }, | |
| 50 | + ...NAV.map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.8 })), | |
| 51 | + ...FOOTER_NAV.filter((f) => !NAV.some((n) => n.href === f.href)).map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.5 })), | |
| 52 | + ...sources.map((s) => ({ url: `${SITE_URL}/source/${s.slug}`, lastModified: toDate(s.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.6 })), | |
| 53 | + ...metrics.filter((m) => m.snapshot_count > 0).map((m) => ({ url: `${SITE_URL}/rankings/${m.slug}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.7 })), | |
| 54 | + ]; | |
| 55 | + } | |
| 56 | + const chunk = (await sitemapChunks()).find((c) => c.id === id); | |
| 57 | + if (!chunk) return []; | |
| 58 | + switch (chunk.kind) { | |
| 59 | + case 'cancer': | |
| 60 | + return (await cancerSlugsForSitemap(chunk.offset, PAGE)).map((r) => ({ url: `${SITE_URL}/cancer/${r.slug}`, lastModified: toDate(r.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.7 })); | |
| 61 | + case 'gene': | |
| 62 | + return (await geneSymbolsForSitemap(chunk.offset, PAGE)).map((r) => ({ url: `${SITE_URL}/gene/${r.symbol}`, lastModified: toDate(r.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.5 })); | |
| 63 | + case 'drug': | |
| 64 | + return (await drugSlugsForSitemap(chunk.offset, PAGE)).map((r) => ({ url: `${SITE_URL}/drug/${r.slug}`, lastModified: toDate(r.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.5 })); | |
| 65 | + case 'trial': | |
| 66 | + return (await trialNctForSitemap(chunk.offset, PAGE)).map((r) => ({ url: `${SITE_URL}/trial/${r.nct_id}`, lastModified: toDate(r.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.4 })); | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| 71 | + | |
| 72 | +export function urlsetXml(entries: Entry[]): string { | |
| 73 | + return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => ` <url><loc>${esc(e.url)}</loc><lastmod>${e.lastModified.toISOString()}</lastmod><changefreq>${e.changeFrequency}</changefreq><priority>${e.priority}</priority></url>`).join('\n')}\n</urlset>\n`; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function indexXml(ids: number[]): string { | |
| 77 | + const now = new Date().toISOString(); | |
| 78 | + return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${ids.map((id) => ` <sitemap><loc>${SITE_URL}/sitemap/${id}</loc><lastmod>${now}</lastmod></sitemap>`).join('\n')}\n</sitemapindex>\n`; | |
| 79 | +} | |
added
apps/web/src/proxy.ts
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { NextResponse, type NextRequest } from 'next/server'; | |
| 2 | +import { createHash } from 'node:crypto'; | |
| 3 | + | |
| 4 | +const ADMIN_COOKIE = 'ci_admin'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Request proxy (Next 16 name for middleware): exchanges `/admin…?token=<ADMIN_TOKEN>` for the | |
| 8 | + * httpOnly admin cookie and redirects to the same URL without the token, so the token never stays | |
| 9 | + * in the address bar or in server logs beyond the first hit. | |
| 10 | + */ | |
| 11 | +export function proxy(req: NextRequest) { | |
| 12 | + const url = req.nextUrl; | |
| 13 | + if (url.pathname.startsWith('/admin') && url.searchParams.has('token')) { | |
| 14 | + const token = url.searchParams.get('token') ?? ''; | |
| 15 | + const expected = process.env.ADMIN_TOKEN ?? ''; | |
| 16 | + const clean = url.clone(); | |
| 17 | + clean.searchParams.delete('token'); | |
| 18 | + const res = NextResponse.redirect(clean); | |
| 19 | + if (expected && token === expected) { | |
| 20 | + res.cookies.set(ADMIN_COOKIE, createHash('sha256').update(token).digest('hex'), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 }); | |
| 21 | + } | |
| 22 | + return res; | |
| 23 | + } | |
| 24 | + return NextResponse.next(); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export const config = { matcher: ['/admin/:path*', '/admin'] }; | |
added
apps/web/tsconfig.json
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "lib": ["dom", "dom.iterable", "esnext"], | |
| 5 | + "allowJs": true, | |
| 6 | + "skipLibCheck": true, | |
| 7 | + "strict": true, | |
| 8 | + "noUncheckedIndexedAccess": true, | |
| 9 | + "noEmit": true, | |
| 10 | + "esModuleInterop": true, | |
| 11 | + "module": "esnext", | |
| 12 | + "moduleResolution": "bundler", | |
| 13 | + "resolveJsonModule": true, | |
| 14 | + "isolatedModules": true, | |
| 15 | + "jsx": "react-jsx", | |
| 16 | + "incremental": true, | |
| 17 | + "plugins": [{ "name": "next" }], | |
| 18 | + "paths": { "@/*": ["./src/*"] } | |
| 19 | + }, | |
| 20 | + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], | |
| 21 | + "exclude": ["node_modules"] | |
| 22 | +} | |
added
apps/web/vitest.config.ts
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from 'vitest/config'; | |
| 2 | +import path from 'node:path'; | |
| 3 | + | |
| 4 | +export default defineConfig({ | |
| 5 | + test: { include: ['src/**/*.test.ts'], environment: 'node', passWithNoTests: true }, | |
| 6 | + resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, | |
| 7 | +}); | |
added
data/import/seer-explorer/lung-and-bronchus-mortality-trends.csv
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +"Lung and Bronchus" | |
| 2 | +"Long-Term Trends in U.S. Age-Adjusted Mortality Rates, 1975-2024" | |
| 3 | +"By Sex, All Races / Ethnicities, All Ages" | |
| 4 | + | |
| 5 | +"Regression Line Segment Trends (shown on graph)" | |
| 6 | +"Sex","Annual Percent Change (APC) Estimates","Annual Percent Change (APC) Estimates","Annual Percent Change (APC) Estimates","Annual Percent Change (APC) Estimates","Annual Percent Change (APC) Estimates","Annual Percent Change (APC) Estimates" | |
| 7 | +"Sex","Year Range","APC (%)","Lower 95% C.I.","Upper 95% C.I.","P-Value","Direction" | |
| 8 | +"Both Sexes","1975-1980","3.0","2.6","3.5","<0.01","Rising" | |
| 9 | +"Both Sexes","1980-1990","1.8","1.7","1.9","<0.01","Rising" | |
| 10 | +"Both Sexes","1990-1995","-0.2","-0.5","0.3","0.20","Not Significant" | |
| 11 | +"Both Sexes","1995-2005","-1.0","-1.1","-0.8","<0.01","Falling" | |
| 12 | +"Both Sexes","2005-2014","-2.3","-2.5","-2.2","<0.01","Falling" | |
| 13 | +"Both Sexes","2014-2020","-4.5","-4.9","-3.3","<0.01","Falling" | |
| 14 | +"Both Sexes","2020-2024","-3.7","-4.1","-2.9","<0.01","Falling" | |
| 15 | +"Female","1975-1982","6.0","5.5","6.5","<0.01","Rising" | |
| 16 | +"Female","1982-1990","4.2","3.8","5.7","<0.01","Rising" | |
| 17 | +"Female","1990-1995","1.7","1.4","4.2","<0.01","Rising" | |
| 18 | +"Female","1995-2003","0.3","0.1","1.7","0.03","Rising" | |
| 19 | +"Female","2003-2007","-0.8","-1.4","0.3","0.15","Not Significant" | |
| 20 | +"Female","2007-2014","-1.8","-2.2","-1.5","<0.01","Falling" | |
| 21 | +"Female","2014-2020","-3.9","-4.4","-3.6","<0.01","Falling" | |
| 22 | +"Female","2020-2024","-3.0","-3.4","-2.1","<0.01","Falling" | |
| 23 | +"Male","1975-1982","1.8","1.5","2.1","<0.01","Rising" | |
| 24 | +"Male","1982-1991","0.4","0.3","0.5","<0.01","Rising" | |
| 25 | +"Male","1991-2005","-1.9","-2.0","-1.8","<0.01","Falling" | |
| 26 | +"Male","2005-2013","-2.9","-3.1","-2.7","<0.01","Falling" | |
| 27 | +"Male","2013-2024","-4.7","-4.8","-4.6","<0.01","Falling" | |
| 28 | + | |
| 29 | + | |
| 30 | +"Recent 5 and 10 Year Trends" | |
| 31 | +"Sex","Average Annual Percent Change (AAPC) Estimates","Average Annual Percent Change (AAPC) Estimates","Average Annual Percent Change (AAPC) Estimates","Average Annual Percent Change (AAPC) Estimates","Average Annual Percent Change (AAPC) Estimates","Average Annual Percent Change (AAPC) Estimates" | |
| 32 | +"Sex","Year Range","AAPC (%)","Lower 95% C.I.","Upper 95% C.I.","P-Value","Direction" | |
| 33 | +"Both Sexes","2015-2024","-4.1","-4.3","-4.0","<0.01","Falling" | |
| 34 | +"Both Sexes","2020-2024","-3.7","-4.1","-3.4","<0.01","Falling" | |
| 35 | +"Female","2015-2024","-3.5","-3.6","-3.4","<0.01","Falling" | |
| 36 | +"Female","2020-2024","-3.0","-3.4","-2.7","<0.01","Falling" | |
| 37 | +"Male","2015-2024","-4.7","-4.8","-4.6","<0.01","Falling" | |
| 38 | +"Male","2020-2024","-4.7","-4.8","-4.6","<0.01","Falling" | |
| 39 | + | |
| 40 | + | |
| 41 | +"Data Source:" | |
| 42 | +"-U.S. Mortality Data (1969-2024), National Center for Health Statistics, CDC." | |
| 43 | +"Methodology:" | |
| 44 | +"-Rates are per 100,000 and are age-adjusted to the 2000 US Std Population (20 age groups - Census P25-1130)." | |
| 45 | +"-The Annual Percent Change (APC) and Average Annual Percent Change (AAPC) estimates were calculated from the underlying rates using the Joinpoint Trend Analysis Software [https://surveillance.cancer.gov/joinpoint], Version 6.0, March 2026, National Cancer Institute using the default settings. The underlying rates supplied to Joinpoint were exported from SEER*Stat at the maximum available precision and included the standard error for the rate." | |
| 46 | +"-The APC's/AAPC's direction is 'Rising' when the entire 95% confidence interval (C.I.) is above 0, 'Falling' when the entire 95% C.I. is lower than 0, otherwise, the trend is considered 'Not Significant'." | |
| 47 | +"Race/Ethnicity Coding:" | |
| 48 | +"-For years prior to 1990, the Census Bureau has only provided county-level population estimates for White, Black, and Other races." | |
| 49 | +"Cancer Site Coding:" | |
| 50 | +"-Cancer site cause of death is defined using the SEER Cause of Death Recode 1969+ (03/01/2018) [https://seer.cancer.gov/codrecode/1969_d03012018/index.html]." | |
| 51 | +Created by https://seer.cancer.gov/statistics-network/explorer/ on Tue Sep 08 2026. | |
| \ No newline at end of file | ||
added
data/import/seer-explorer/lung-and-bronchus-relative-survival-2000-2022.csv
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +"Lung and Bronchus" | |
| 2 | +"SEER Relative Survival Rates by Time Since Diagnosis, 2000-2022" | |
| 3 | +"By Sex, All Races / Ethnicities, All Ages, All Stages" | |
| 4 | + | |
| 5 | +"","Both Sexes","Both Sexes","Both Sexes","Female","Female","Female","Male","Male","Male" | |
| 6 | +"Time Since Diagnosis","Relative Survival (%)","Lower 95% C.I.","Upper 95% C.I.","Relative Survival (%)","Lower 95% C.I.","Upper 95% C.I.","Relative Survival (%)","Lower 95% C.I.","Upper 95% C.I." | |
| 7 | +"Diagnosis","100.0","^","^","100.0","^","^","100.0","^","^" | |
| 8 | +"1 year","47.4","47.4","47.5","51.7","51.6","51.9","43.6","43.5","43.7" | |
| 9 | +"2 years","33.7","33.7","33.8","38.3","38.2","38.4","29.6","29.5","29.7" | |
| 10 | +"3 years","27.5","27.5","27.6","31.9","31.7","32.0","23.6","23.5","23.7" | |
| 11 | +"4 years","24.0","23.9","24.0","28.0","27.9","28.1","20.3","20.2","20.4" | |
| 12 | +"5 years","21.5","21.4","21.6","25.2","25.1","25.4","18.0","17.9","18.1" | |
| 13 | +"6 years","19.6","19.5","19.7","23.2","23.0","23.3","16.3","16.2","16.5" | |
| 14 | +"7 years","18.1","18.0","18.1","21.5","21.3","21.6","14.9","14.8","15.0" | |
| 15 | +"8 years","16.7","16.7","16.8","20.0","19.9","20.1","13.8","13.7","13.9" | |
| 16 | +"9 years","15.6","15.5","15.7","18.8","18.6","18.9","12.8","12.6","12.9" | |
| 17 | +"10 years","14.6","14.5","14.7","17.6","17.5","17.8","11.9","11.7","12.0" | |
| 18 | + | |
| 19 | + | |
| 20 | +"Data Source:" | |
| 21 | +"-SEER Incidence Data, November 2025 Submission (1975-2023), SEER 21 registries [https://seer.cancer.gov/registries/terms.html] (excluding Illinois)." | |
| 22 | +"-Expected Survival Life Tables [https://seer.cancer.gov/expsurvival/] by Socio-Economic Standards." | |
| 23 | +"Methodology:" | |
| 24 | +"-The relative survival rates are calculated using monthly intervals." | |
| 25 | +"Race/Ethnicity Coding:" | |
| 26 | +"-For more details on SEER race/ethnicity groupings, please see Race and Hispanic Ethnicity Changes [https://seer.cancer.gov/seerstat/variables/seer/race_ethnicity/]." | |
| 27 | +"-Incidence data for Hispanics and Non-Hispanics are based on the NAACCR Hispanic Latino Identification Algorithm (NHIA)." | |
| 28 | +"-Rates for American Indians/Alaska Natives only include cases that are in a Purchased/Referred Care Delivery Area (PRCDA)." | |
| 29 | +"Cancer Site Coding:" | |
| 30 | +"-See SEER*Explorer Cancer Site Definitions [https://seer.cancer.gov/statistics-network/explorer/cancer-sites.html] for details about the cancer site coding used for SEER Incidence data." | |
| 31 | +"-Stage at diagnosis is calculated using the Combined Summary Stage (2004+) recode, created from SEER Combined Summary Stage 2000 (2004-2017) & Derived Summary Stage 2018 (2018+). For more information, see the SEER documentation [https://seer.cancer.gov/seerstat/variables/seer/lrd-stage]." | |
| 32 | +Created by https://seer.cancer.gov/statistics-network/explorer/ on Tue Sep 08 2026. | |
| \ No newline at end of file | ||
added
deploy/README.md
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +# Deploying CancerIndex on the MacLustr cluster | |
| 2 | + | |
| 3 | +Everything goes through the gateway **M1M32** and the `mld` orchestrator (`~/Desktop/cluster-skill/mld` | |
| 4 | +on the laptop relays to `M1M32:~/dispatch`). Nothing in this folder runs on the cluster by itself; | |
| 5 | +these are the files and the procedure. | |
| 6 | + | |
| 7 | +## What gets deployed | |
| 8 | + | |
| 9 | +| Piece | Where | Port | | |
| 10 | +|---|---|---| | |
| 11 | +| `cancerindex-web` (PM2) | `~/apps/cancerindex/apps/web`, `next start -p 8250 -H 0.0.0.0` | 8250 (public via ngrok) | | |
| 12 | +| `cancerindex-api` (PM2) | `~/apps/cancerindex`, `tsx apps/api/src/server.ts` | 8251 (127.0.0.1) | | |
| 13 | +| `cancerindex-worker` (PM2) | `~/apps/cancerindex`, `tsx workers/main.ts` | — | | |
| 14 | +| `cancerindex-ngrok` | `www.cancerindex.io` → 8250 (reserved domain) | — | | |
| 15 | +| PostgreSQL 17 | database `cancerindex` on the node (extensions `pg_trgm`, `unaccent`, `vector`) | 5432 | | |
| 16 | + | |
| 17 | +Preferred node: **M4M64b** (Postgres 17 + pgvector, pnpm, redis, 772 GB disk). Avoid M3U96b | |
| 18 | +(hfmarketdata) and M1M32 (gateway). Requires ≈ 6 GB RAM. | |
| 19 | + | |
| 20 | +## Procedure | |
| 21 | + | |
| 22 | +1. **Manifest** — copy `deploy/mld-manifest.cancerindex.json` to `M1M32:~/dispatch/apps/cancerindex.json` | |
| 23 | + and replace the `{{ADMIN_TOKEN}}` placeholder with a real secret (or set it through | |
| 24 | + `env_overrides`). `{{HOME}}` is expanded by `mld`. | |
| 25 | + | |
| 26 | + ```bash | |
| 27 | + scp deploy/mld-manifest.cancerindex.json M1M32:~/dispatch/apps/cancerindex.json | |
| 28 | + ssh M1M32 "sed -i '' 's/{{ADMIN_TOKEN}}/'\"$(openssl rand -hex 24)\"'/g' ~/dispatch/apps/cancerindex.json" | |
| 29 | + ``` | |
| 30 | + | |
| 31 | +2. **Stage** — from a clean export of the repository (no `node_modules`, no `.env`, no data lake): | |
| 32 | + | |
| 33 | + ```bash | |
| 34 | + cd /path/to/cancerindex | |
| 35 | + rm -rf /tmp/cancerindex-stage && mkdir -p /tmp/cancerindex-stage | |
| 36 | + git archive HEAD | tar -x -C /tmp/cancerindex-stage | |
| 37 | + ~/Desktop/cluster-skill/mld stage /tmp/cancerindex-stage cancerindex | |
| 38 | + ``` | |
| 39 | + | |
| 40 | + Source of truth for the code is spbgit `cancerindex.git` (`ssh gitsrv`); push there first. | |
| 41 | + | |
| 42 | +3. **Deploy** — `mld` scores the nodes, syncs to `~/apps/cancerindex`, runs the `post_sync` hooks | |
| 43 | + (`pnpm install --frozen-lockfile`, `createdb cancerindex` if missing, extensions, `db:migrate`, | |
| 44 | + `db:seed`, `cix sources:sync`, `mkdir data/raw data/cache logs`, `next build`), starts the PM2 | |
| 45 | + processes and the ngrok tunnel, and checks `/healthz`: | |
| 46 | + | |
| 47 | + ```bash | |
| 48 | + ~/Desktop/cluster-skill/mld plan cancerindex | |
| 49 | + ~/Desktop/cluster-skill/mld deploy cancerindex # or --node M4M64b | |
| 50 | + ~/Desktop/cluster-skill/mld status --live | |
| 51 | + ``` | |
| 52 | + | |
| 53 | +4. **First ingestion** — on the node, in order (terminology → genes → evidence/genomics/variants → | |
| 54 | + trials → literature → epidemiology), then counters and rankings: | |
| 55 | + | |
| 56 | + ```bash | |
| 57 | + ssh M4M64b 'cd ~/apps/cancerindex && bash deploy/first-run.sh' | |
| 58 | + ``` | |
| 59 | + | |
| 60 | + `first-run.sh` is idempotent: connectors are restartable (cursors), records are hashed, and the | |
| 61 | + script re-runs a connector until its last run is `succeeded` or the retry budget is spent. | |
| 62 | + Expect ClinicalTrials.gov and PubMed to need several 45-minute windows; the worker's cron picks | |
| 63 | + up where the script stops. | |
| 64 | + | |
| 65 | +5. **Verify** | |
| 66 | + | |
| 67 | + ```bash | |
| 68 | + curl -fsS http://127.0.0.1:8251/healthz | |
| 69 | + curl -fsS http://127.0.0.1:8251/v1/stats | jq .data | |
| 70 | + curl -fsS "http://127.0.0.1:8251/v1/sources" | jq '.data[] | {slug, status, health: .connector.health}' | |
| 71 | + pm2 ls && pm2 logs cancerindex-worker --lines 50 | |
| 72 | + curl -fsS https://www.cancerindex.io/healthz | |
| 73 | + ``` | |
| 74 | + | |
| 75 | +## Day-2 operations | |
| 76 | + | |
| 77 | +- **Redeploy code**: push to spbgit, `git archive` → `mld stage` → `mld deploy cancerindex` (hooks | |
| 78 | + rebuild the web app; PM2 restarts). Migrations run in `post_sync` (`pnpm db:migrate`). | |
| 79 | +- **Move node**: `mld move cancerindex --to <node>` — the data lake (`data/raw`) is excluded from sync; | |
| 80 | + copy it separately (`rsync -a M4M64b:~/apps/cancerindex/data/raw/ <node>:~/apps/cancerindex/data/raw/`) | |
| 81 | + and dump/restore the database (`pg_dump -Fc cancerindex`). | |
| 82 | +- **Backups**: `pg_dump -Fc cancerindex` + `data/raw` (append-only). Both are needed for TRACE. | |
| 83 | +- **Trigger a run**: `pnpm cix run <id>` on the node, or `POST /v1/admin/connectors/<id>/run` with | |
| 84 | + `x-admin-token`, or `tsx workers/cli.ts run <id>`. | |
| 85 | +- **Pause a connector**: `POST /v1/admin/connectors/<id>/pause` (worker skips scheduled runs). | |
| 86 | +- **Logs**: pino JSON via PM2 (`pm2 logs cancerindex-api`), `CI_SERVICE` labels api/worker. | |
| 87 | +- **Schedules** (UTC, from manifests): civic 02:00 daily, clinicaltrials 02:30 daily, pubmed 03:00 | |
| 88 | + daily, ncit-evs 03:00 on the 1st, oncotree 04:00 Mondays, hgnc/clinvar 05:00 Tuesdays, gdc 06:00 | |
| 89 | + on the 1st; counters 06:00, rank 06:30, health probe hourly at :15. Check with | |
| 90 | + `tsx workers/cli.ts schedules`. | |
| 91 | + | |
| 92 | +## Gotchas | |
| 93 | + | |
| 94 | +- `pg-boss` creates its own schema `pgboss` in the same database on first worker start; the API only | |
| 95 | + enqueues (no supervision). Queue policy is `stately` (one job per connector queued/active). | |
| 96 | +- `mld` syncs with the `sync_excludes` above: `.env*`, `data/raw`, `.next` are never transferred; | |
| 97 | + production env comes from the manifest `env` blocks. | |
| 98 | +- The web build needs `NEXT_PUBLIC_SITE_URL` and `CI_API_URL` at build time (baked into the bundle); | |
| 99 | + they are set in the `post_sync` build command. | |
| 100 | +- M2U64 has no graphical session — not relevant here (PM2 processes), but launchd services there | |
| 101 | + would need LaunchDaemons. | |
| 102 | +- Never commit real values into the manifest; `{{ADMIN_TOKEN}}` is a placeholder on purpose. | |
added
deploy/first-run.sh
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# CancerIndex — ordered first ingestion (idempotent; safe to re-run). | |
| 3 | +# | |
| 4 | +# Runs connectors in the recommended order (terminology → genes → evidence/genomics/variants → | |
| 5 | +# trials → literature → epidemiology) with per-connector time budgets, then rebuilds counters and | |
| 6 | +# rankings. Connectors are restartable (cursor) and idempotent (payload hash), so re-running this | |
| 7 | +# script only fetches what is missing. A connector that is not registered yet, paused, awaiting | |
| 8 | +# credentials or under license review is skipped with a message — never treated as an error. | |
| 9 | +# | |
| 10 | +# Usage: cd ~/apps/cancerindex && bash deploy/first-run.sh [--only id,id] [--skip id,id] [--dry-run] | |
| 11 | +set -uo pipefail | |
| 12 | + | |
| 13 | +cd "$(dirname "$0")/.." || exit 1 | |
| 14 | +export DATABASE_URL="${DATABASE_URL:-postgres://localhost:5432/cancerindex}" | |
| 15 | +export CI_DATA_DIR="${CI_DATA_DIR:-$PWD/data}" | |
| 16 | +export CI_SERVICE=first-run | |
| 17 | +LOG_DIR="${LOG_DIR:-$PWD/logs}" | |
| 18 | +mkdir -p "$LOG_DIR" data/raw data/cache | |
| 19 | + | |
| 20 | +# connector id : time budget (minutes) : max attempts within this script | |
| 21 | +ORDER=( | |
| 22 | + "ncit-evs:45:2" | |
| 23 | + "oncotree:10:2" | |
| 24 | + "hgnc:15:2" | |
| 25 | + "civic:30:2" | |
| 26 | + "gdc:30:2" | |
| 27 | + "clinvar:45:2" | |
| 28 | + "clinicaltrials:45:3" | |
| 29 | + "pubmed:45:3" | |
| 30 | + "cdc-wonder:30:2" | |
| 31 | +) | |
| 32 | + | |
| 33 | +ONLY=""; SKIP=""; DRY="" | |
| 34 | +while [ $# -gt 0 ]; do | |
| 35 | + case "$1" in | |
| 36 | + --only) ONLY="$2"; shift 2 ;; | |
| 37 | + --skip) SKIP="$2"; shift 2 ;; | |
| 38 | + --dry-run) DRY="--mode dry_run --max-records 100"; shift ;; | |
| 39 | + *) echo "unknown option $1"; exit 2 ;; | |
| 40 | + esac | |
| 41 | +done | |
| 42 | + | |
| 43 | +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } | |
| 44 | +log() { echo "[$(ts)] $*" | tee -a "$LOG_DIR/first-run.log"; } | |
| 45 | +in_list() { [ -z "$2" ] && return 1; echo ",$2," | grep -q ",$1,"; } | |
| 46 | + | |
| 47 | +log "== CancerIndex first run (db=$DATABASE_URL, data=$CI_DATA_DIR) ==" | |
| 48 | +pnpm cix sources:sync >>"$LOG_DIR/first-run.log" 2>&1 || log "warning: sources:sync failed (continuing)" | |
| 49 | +REGISTERED="$(pnpm --silent cix connectors 2>/dev/null | awk '{print $1}' | tr '\n' ',')" | |
| 50 | + | |
| 51 | +last_status() { | |
| 52 | + psql "$DATABASE_URL" -Atc "SELECT status FROM ingest_runs WHERE connector_id = '$1' ORDER BY started_at DESC LIMIT 1" 2>/dev/null | |
| 53 | +} | |
| 54 | +cursor_health() { | |
| 55 | + psql "$DATABASE_URL" -Atc "SELECT coalesce(health,'unknown') || '|' || coalesce(paused::text,'false') FROM connector_cursors WHERE connector_id = '$1'" 2>/dev/null | |
| 56 | +} | |
| 57 | + | |
| 58 | +for entry in "${ORDER[@]}"; do | |
| 59 | + IFS=: read -r id minutes attempts <<<"$entry" | |
| 60 | + if [ -n "$ONLY" ] && ! in_list "$id" "$ONLY"; then continue; fi | |
| 61 | + if in_list "$id" "$SKIP"; then log "-- $id: skipped (--skip)"; continue; fi | |
| 62 | + if ! echo ",$REGISTERED" | grep -q ",$id,"; then log "-- $id: not registered in packages/connectors/src/registry.ts — skipped"; continue; fi | |
| 63 | + hp="$(cursor_health "$id")" | |
| 64 | + case "$hp" in | |
| 65 | + awaiting_credentials*) log "-- $id: awaiting credentials — skipped"; continue ;; | |
| 66 | + *"|true") log "-- $id: paused — skipped"; continue ;; | |
| 67 | + esac | |
| 68 | + if [ -z "$DRY" ] && [ "$(last_status "$id")" = "succeeded" ] && [ "${FORCE:-}" != "1" ]; then | |
| 69 | + log "-- $id: last run already succeeded — running incremental refresh (FORCE=1 to force full)" | |
| 70 | + fi | |
| 71 | + n=0 | |
| 72 | + while [ "$n" -lt "$attempts" ]; do | |
| 73 | + n=$((n + 1)) | |
| 74 | + log "-> $id attempt $n/$attempts (budget ${minutes} min)" | |
| 75 | + # shellcheck disable=SC2086 | |
| 76 | + if pnpm cix run "$id" --max-minutes "$minutes" $DRY >>"$LOG_DIR/first-run.$id.log" 2>&1; then | |
| 77 | + st="$(last_status "$id")" | |
| 78 | + log " $id: $st" | |
| 79 | + # partial = time budget hit with cursor saved → run again while attempts remain | |
| 80 | + [ "$st" = "partial" ] && [ "$n" -lt "$attempts" ] && continue | |
| 81 | + break | |
| 82 | + else | |
| 83 | + st="$(last_status "$id")" | |
| 84 | + log " $id: FAILED (status=${st:-none}); see $LOG_DIR/first-run.$id.log" | |
| 85 | + case "$st" in aborted) break ;; esac # license/credentials gate: do not retry | |
| 86 | + fi | |
| 87 | + done | |
| 88 | +done | |
| 89 | + | |
| 90 | +if [ -z "$DRY" ]; then | |
| 91 | + log "-> counters" | |
| 92 | + pnpm cix counters >>"$LOG_DIR/first-run.log" 2>&1 && log " counters ok" || log " counters FAILED" | |
| 93 | + log "-> rank" | |
| 94 | + pnpm cix rank >>"$LOG_DIR/first-run.log" 2>&1 && log " rank ok" || log " rank FAILED" | |
| 95 | +fi | |
| 96 | + | |
| 97 | +log "== summary ==" | |
| 98 | +pnpm --silent cix connectors 2>/dev/null | tee -a "$LOG_DIR/first-run.log" | |
| 99 | +pnpm --silent cix stats 2>/dev/null | tee -a "$LOG_DIR/first-run.log" | |
| 100 | +log "== done ==" | |
added
deploy/mld-manifest.cancerindex.json
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +{ | |
| 2 | + "app": "cancerindex", | |
| 3 | + "label": "CancerIndex — The global index of cancer", | |
| 4 | + "domain": "www.cancerindex.io", | |
| 5 | + "port": 8250, | |
| 6 | + "health_path": "/healthz", | |
| 7 | + "dir": "~/apps/cancerindex", | |
| 8 | + "extra_paths": [], | |
| 9 | + "sync_excludes": [ | |
| 10 | + "node_modules/", | |
| 11 | + ".next/", | |
| 12 | + ".git/", | |
| 13 | + ".env*", | |
| 14 | + "data/raw/", | |
| 15 | + "data/cache/", | |
| 16 | + "logs/", | |
| 17 | + "tmp/", | |
| 18 | + "coverage/", | |
| 19 | + ".DS_Store", | |
| 20 | + ".claude/", | |
| 21 | + "apps/web/.next/" | |
| 22 | + ], | |
| 23 | + "requires": { | |
| 24 | + "runtimes": ["pm2", "ngrok", "node", "pnpm", "postgresql@17"], | |
| 25 | + "ram_gb": 6, | |
| 26 | + "ports": [8250, 8251] | |
| 27 | + }, | |
| 28 | + "ram_mb_observed": null, | |
| 29 | + "size_mb": null, | |
| 30 | + "placement": { | |
| 31 | + "pin": null, | |
| 32 | + "prefer": "M4M64b", | |
| 33 | + "avoid": ["M3U96b", "M1M32"], | |
| 34 | + "reason": "M4M64b already runs Postgres 17 + pgvector, pnpm and redis with 772 GB of disk for the raw lake; M3U96b is reserved for hfmarketdata and M1M32 is the gateway." | |
| 35 | + }, | |
| 36 | + "ngrok": { | |
| 37 | + "name": "cancerindex-ngrok", | |
| 38 | + "url": "www.cancerindex.io", | |
| 39 | + "port": 8250 | |
| 40 | + }, | |
| 41 | + "launchd": [], | |
| 42 | + "env_overrides": {}, | |
| 43 | + "hooks": { | |
| 44 | + "post_sync": [ | |
| 45 | + "pnpm install --frozen-lockfile", | |
| 46 | + "psql -lqt | cut -d'|' -f1 | grep -qw cancerindex || createdb cancerindex", | |
| 47 | + "psql cancerindex -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS vector;'", | |
| 48 | + "DATABASE_URL=postgres://localhost:5432/cancerindex pnpm db:migrate", | |
| 49 | + "DATABASE_URL=postgres://localhost:5432/cancerindex pnpm db:seed", | |
| 50 | + "DATABASE_URL=postgres://localhost:5432/cancerindex pnpm cix sources:sync", | |
| 51 | + "mkdir -p data/raw data/cache logs", | |
| 52 | + "NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 NEXT_PUBLIC_SITE_URL=https://www.cancerindex.io CI_API_URL=http://127.0.0.1:8251 DATABASE_URL=postgres://localhost:5432/cancerindex pnpm --filter @cancerindex/web build" | |
| 53 | + ], | |
| 54 | + "post_start": [ | |
| 55 | + "sleep 5 && curl -fsS http://127.0.0.1:8251/healthz", | |
| 56 | + "sleep 2 && curl -fsS http://127.0.0.1:8250/healthz || curl -fsS -o /dev/null http://127.0.0.1:8250/" | |
| 57 | + ] | |
| 58 | + }, | |
| 59 | + "ka_repo": null, | |
| 60 | + "processes": [ | |
| 61 | + { | |
| 62 | + "name": "cancerindex-web", | |
| 63 | + "manager": "pm2", | |
| 64 | + "script": "/opt/homebrew/bin/node", | |
| 65 | + "args": ["node_modules/next/dist/bin/next", "start", "-p", "8250", "-H", "0.0.0.0"], | |
| 66 | + "interpreter": null, | |
| 67 | + "cwd": "{{HOME}}/apps/cancerindex/apps/web", | |
| 68 | + "env": { | |
| 69 | + "NODE_ENV": "production", | |
| 70 | + "DATABASE_URL": "postgres://localhost:5432/cancerindex", | |
| 71 | + "NEXT_PUBLIC_SITE_URL": "https://www.cancerindex.io", | |
| 72 | + "CI_API_URL": "http://127.0.0.1:8251", | |
| 73 | + "API_PORT": "8251", | |
| 74 | + "WEB_PORT": "8250", | |
| 75 | + "CI_DATA_DIR": "{{HOME}}/apps/cancerindex/data", | |
| 76 | + "ADMIN_TOKEN": "{{ADMIN_TOKEN}}", | |
| 77 | + "NCBI_TOOL": "cancerindex", | |
| 78 | + "NCBI_EMAIL": "spbou4@icloud.com", | |
| 79 | + "NCBI_API_KEY": "", | |
| 80 | + "SEER_API_KEY": "", | |
| 81 | + "NEXT_TELEMETRY_DISABLED": "1" | |
| 82 | + }, | |
| 83 | + "cron_restart": null, | |
| 84 | + "autorestart": true, | |
| 85 | + "max_memory_restart": "2G" | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "name": "cancerindex-api", | |
| 89 | + "manager": "pm2", | |
| 90 | + "script": "/opt/homebrew/bin/node", | |
| 91 | + "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"], | |
| 92 | + "interpreter": null, | |
| 93 | + "cwd": "{{HOME}}/apps/cancerindex", | |
| 94 | + "env": { | |
| 95 | + "NODE_ENV": "production", | |
| 96 | + "CI_SERVICE": "api", | |
| 97 | + "DATABASE_URL": "postgres://localhost:5432/cancerindex", | |
| 98 | + "API_HOST": "127.0.0.1", | |
| 99 | + "API_PORT": "8251", | |
| 100 | + "WEB_PORT": "8250", | |
| 101 | + "CI_API_URL": "http://127.0.0.1:8251", | |
| 102 | + "NEXT_PUBLIC_SITE_URL": "https://www.cancerindex.io", | |
| 103 | + "CI_DATA_DIR": "{{HOME}}/apps/cancerindex/data", | |
| 104 | + "ADMIN_TOKEN": "{{ADMIN_TOKEN}}", | |
| 105 | + "LOG_LEVEL": "info" | |
| 106 | + }, | |
| 107 | + "cron_restart": null, | |
| 108 | + "autorestart": true, | |
| 109 | + "max_memory_restart": "1G" | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "name": "cancerindex-worker", | |
| 113 | + "manager": "pm2", | |
| 114 | + "script": "/opt/homebrew/bin/node", | |
| 115 | + "args": ["node_modules/tsx/dist/cli.mjs", "workers/main.ts"], | |
| 116 | + "interpreter": null, | |
| 117 | + "cwd": "{{HOME}}/apps/cancerindex", | |
| 118 | + "env": { | |
| 119 | + "NODE_ENV": "production", | |
| 120 | + "CI_SERVICE": "worker", | |
| 121 | + "DATABASE_URL": "postgres://localhost:5432/cancerindex", | |
| 122 | + "CI_DATA_DIR": "{{HOME}}/apps/cancerindex/data", | |
| 123 | + "WORKER_CONCURRENCY": "2", | |
| 124 | + "CI_MAX_RUN_MINUTES": "45", | |
| 125 | + "NCBI_TOOL": "cancerindex", | |
| 126 | + "NCBI_EMAIL": "spbou4@icloud.com", | |
| 127 | + "NCBI_API_KEY": "", | |
| 128 | + "SEER_API_KEY": "", | |
| 129 | + "LOG_LEVEL": "info" | |
| 130 | + }, | |
| 131 | + "cron_restart": null, | |
| 132 | + "autorestart": true, | |
| 133 | + "max_memory_restart": "3G" | |
| 134 | + } | |
| 135 | + ] | |
| 136 | +} | |
added
docs/AI.md
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +# CancerIndex — AI policy | |
| 2 | + | |
| 3 | +Status: **not implemented in Phase 1.** No language model is called anywhere in the platform today; | |
| 4 | +the `ai_answers` and `entity_embeddings` tables exist so that the policy below can be enforced by the | |
| 5 | +schema when the feature ships (spec §62-66, §158-159, §322). | |
| 6 | + | |
| 7 | +## Principles | |
| 8 | + | |
| 9 | +1. **Database-grounded only.** An AI answer may only state facts that exist as rows in the | |
| 10 | + CancerIndex database at answer time (observations, curated evidence, approvals, trials, | |
| 11 | + publications, computed metrics). The model receives those rows as context and is instructed to | |
| 12 | + answer exclusively from them. Anything not in the database is answered with "CancerIndex has no | |
| 13 | + data on this" — never with general knowledge. | |
| 14 | +2. **Every claim cites.** Each sentence that carries a fact must reference the `CI-*` id (and | |
| 15 | + provenance) of the row it comes from; the UI renders these as the same source badges used | |
| 16 | + elsewhere. Answers without a resolvable citation are rejected before display. | |
| 17 | +3. **The label is `ai_generated_synthesis`.** AI text is displayed with its own category and is | |
| 18 | + never merged with observed, curated or regulatory content (safety labels, CLAUDE.md §3, §12). | |
| 19 | +4. **No medical advice.** Questions asking for diagnosis, prognosis for a person, or treatment | |
| 20 | + choice receive a fixed refusal with pointers to population statistics and to a clinician. | |
| 21 | + Population survival is never phrased as an individual prediction (§325). | |
| 22 | +5. **Reconciliation stays deterministic.** Models may only propose candidate mappings for the | |
| 23 | + curation queue (`unresolved_labels.suggested_*`); they never create aliases, merge entities or | |
| 24 | + write canonical data (CLAUDE.md §5). | |
| 25 | +6. **Reproducibility.** Every answer is cached in `ai_answers` with `model`, `prompt_version`, | |
| 26 | + `question_hash`, the `source_snapshot` (ids + values that were shown to the model) and | |
| 27 | + `data_as_of`. A cached answer is invalidated when any referenced row changes (`change_events`). | |
| 28 | +7. **Privacy.** Questions are not stored with user identity; API keys are never sent to a provider; | |
| 29 | + provider calls are server-side only, with the provider recorded per answer. | |
| 30 | + | |
| 31 | +## Answer contract | |
| 32 | + | |
| 33 | +```json | |
| 34 | +{ | |
| 35 | + "kind": "cancer_summary | ask", | |
| 36 | + "subjectId": "CI-CAN-00000364", | |
| 37 | + "answer": { | |
| 38 | + "text": "…", | |
| 39 | + "claims": [{ "sentence": 0, "refs": ["epidemiology_observations:1234", "rankings:5678"] }], | |
| 40 | + "refusals": [], | |
| 41 | + "limitations": ["No survival data available for this entity (SEER awaiting credentials)."] | |
| 42 | + }, | |
| 43 | + "model": "provider/model-id", | |
| 44 | + "promptVersion": "ci-summary-v1", | |
| 45 | + "sourceSnapshot": { "rows": ["…"] }, | |
| 46 | + "dataAsOf": "2026-09-08T06:30:00Z", | |
| 47 | + "category": "ai_generated_synthesis" | |
| 48 | +} | |
| 49 | +``` | |
| 50 | + | |
| 51 | +## Planned implementation (Phase 5) | |
| 52 | + | |
| 53 | +- Retrieval: SQL over canonical/derived tables first; pgvector (`entity_embeddings`, model recorded | |
| 54 | + per row) only for entity discovery, never as a source of facts. | |
| 55 | +- Providers: optional `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OPENAI_BASE_URL` | |
| 56 | + (`.env.example`); the platform works fully without them. | |
| 57 | +- Evaluation: a fixture set of questions with expected refusals and citations must pass before the | |
| 58 | + feature is enabled in production. | |
added
docs/API.md
+162 −0
@@ -0,0 +1,162 @@ | ||
| 1 | +# CancerIndex — Public API | |
| 2 | + | |
| 3 | +Base URL: `https://www.cancerindex.io/api/v1` (proxied by the web app to the Fastify service on | |
| 4 | +`http://127.0.0.1:8251/v1`). Read-only. JSON only. | |
| 5 | + | |
| 6 | +**The reference documentation is generated from the route schemas and is always current:** | |
| 7 | + | |
| 8 | +- Swagger UI: `/v1/docs` | |
| 9 | +- OpenAPI 3.1: `/v1/openapi.json` | |
| 10 | + | |
| 11 | +This page only explains the conventions and gives examples. Do not edit endpoint descriptions here; | |
| 12 | +change the zod schemas in `apps/api/src/routes/*.ts` (CLAUDE.md §357). | |
| 13 | + | |
| 14 | +## Conventions | |
| 15 | + | |
| 16 | +### Envelope | |
| 17 | + | |
| 18 | +Every `/v1` response is wrapped: | |
| 19 | + | |
| 20 | +```json | |
| 21 | +{ | |
| 22 | + "data": …, | |
| 23 | + "sources": [{ "id": "CI-SOURCE-00000001", "slug": "oncotree", "name": "OncoTree", "license": "CC BY 4.0", "attribution": "OncoTree (MSKCC), CC BY 4.0, https://oncotree.mskcc.org", "url": "https://oncotree.mskcc.org" }], | |
| 24 | + "dataRelease": "CancerIndex 2026-09", | |
| 25 | + "generatedAt": "2026-09-08T09:33:19.433Z", | |
| 26 | + "total": 865, "limit": 50, "offset": 0, "hasMore": true | |
| 27 | +} | |
| 28 | +``` | |
| 29 | + | |
| 30 | +`sources` are the distinct upstream sources behind `data` (license and attribution included so you | |
| 31 | +can comply with redistribution terms). Pagination fields appear on list endpoints; `limit` ≤ 200. | |
| 32 | + | |
| 33 | +### Identifiers | |
| 34 | + | |
| 35 | +Entities are addressed by their public id (`CI-CAN-00000364`) or a human reference: cancer slug | |
| 36 | +(`lung-adenocarcinoma`), HGNC symbol (`BRAF`, aliases accepted), variant slug, drug slug, `NCT` id, | |
| 37 | +PMID. Merged cancers redirect to the surviving entity. | |
| 38 | + | |
| 39 | +### Errors | |
| 40 | + | |
| 41 | +```json | |
| 42 | +{ "error": { "code": "not_found", "message": "cancer \"foo\" not found" }, "requestId": "…" } | |
| 43 | +``` | |
| 44 | + | |
| 45 | +Codes: `bad_request` (400, with zod `details`), `unauthorized` (401), `not_found` (404), | |
| 46 | +`rate_limited` (429), `service_unavailable` (503), `internal` (500). Every response carries | |
| 47 | +`x-request-id`; quote it when reporting a problem. | |
| 48 | + | |
| 49 | +### Rate limits and API keys | |
| 50 | + | |
| 51 | +Anonymous: 60 requests/minute per IP. With `Authorization: Bearer <key>`: the key's own limit | |
| 52 | +(`api_keys.rate_limit_per_minute`). Headers: `x-ratelimit-limit`, `x-ratelimit-remaining`, | |
| 53 | +`x-ratelimit-reset`, `retry-after` on 429. Mint a key: | |
| 54 | + | |
| 55 | +```bash | |
| 56 | +pnpm --filter @cancerindex/api create-key -- --label "Lab X" --email lab@example.org --tier research --rpm 600 | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Only the sha256 hash and a prefix are stored; the key is printed once. | |
| 60 | + | |
| 61 | +### Scientific labels | |
| 62 | + | |
| 63 | +Payload objects that carry a number also carry the category (`observed_data`, `curated_evidence`, | |
| 64 | +`regulatory_status`, `computed_metric`) and a `provenance` object (source, dataset, version, | |
| 65 | +`retrievedAt`, URL). Missing data is `null` or an empty list — never `0` substituted for "unknown". | |
| 66 | +`counters: null` means counters have not been computed yet. | |
| 67 | + | |
| 68 | +## Endpoints (summary) | |
| 69 | + | |
| 70 | +| Method + path | Purpose | | |
| 71 | +|---|---| | |
| 72 | +| `GET /healthz` | `{ ok, dataRelease, db }` | | |
| 73 | +| `GET /v1/cancers` | list; `q`, `level=top|all`, `type`, `malignant`, `hematologic`, `pediatric`, `rare`, `sort=name|active_trials|publications_5y`, `limit`, `offset` | | |
| 74 | +| `GET /v1/cancers/:id` | entity + aliases + codes + hierarchy (parents/children per hierarchy type, breadcrumbs) + anatomy + counters + completeness + current rankings + last 20 changes | | |
| 75 | +| `GET /v1/cancers/:id/statistics` | epidemiology observations with per-row provenance + charting `series`; filters `metric`, `geography`, `sex` | | |
| 76 | +| `GET /v1/cancers/:id/survival` | survival observations with provenance | | |
| 77 | +| `GET /v1/cancers/:id/genes` | cohort frequencies (denominators) + curated evidence per gene, cancer + descendants | | |
| 78 | +| `GET /v1/cancers/:id/variants` | variants with evidence counts by level/direction/type | | |
| 79 | +| `GET /v1/cancers/:id/drugs` | evidence by drug (level/direction/significance) + jurisdiction-aware approvals | | |
| 80 | +| `GET /v1/cancers/:id/trials` | trials for the cancer and descendants; `status`, `phase`, `interventionalOnly` | | |
| 81 | +| `GET /v1/cancers/:id/publications` | publication edges + literature count windows (query stored) | | |
| 82 | +| `GET /v1/genes` · `GET /v1/genes/:symbol` | genes; detail with cancers (curated + cohort), variants, drugs, literature | | |
| 83 | +| `GET /v1/variants/:id` | variant, ClinVar interpretations, CIViC evidence grouped by cancer, edges | | |
| 84 | +| `GET /v1/drugs` · `GET /v1/drugs/:id` | drugs; detail with evidence by cancer, approvals, trials | | |
| 85 | +| `GET /v1/trials` · `GET /v1/trials/:nct` | trial search (`q`, `status`, `phase`, `cancer`, `country`); detail with condition/intervention mappings, locations (200), publications | | |
| 86 | +| `GET /v1/publications/:pmid` | publication + entity edges (abstract truncated, links) | | |
| 87 | +| `GET /v1/rankings/metrics` | metric catalog with available scopes | | |
| 88 | +| `GET /v1/rankings` | `metric`, `geography`, `sex`, `age`, `year`, `level`; snapshot metadata + metric definition + rows | | |
| 89 | +| `GET /v1/rankings/:metric/:cancerId/explain` | "Why this rank?": inputs, previous rank, neighbours, lineage trace | | |
| 90 | +| `GET /v1/search?q=` | cross-entity search, ≤ 20 typed results, exact > alias > prefix > fuzzy | | |
| 91 | +| `GET /v1/sources` · `GET /v1/sources/:slug` | registry, license status, connector health, runs, counts | | |
| 92 | +| `GET /v1/stats` | live counts (60 s cache) | | |
| 93 | +| `GET /v1/changes` | change events; `entityType`, `entityId`, `kind`, `since` | | |
| 94 | +| `GET/POST /v1/admin/*` | operators only (`x-admin-token`): connectors, run/pause/resume, runs/:runId, unresolved + resolve, trace, jobs/counters, jobs/rank, audit | | |
| 95 | + | |
| 96 | +## Examples | |
| 97 | + | |
| 98 | +```bash | |
| 99 | +API=http://127.0.0.1:8251 | |
| 100 | + | |
| 101 | +# health | |
| 102 | +curl -s $API/healthz | |
| 103 | + | |
| 104 | +# homepage ticker | |
| 105 | +curl -s $API/v1/stats | jq .data | |
| 106 | + | |
| 107 | +# cancers whose name/alias starts with "glio", sorted by active trials | |
| 108 | +curl -s "$API/v1/cancers?q=glio&sort=active_trials&limit=5" | jq '.data[] | {id, slug, name, counters}' | |
| 109 | + | |
| 110 | +# one cancer with hierarchy and current rankings | |
| 111 | +curl -s $API/v1/cancers/lung-adenocarcinoma | jq '.data | {name, primaryNcitCode, hierarchy: .hierarchy.parents, rankings}' | |
| 112 | + | |
| 113 | +# epidemiology observations with provenance for a top-level cancer, US only | |
| 114 | +curl -s "$API/v1/cancers/lung-cancer/statistics?geography=USA&sex=all" | jq '.data.series[0]' | |
| 115 | + | |
| 116 | +# trials recruiting for a cancer and its subtypes | |
| 117 | +curl -s "$API/v1/cancers/lung-adenocarcinoma/trials?status=RECRUITING&limit=10" | jq '.data[] | {nctId, briefTitle, phases}' | |
| 118 | + | |
| 119 | +# a gene and its cancers | |
| 120 | +curl -s $API/v1/genes/BRAF | jq '.data | {symbol, counters, cancers: .cancers.curatedEvidence[0:3]}' | |
| 121 | + | |
| 122 | +# a variant: evidence separated by cancer | |
| 123 | +curl -s $API/v1/variants/braf-v600e | jq '.data.evidenceByCancer[] | {cancer: .cancer.name, acceptedItems}' | |
| 124 | + | |
| 125 | +# ranking: active trials, top-level set | |
| 126 | +curl -s "$API/v1/rankings?metric=active_trials&level=top&limit=10" | jq '{snapshot: .data.snapshot, top: [.data.rows[] | {rank, name: .cancer.name, value}]}' | |
| 127 | + | |
| 128 | +# why this rank? (lineage down to raw records) | |
| 129 | +curl -s "$API/v1/rankings/active_trials/lung-cancer/explain?level=top" | jq '.data | {rank, previousRank, inputs, trace}' | |
| 130 | + | |
| 131 | +# search | |
| 132 | +curl -s "$API/v1/search?q=glio" | jq '.data[] | {type, name, match}' | |
| 133 | + | |
| 134 | +# sources and license status | |
| 135 | +curl -s $API/v1/sources | jq '.data[] | {slug, licenseStatus, status, health: .connector.health, records: .counts.sourceRecords}' | |
| 136 | + | |
| 137 | +# authenticated request (higher limit) | |
| 138 | +curl -s -H "Authorization: Bearer cix_…" $API/v1/stats -D - -o /dev/null | grep -i x-ratelimit | |
| 139 | + | |
| 140 | +# admin: enqueue a connector run, then watch it | |
| 141 | +curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" -H 'content-type: application/json' \ | |
| 142 | + -d '{"mode":"incremental","maxMinutes":30,"reason":"manual refresh"}' $API/v1/admin/connectors/clinicaltrials/run | |
| 143 | +curl -s -H "x-admin-token: $ADMIN_TOKEN" $API/v1/admin/connectors | jq '.data[] | {connectorId, health, lastSuccessAt, recentRuns: .recentRuns[0].status}' | |
| 144 | + | |
| 145 | +# admin: curation queue and resolution | |
| 146 | +curl -s -H "x-admin-token: $ADMIN_TOKEN" "$API/v1/admin/unresolved?entityKind=cancer&limit=20" | |
| 147 | +curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" -H 'content-type: application/json' \ | |
| 148 | + -d '{"cancerId":"CI-CAN-00000364","reason":"synonym per NCIt"}' $API/v1/admin/unresolved/123/resolve | |
| 149 | +``` | |
| 150 | + | |
| 151 | +Resolving an unresolved label adds a curated `cancer_aliases` row (`alias_type = synonym`, | |
| 152 | +`source_terminology = curation`, `source_id` = the label's source), re-maps `trial_conditions` rows | |
| 153 | +that carry the same normalised text, writes `audit_log` and a `change_events` row; every other | |
| 154 | +connector picks the alias up on its next run through `CancerResolver`. | |
| 155 | + | |
| 156 | +## Local development | |
| 157 | + | |
| 158 | +```bash | |
| 159 | +pnpm dev:api # tsx watch, http://127.0.0.1:8251 | |
| 160 | +pnpm --filter @cancerindex/api test # unit tests + read-only smoke tests (skipped if DB unreachable) | |
| 161 | +pnpm --filter @cancerindex/api typecheck | |
| 162 | +``` | |
added
docs/ARCHITECTURE.md
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +# CancerIndex — Architecture | |
| 2 | + | |
| 3 | +CancerIndex.io is a provenance-first oncology knowledge platform: a canonical cancer ontology | |
| 4 | +connected to epidemiology, genomics, biomarkers, therapies, clinical trials, regulatory status and | |
| 5 | +literature, with transparent, reproducible rankings. This document describes how the system is | |
| 6 | +layered, which packages implement each layer, how data flows, and how the platform is deployed. | |
| 7 | +The condensed rules every contributor follows are in `CLAUDE.md`; the full specification is | |
| 8 | +`docs/SPEC-original.md`. | |
| 9 | + | |
| 10 | +## 1. Layers | |
| 11 | + | |
| 12 | +The platform is organised as six separable layers (CLAUDE.md §2). Each layer only reads from the | |
| 13 | +layer below it and writes to its own tables; nothing is ever overwritten across layers. | |
| 14 | + | |
| 15 | +| Layer | What lives here | Tables (examples) | Written by | | |
| 16 | +|---|---|---|---| | |
| 17 | +| **RAW** | Exact upstream payloads, gzip JSON Lines in the data lake, plus a hash per record | `source_records`, files under `data/raw/{source}/{date}/{entity}/{runId}-{part}.jsonl.gz` | connectors (`ctx.upsertSourceRecord`) | | |
| 18 | +| **NORMALIZED** | Source-native structures kept intact but typed and validated (CIViC evidence items, ClinVar interpretations, trial conditions before mapping) | `civic_evidence_items`, `variant_clinical_significance`, `trial_conditions`, `trial_interventions`, `connector_field_stats` | connectors | | |
| 19 | +| **CANONICAL** | One row per real-world concept with stable `CI-*` identifiers, aliases, cross-reference codes, multi-dimensional hierarchy | `cancers`, `cancer_aliases`, `cancer_codes`, `cancer_hierarchy`, `genes`, `variants`, `drugs`, `clinical_trials`, `publications`, `geographies`, `anatomical_sites` | connectors through `CancerResolver` reconciliation | | |
| 20 | +| **DERIVED** | Numbers computed from canonical data, always with provenance and a formula version | `epidemiology_observations`, `survival_observations`, `cancer_gene_frequencies`, `literature_counts`, `entity_counters`, `knowledge_edges`, `trial_pulse` | connectors (observations) and `@cancerindex/ranking` (`refreshCounters`) | | |
| 21 | +| **RANKED** | Ranking snapshots per metric × scope × formula version with per-row lineage | `metric_definitions`, `ranking_snapshots`, `rankings` | `@cancerindex/ranking` (`computeAllRankings`) | | |
| 22 | +| **AI** | Cached, database-grounded syntheses with model and prompt version (not implemented in Phase 1, see `docs/AI.md`) | `ai_answers`, `entity_embeddings` | — | | |
| 23 | + | |
| 24 | +Cross-cutting tables: `sources` (registry + license status), `ingest_runs`, `connector_cursors`, | |
| 25 | +`provenance`, `unresolved_labels`, `change_events`, `audit_log`, `entity_merges`, `api_keys`. | |
| 26 | + | |
| 27 | +Two invariants hold across every layer: | |
| 28 | + | |
| 29 | +1. **Provenance first.** No scientific number exists without a `provenance` row (source, dataset, | |
| 30 | + version, retrieval time, URL, evidence type, license). Derived values also carry | |
| 31 | + `formula_version` and their inputs. `traceValue()` in `@cancerindex/ranking` walks a ranked value | |
| 32 | + back to the observation, the provenance row and the raw record. | |
| 33 | +2. **Never fake data.** Missing data is absent, never zero. The API returns `counters: null` when | |
| 34 | + counters have not been computed, and a ranking without a snapshot returns `status: "not_available"`. | |
| 35 | + | |
| 36 | +## 2. Repository and packages | |
| 37 | + | |
| 38 | +``` | |
| 39 | +cancerindex/ | |
| 40 | +├── apps/ | |
| 41 | +│ ├── web/ Next.js 16 (webpack), Tailwind v4, server components; proxies /api/v1/* → API | |
| 42 | +│ └── api/ Fastify 5 public API (/v1), OpenAPI from zod route schemas, rate limiting, admin | |
| 43 | +├── workers/ pg-boss scheduler (connector cron, counters, rankings, health probes) | |
| 44 | +├── packages/ | |
| 45 | +│ ├── shared/ ids (CI-*), provenance types, normalization, pino logger, env helpers | |
| 46 | +│ ├── database/ Drizzle schema (snake_case), migrations, seed (metrics + geographies) | |
| 47 | +│ ├── ontology/ qualifier rules, CancerResolver, TOP_LEVEL_CANCERS, NCIt roots | |
| 48 | +│ ├── connectors/ SDK (manifest, HttpClient, RawLake, RunContext, validators) + connectors/<id>/ | |
| 49 | +│ └── ranking/ counters, ranking engine (snapshots + lineage), trace | |
| 50 | +├── scripts/ci.ts operator CLI (`pnpm cix …`) | |
| 51 | +├── deploy/ mld manifest, first-run bootstrap, deployment notes | |
| 52 | +├── docs/ this folder | |
| 53 | +└── data/raw data lake (gitignored) | |
| 54 | +``` | |
| 55 | + | |
| 56 | +Dependency direction (no cycles): `shared` ← `database` ← `ontology` ← `connectors` ← `ranking`; | |
| 57 | +`apps/api` depends on `database`, `shared`, `ranking`, `ontology`; `workers` additionally depends | |
| 58 | +on `connectors`; `apps/web` never imports `connectors` (CLAUDE.md conventions). | |
| 59 | + | |
| 60 | +### Connector SDK | |
| 61 | + | |
| 62 | +A connector is a class extending `Connector` with a validated `manifest` (zod, see | |
| 63 | +`packages/connectors/src/sdk/manifest.ts`), a `healthCheck(ctx)` and a `sync(ctx)`. The `RunContext` | |
| 64 | +provides a rate-limited `HttpClient` (token bucket, bounded concurrency, exponential backoff, | |
| 65 | +`Retry-After`), a `RawLake` writer, idempotent `upsertSourceRecord` (sha256 payload hash), | |
| 66 | +`addProvenance`, `recordUnresolved` (curation queue), `recordChange`, a restartable `cursor`, and a | |
| 67 | +time budget checked with `shouldStop()`. Schema drift is detected by recording observed field | |
| 68 | +names/types per entity (`connector_field_stats`). `runConnector()` handles bookkeeping in | |
| 69 | +`ingest_runs` / `connector_cursors`, the credentials gate (`awaiting_credentials`) and the license | |
| 70 | +gate (`licenseStatus: blocked` never ingests). | |
| 71 | + | |
| 72 | +### Reconciliation | |
| 73 | + | |
| 74 | +Labels are mapped to canonical cancers by `CancerResolver`: identifiers first (NCIt, DOID, OncoTree, | |
| 75 | +UMLS, MeSH…), then curated aliases, then normalized strings. Every mapping stores a `match_type` | |
| 76 | +(`EXACT_IDENTIFIER`, `CURATED_EXACT`, `ONTOLOGY_EXACT`, `CURATED_BROADER`, `CURATED_NARROWER`, | |
| 77 | +`ALIAS`, `PROBABILISTIC`, `UNRESOLVED`). Unknown labels are stored in `unresolved_labels` with an | |
| 78 | +optional trigram suggestion and are resolved by a curator through the admin API, which adds a curated | |
| 79 | +alias so the next connector run picks it up. | |
| 80 | + | |
| 81 | +## 3. Data flow | |
| 82 | + | |
| 83 | +``` | |
| 84 | + upstream source ──HTTP──▶ connector.sync(ctx) | |
| 85 | + │ │ upsertSourceRecord → source_records + data/raw/*.jsonl.gz (RAW) | |
| 86 | + │ │ validate / typed rows (NORMALIZED) | |
| 87 | + │ │ CancerResolver → canonical ids, match_type; misses → unresolved_labels | |
| 88 | + │ │ addProvenance → provenance; observations / evidence / edges (CANONICAL, DERIVED) | |
| 89 | + │ └ recordChange → change_events ; ingest_runs finalised | |
| 90 | + ▼ | |
| 91 | + workers: maintenance.counters → refreshCounters() → entity_counters (DERIVED) | |
| 92 | + maintenance.rank → computeAllRankings() → ranking_snapshots + rankings (RANKED) | |
| 93 | + ▼ | |
| 94 | + apps/api (/v1) ── envelope { data, sources, dataRelease, generatedAt } ──▶ apps/web, API consumers | |
| 95 | +``` | |
| 96 | + | |
| 97 | +Runs are scheduled by the worker from each manifest's `schedule` (cron, UTC) and can be triggered | |
| 98 | +by an operator (`pnpm cix run <id>` or `POST /v1/admin/connectors/:id/run`). A `connector.run` job is | |
| 99 | +`stately` per connector id (never queued twice, never concurrent). When a run creates or updates | |
| 100 | +records, the worker enqueues `maintenance.counters` with `thenRank: true`, so derived and ranked | |
| 101 | +layers follow canonical changes. A daily counters (06:00 UTC) and rank (06:30 UTC) pass covers | |
| 102 | +everything else; an hourly `health.probe` refreshes `connector_cursors.health`. | |
| 103 | + | |
| 104 | +## 4. Public API | |
| 105 | + | |
| 106 | +`apps/api` is a read-only Fastify 5 service. Route schemas are written in zod (via | |
| 107 | +`fastify-type-provider-zod`) and the OpenAPI 3.1 document is generated from them (`/v1/openapi.json`, | |
| 108 | +Swagger UI at `/v1/docs`) — documentation is never maintained by hand (CLAUDE.md §357). | |
| 109 | + | |
| 110 | +Every `/v1` response uses the envelope `{ data, sources, dataRelease, generatedAt, …pagination }`. | |
| 111 | +`sources` lists the distinct upstream sources behind the payload with license and attribution; | |
| 112 | +`dataRelease` is `CancerIndex <YYYY-MM>` derived from the latest successful ingest. | |
| 113 | + | |
| 114 | +Other properties: request correlation id (`x-request-id`, honoured when supplied), pino structured | |
| 115 | +logs, `@fastify/rate-limit` (anonymous 60/min per IP; API keys looked up by sha256 hash with | |
| 116 | +per-key limits, `X-RateLimit-*` headers), ETag + gzip/brotli, CORS. Admin endpoints under | |
| 117 | +`/v1/admin/*` require `x-admin-token` (constant-time compare) and write `audit_log` rows for every | |
| 118 | +mutation; they never execute ingestion in-process, they enqueue pg-boss jobs. | |
| 119 | + | |
| 120 | +See `docs/API.md` for endpoint examples. | |
| 121 | + | |
| 122 | +## 5. Web application | |
| 123 | + | |
| 124 | +`apps/web` (Next.js 16, server components) renders the public site. It talks to the API through a | |
| 125 | +`/api/v1/*` proxy to `http://127.0.0.1:${API_PORT}/v1/*`, so the browser only ever sees one origin. | |
| 126 | +The UI follows the scientific-editorial aesthetic and shows, for every number: source badge, unit, | |
| 127 | +population, period and freshness; empty states read "Data not yet available". | |
| 128 | + | |
| 129 | +## 6. Ports, processes and environment | |
| 130 | + | |
| 131 | +| Process | Command | Port | Notes | | |
| 132 | +|---|---|---|---| | |
| 133 | +| `cancerindex-web` | `next start -p 8250 -H 0.0.0.0` | 8250 | public entry (ngrok → www.cancerindex.io) | | |
| 134 | +| `cancerindex-api` | `tsx apps/api/src/server.ts` | 8251 (127.0.0.1) | reached only via the web proxy / localhost | | |
| 135 | +| `cancerindex-worker` | `tsx workers/main.ts` | — | pg-boss schema `pgboss` in the same database | | |
| 136 | +| PostgreSQL 17 | database `cancerindex`, extensions `pg_trgm`, `unaccent`, `vector` | 5432 | single node | | |
| 137 | + | |
| 138 | +Key environment variables (see `.env.example`): `DATABASE_URL`, `API_HOST`/`API_PORT`, `WEB_PORT`, | |
| 139 | +`CI_API_URL`, `CI_DATA_DIR` (data lake root), `ADMIN_TOKEN`, `NCBI_TOOL`/`NCBI_EMAIL`/`NCBI_API_KEY`, | |
| 140 | +`SEER_API_KEY`, `WORKER_CONCURRENCY`, `CI_MAX_RUN_MINUTES`, `LOG_LEVEL`, `CI_SERVICE` (log label). | |
| 141 | + | |
| 142 | +## 7. Deployment | |
| 143 | + | |
| 144 | +Production runs on the MacLustr cluster through the `mld` orchestrator (gateway M1M32). The | |
| 145 | +manifest is `deploy/mld-manifest.cancerindex.json` (copied to `M1M32:~/dispatch/apps/cancerindex.json`); | |
| 146 | +`mld stage <dir> cancerindex` uploads a `git archive` of the repository and `mld deploy cancerindex` | |
| 147 | +places it on the preferred node (M4M64b: Postgres 17 + pgvector, pnpm, large disk), runs the | |
| 148 | +`post_sync` hooks (`pnpm install`, `createdb` if missing, migrate, seed, `sources:sync`, web build) | |
| 149 | +and starts three PM2 processes plus the ngrok tunnel. `deploy/first-run.sh` bootstraps the data in | |
| 150 | +the recommended connector order. Details in `deploy/README.md`. | |
| 151 | + | |
| 152 | +## 8. Phase 1 boundaries | |
| 153 | + | |
| 154 | +- Rankings are count-based (trials, literature, curated evidence, genes, cohorts) for all entities | |
| 155 | + and burden/lethality/gap metrics only where epidemiology observations exist for the scope. | |
| 156 | +- No composite score (ADR-006). No AI synthesis (docs/AI.md). No HGVS normalisation / liftover | |
| 157 | + (ADR-002 plans Python/DuckDB workers for that). | |
| 158 | +- GLOBOCAN stays in license review and SEER in `awaiting_credentials`; the platform is honest about | |
| 159 | + the resulting gaps ("awaiting license review"). | |
added
docs/DATA-MODEL.md
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +# CancerIndex — Data model | |
| 2 | + | |
| 3 | +Source of truth: `packages/database/src/schema/*.ts` (Drizzle, `casing: 'snake_case'`). This document | |
| 4 | +explains the purpose of each table, its key columns and the modelling rules behind it. Column names | |
| 5 | +below are the database names (snake_case); the API returns camelCase. | |
| 6 | + | |
| 7 | +## 1. Identifier namespaces | |
| 8 | + | |
| 9 | +Public identifiers are `CI-<NS>-00000001` (8+ digits, zero-padded), minted from per-namespace | |
| 10 | +counters in `id_sequences` by `mintId()` — never reused, never database integers (CLAUDE.md §6). | |
| 11 | + | |
| 12 | +| Namespace | Entity | Table | | |
| 13 | +|---|---|---| | |
| 14 | +| `CI-CAN` | cancer entity | `cancers` | | |
| 15 | +| `CI-GENE` | gene | `genes` | | |
| 16 | +| `CI-VAR` | variant | `variants` | | |
| 17 | +| `CI-DRUG` | drug | `drugs` | | |
| 18 | +| `CI-TRIAL` | clinical trial | `clinical_trials` | | |
| 19 | +| `CI-PUB` | publication | `publications` | | |
| 20 | +| `CI-BIO` | biomarker | `biomarkers` | | |
| 21 | +| `CI-STUDY` | genomic cohort / study | `genomic_cohorts` | | |
| 22 | +| `CI-METRIC` | metric definition | `metric_definitions` | | |
| 23 | +| `CI-SOURCE` | source | `sources` | | |
| 24 | +| `CI-TRT` | treatment / regimen | `treatment_regimens` | | |
| 25 | +| `CI-ANAT` | anatomical site | `anatomical_sites` | | |
| 26 | +| `CI-GEO` | geography | `geographies` | | |
| 27 | +| `CI-PROV` | provenance (minted lazily when exposed) | `provenance.public_id` | | |
| 28 | +| `CI-ORG`, `CI-EDGE`, `CI-RANK` | reserved | — | | |
| 29 | + | |
| 30 | +Other public references: slugs (`cancers.slug`, `drugs.slug`, `variants.slug`), HGNC symbols, | |
| 31 | +`NCT` ids and PMIDs. Ingest runs are `ING-<CONNECTOR>-YYYYMMDD-000001`. | |
| 32 | + | |
| 33 | +## 2. Registry and operations | |
| 34 | + | |
| 35 | +| Table | Purpose | Key columns | | |
| 36 | +|---|---|---| | |
| 37 | +| `sources` | Source + license registry, seeded from connector manifests (`pnpm cix sources:sync`). Drives `/sources`. | `slug` (= connector id), `category`, `access_type`, `access_auth`, `license`, `license_status` (review/approved/restricted/blocked), `commercial_use`, `redistribution`, `attribution`, `approved_for_production`, `status` (planned/active/paused/degraded/awaiting_credentials/review/retired), `tier`, `manifest` (jsonb copy) | | |
| 38 | +| `ingest_runs` | One row per connector execution with counters, HTTP stats, schema drift, cursors, log and anomaly flag. | `id`, `connector_id`, `mode` (full/incremental/backfill/dry_run/probe), `status` (running/succeeded/failed/partial/aborted), `records_*`, `http_*`, `schema_drift`, `cursor_before/after`, `log`, `dataset_version`, `anomaly` | | |
| 39 | +| `connector_cursors` | Restart cursor and health per connector. | `connector_id`, `cursor` (jsonb), `last_success_at`, `paused`, `health` (healthy/degraded/failing/review/awaiting_credentials/unknown), `health_detail` | | |
| 40 | +| `connector_field_stats` | Observed field names/types per entity for schema-drift detection. | `connector_id`, `entity`, `field`, `types[]`, `seen_count`, `null_count` | | |
| 41 | +| `source_records` | RAW layer index: one row per source-native record, idempotency key `(source_id, entity_kind, source_record_id)`. | `payload_hash` (sha256), `raw_path` (lake file), `status` (active/deprecated/retracted/withdrawn/source_missing), `first_seen_run`, `last_seen_run`, `canonical_type`, `canonical_id` | | |
| 42 | +| `provenance` | The provenance record every scientific value points to. | `source_id`, `source_record_id`, `source_url`, `dataset`, `dataset_version`, `pmid`, `doi`, `retrieved_at`, `published_at`, `geography`, `population`, `cohort_size`, `methodology`, `evidence_type` (registry/clinical_trial/…/computed), `access_level`, `confidence`, `license`, `ingest_run_id` | | |
| 43 | +| `unresolved_labels` | Curation queue: labels no connector could reconcile (never dropped). | `source_id`, `entity_kind`, `source_text`, `normalized`, `count`, `status` (open/mapped/rejected/ignored), `suggested_id`, `suggested_match_type`, `suggested_score`, `resolved_id`, `resolved_by` | | |
| 44 | +| `change_events` | Entity change history feeding "What changed". | `entity_type`, `entity_id`, `kind` (created/updated/trial_added/approval_added/ranking_changed/merged/deprecated/alias_added), `summary`, `before`, `after`, `ingest_run_id` | | |
| 45 | +| `audit_log` | Every admin/curator mutation. | `actor`, `action`, `entity_type`, `entity_id`, `before`, `after`, `reason` | | |
| 46 | +| `entity_merges` | Reversible merge queue. | `entity_type`, `keep_id`, `merge_id`, `evidence`, `status` (proposed/merged/kept_separate/reverted) | | |
| 47 | +| `api_keys` | Public API keys: hash only. | `key_hash` (sha256), `prefix`, `tier`, `rate_limit_per_minute`, `active`, `last_used_at` | | |
| 48 | +| `id_sequences` | Per-namespace counters for `CI-*` ids. | `namespace`, `next` | | |
| 49 | + | |
| 50 | +## 3. Cancer ontology (canonical) | |
| 51 | + | |
| 52 | +| Table | Purpose | Key columns | | |
| 53 | +|---|---|---| | |
| 54 | +| `cancers` | One row per recognised disease concept (never per alias). | `id`, `slug`, `canonical_name`, `short_name`, `entity_type` (cancer/cancer_family/histology/subtype/molecular_subtype/hematologic_malignancy/precursor_condition/other), `malignant`, `solid_tumor`, `hematologic`, `pediatric_relevant`, `rare_cancer` (null = unknown), `top_level` (member of the mutually exclusive ranking set), `description` + `description_provenance_id`, `primary_ncit_code`, `primary_oncotree_code`, `depth`, `status` (active/deprecated/merged), `merged_into`, `classification_version`, `semantic_types[]` | | |
| 55 | +| `cancer_aliases` | Synonyms with normalised form used for reconciliation. | `cancer_id`, `alias`, `normalized`, `alias_type` (preferred/synonym/abbreviation/historical/deprecated/display), `source_id`, `source_terminology`, `language` | | |
| 56 | +| `cancer_hierarchy` | Multi-dimensional hierarchy: several trees coexist. | `parent_id`, `child_id`, `hierarchy_type` (ncit/oncotree/anatomical/histological/molecular/who/icd/seer), `source_id` | | |
| 57 | +| `cancer_codes` | Cross-reference codes, searchable (never buried in JSON). | `cancer_id`, `system` (ncit/icd10/icd10cm/icdo_topography/icdo_morphology/doid/oncotree/umls/mesh/mondo/seer_site/efo/orphanet/gdc_project), `code`, `match_type`, `source_id`, `valid_from`, `valid_to` | | |
| 58 | +| `anatomical_sites` / `cancer_anatomy` | Anatomy dimension (NCIt/UBERON) and cancer→site relations (`primary`/`metastatic`). | `ncit_code`, `uberon_id`, `parent_id`, `system`; `relation` | | |
| 59 | +| `geographies` | Canonical geography for observations and ranking scopes. | `slug`, `name`, `kind` (world/region/who_region/country/subdivision), `iso2`, `iso3`, `parent_id`, `who_region`, `population`, `population_year` | | |
| 60 | +| `cohort_definitions` | Attribute combinations that are not taxonomy nodes (stage, biomarkers). | `cancer_id`, `biomarker_ids[]`, `variant_ids[]`, `stage`, `attributes` | | |
| 61 | + | |
| 62 | +Qualified disease states (stage, recurrent, metastatic, laterality…) are not canonical cancers; the | |
| 63 | +rules are in `packages/ontology/src/qualifiers.ts`. The top-level ranking set | |
| 64 | +(`packages/ontology/src/top-level.ts`) maps GLOBOCAN/ICD-10 site groups to NCIt anchors. | |
| 65 | + | |
| 66 | +## 4. Genes, variants, biomarkers, cohorts | |
| 67 | + | |
| 68 | +| Table | Purpose | Key columns | | |
| 69 | +|---|---|---| | |
| 70 | +| `genes` | HGNC is authoritative for symbols. | `hgnc_id`, `symbol`, `name`, `locus_type`, `location`, `chromosome`, `ensembl_gene_id`, `ncbi_gene_id`, `omim_ids[]`, `uniprot_ids[]`, `prev_symbols[]`, `alias_symbols[]`, `status`, `is_cancer_gene` (derived), `civic_gene_id` | | |
| 71 | +| `gene_aliases` | Previous/alias symbols and names. | `alias`, `alias_type` (prev_symbol/alias_symbol/prev_name/alias_name) | | |
| 72 | +| `variants` | Coordinates always carry an assembly; original + normalised kept. | `slug`, `gene_id`, `gene_symbol`, `name`, `variant_type` (SO-style), `hgvs_g/c/p`, `assembly` (GRCh37/GRCh38), `chromosome`, `start`, `end`, `reference_bases`, `alternate_bases`, `coordinates` (per-assembly list), `clinvar_variation_id`, `civic_variant_id`, `dbsnp_ids[]`, `fusion_partners[]` | | |
| 73 | +| `variant_aliases` | Alternative names. | `alias`, `source_id` | | |
| 74 | +| `variant_clinical_significance` | ClinVar interpretations kept structured (never flattened). | `clinvar_variation_id`, `clinical_significance`, `review_status`, `star_rating`, `last_evaluated`, `conditions[]`, `condition_cancer_ids[]`, `origin_simple`, `number_submitters`, `provenance_id` | | |
| 75 | +| `biomarkers` | First-class biomarkers with assay/threshold metadata. | `kind` (gene_mutation/protein_expression/hormone_receptor/immune_marker/msi/tmb/hrd/ctdna/…), `gene_id`, `ncit_code`, `measurement` (jsonb) | | |
| 76 | +| `genomic_cohorts` | Studies (GDC projects…), original study ids preserved. | `source_id`, `study_id`, `name`, `program`, `primary_sites[]`, `disease_types[]`, `cancer_id`, `cancer_match_type`, `case_count`, `cases_with_ssm` (denominator), `data_release`, `access_level`, `provenance_id` | | |
| 77 | +| `cancer_gene_frequencies` | Alteration frequency per cohort — denominator mandatory. | `cohort_id`, `cancer_id`, `gene_id`, `gene_symbol`, `alteration_type` (ssm/cnv_gain/cnv_loss/fusion), `cases_affected`, `cases_profiled`, `frequency`, `rank`, `data_release`, `provenance_id` | | |
| 78 | +| `entity_embeddings` | Vectors with model + dimensions recorded (pgvector). | `entity_type`, `entity_id`, `model`, `dimensions`, `text_hash`, `embedding` | | |
| 79 | + | |
| 80 | +## 5. Therapies and regulatory status | |
| 81 | + | |
| 82 | +| Table | Purpose | Key columns | | |
| 83 | +|---|---|---| | |
| 84 | +| `drugs` | Generic/INN preferred; brands are aliases, never separate molecules. | `slug`, `name`, `kind`, `ncit_code`, `chembl_id`, `civic_therapy_id`, `drugbank_id`, `pubchem_cid`, `unii`, `mechanism`, `target_gene_ids[]`, `development_status` | | |
| 85 | +| `drug_aliases` | Generic/brand/development code/salt/synonym. | `alias`, `normalized`, `alias_type`, `source_id` | | |
| 86 | +| `treatment_regimens` | Combinations and modalities. | `component_drug_ids[]`, `modality` | | |
| 87 | +| `drug_approvals` | Country-aware regulatory status — never a bare `approved=true`. | `drug_id`, `cancer_id`, `biomarker_ids[]`, `tumor_agnostic`, `jurisdiction` (US/CA/EU/UK/AU/JP/CH/OTHER), `authority` (FDA/Health Canada/EMA/…), `indication`, `line_of_therapy`, `disease_stage`, `approval_type`, `accelerated`, `conditional`, `approval_date`, `withdrawal_date`, `status` (approved/conditional/accelerated/withdrawn/superseded), `application_number`, `source_id`, `provenance_id`, `raw` | | |
| 88 | + | |
| 89 | +## 6. Clinical trials | |
| 90 | + | |
| 91 | +| Table | Purpose | Key columns | | |
| 92 | +|---|---|---| | |
| 93 | +| `clinical_trials` | ClinicalTrials.gov studies (v2 API fields). | `nct_id`, `brief_title`, `official_title`, `acronym`, `study_type`, `phases[]`, `overall_status`, `why_stopped`, dates (`start_date`, `primary_completion_date`, `completion_date`, `first_posted_date`, `last_update_posted_date`, `results_first_posted_date`), `has_results`, `enrollment_count/type`, `lead_sponsor`, `lead_sponsor_class`, `collaborators[]`, `conditions[]` (raw), `keywords[]`, `interventions` (jsonb), `arms`, `primary/secondary_outcomes`, `eligibility`, `sex`, `minimum_age`, `maximum_age`, `countries[]`, `locations_count`, `references` (jsonb with PMIDs), `brief_summary`, `is_oncology`, `source_record_id`, `ingest_run_id` | | |
| 94 | +| `trial_conditions` | Free-text condition → cancer mapping with match type and confidence. | `trial_id`, `condition_text`, `normalized`, `cancer_id` (nullable), `match_type` (default `UNRESOLVED`), `confidence` | | |
| 95 | +| `trial_interventions` | Intervention → drug mapping. | `trial_id`, `name`, `normalized`, `intervention_type`, `drug_id`, `match_type` | | |
| 96 | +| `trial_locations` | Sites (facility, city, state, country, status, lat/lng). | `trial_id`, `country`, … | | |
| 97 | +| `trial_pulse` | Derived daily new-trial counts per cancer/phase. | `day`, `cancer_id`, `phase`, `new_trials` | | |
| 98 | + | |
| 99 | +Trials count for a cancer **and all its ancestors**: a trial mapped to "Lung Adenocarcinoma" also | |
| 100 | +counts for "Lung Cancer". Descendant traversal is a recursive CTE over `cancer_hierarchy`, depth ≤ 12, | |
| 101 | +identical in the API (`apps/api/src/lib/descendants.ts`) and the counters (`packages/ranking`). | |
| 102 | + | |
| 103 | +## 7. Literature | |
| 104 | + | |
| 105 | +| Table | Purpose | Key columns | | |
| 106 | +|---|---|---| | |
| 107 | +| `publications` | One entity per paper across PubMed/DOI/Europe PMC. | `pmid`, `doi`, `pmcid`, `title`, `abstract` (indexed, displayed truncated), `journal`, `journal_iso`, `pub_date`, `pub_year`, `publication_types[]`, `mesh_terms` (jsonb with major flag + qualifiers), `authors` (jsonb), `language`, `is_preprint`, `retracted`, `retraction_notice`, `nct_ids[]`, `cited_by_count` | | |
| 108 | +| `publication_entity_edges` | Publication → entity links with extraction method and status. | `publication_id`, `entity_type` (cancer/gene/variant/drug/biomarker/trial), `entity_id`, `method` (mesh/dictionary/registry_reference/civic_curation/ner/llm/curator), `confidence`, `status` (candidate/validated/rejected), `source_id` | | |
| 109 | +| `literature_counts` | Research activity per cancer per window — the exact PubMed query is stored with the count. | `cancer_id`, `window_key` (all/12m/5y/5y_prior/10y/y2015…), `window_start`, `window_end`, `query`, `count`, `provenance_id` | | |
| 110 | + | |
| 111 | +## 8. Evidence and knowledge graph | |
| 112 | + | |
| 113 | +| Table | Purpose | Key columns | | |
| 114 | +|---|---|---| | |
| 115 | +| `civic_evidence_items` | CIViC evidence items kept in their native structure. | `civic_id`, `molecular_profile_id/name`, `gene_symbols[]`, `gene_ids[]`, `variant_ids[]`, `civic_variant_ids[]`, `disease_name`, `doid`, `cancer_id`, `cancer_match_type`, `therapy_names[]`, `therapy_ids[]`, `therapy_interaction_type`, `evidence_type` (PREDICTIVE/PROGNOSTIC/DIAGNOSTIC/PREDISPOSING/ONCOGENIC/FUNCTIONAL), `evidence_level` (A–E), `evidence_direction` (SUPPORTS/DOES_NOT_SUPPORT), `significance`, `evidence_rating`, `status` (ACCEPTED/SUBMITTED/REJECTED), `pmid`, `source_citation`, `phenotypes[]`, `provenance_id` | | |
| 116 | +| `knowledge_edges` | Typed edges with mandatory context. | `source_entity_type/id`, `target_entity_type/id`, `relationship_type` (HAS_SUBTYPE/OCCURS_IN/ASSOCIATED_WITH/HAS_VARIANT/HAS_BIOMARKER/TREATED_BY/STUDIED_IN/DESCRIBED_BY/TARGETS/APPROVED_FOR/INVESTIGATED_FOR/PREDICTS_RESPONSE_TO/CONFERS_RESISTANCE_TO/PREDISPOSES_TO/PROGNOSTIC_IN/DIAGNOSTIC_OF), `cancer_context_ids[]`, `predictive/prognostic/diagnostic/predisposing`, `direction`, `evidence_level` (source-native, never re-scaled), `evidence_score`, `evidence_category` (observed_data/published_evidence/curated_evidence/regulatory_status/clinical_guideline/computed_metric), `status`, `source_id`, `provenance_ids[]`, `support_count`, `first/last_seen_at` | | |
| 117 | +| `risk_factors` | Risk factors and carcinogens as first-class entities. | `slug`, `name`, `kind`, `classification_authority`, `classification` | | |
| 118 | + | |
| 119 | +Only accepted CIViC items (`status = 'ACCEPTED'`) feed counters and rankings; the API separates | |
| 120 | +evidence by cancer context and never pools levels across diseases. | |
| 121 | + | |
| 122 | +## 9. Time-aware observations | |
| 123 | + | |
| 124 | +| Table | Purpose | Key columns | | |
| 125 | +|---|---|---| | |
| 126 | +| `epidemiology_observations` | Incidence/mortality/prevalence per cancer × geography × year × sex × age group × metric × source × site definition. Unique on that key — a new year is a new row, never an overwrite. | `year`, `year_end` (multi-year aggregates), `sex` (all/male/female), `age_group`, `metric` (incidence_count/incidence_rate/as_incidence_rate/mortality_count/mortality_rate/as_mortality_rate/prevalence/prevalence_5y), `value`, `unit` (count/per_100k), `lower_ci`, `upper_ci`, `standard_population`, `estimate_type` (observed/estimated/projected), `site_definition` (source's own site label / ICD range), `source_id`, `provenance_id` | | |
| 127 | +| `survival_observations` | Survival with mandatory context. | `geography_id`, `stage`, `staging_system`, `sex`, `age_group`, `diagnosis_period`, `survival_type` (overall/relative/cause_specific/progression_free/disease_free/net), `duration_months`, `probability` (0..1), `median_months`, `cohort_size`, `lower_ci`, `upper_ci`, `method`, `source_id`, `provenance_id` | | |
| 128 | + | |
| 129 | +Rates standardised to different standard populations are never compared in one ranking; the | |
| 130 | +`standard_population` column is part of the scope. | |
| 131 | + | |
| 132 | +## 10. Derived counters and rankings | |
| 133 | + | |
| 134 | +| Table | Purpose | Key columns | | |
| 135 | +|---|---|---| | |
| 136 | +| `metric_definitions` | Versioned metric catalog (seeded from `packages/database/src/seed-data/metrics.ts`). | `slug`, `name`, `description`, `formula`, `formula_version`, `unit`, `higher_is_worse`, `aggregation`, `valid_dimensions[]`, `source_slugs[]`, `category`, `eligibility` (jsonb), `experimental` | | |
| 137 | +| `entity_counters` | Deterministic counters per entity, fully rebuilt by `refreshCounters()`. | `entity_type`, `entity_id`, `trial_count`, `active_trial_count`, `recruiting_trial_count`, `phase3_trial_count`, `publication_count(_5y/_12m)`, `gene_count`, `variant_count`, `drug_count`, `approved_drug_count`, `evidence_count`, `cohort_count`, `subtype_count`, `descendant_count`, `epidemiology_obs_count`, `survival_obs_count`, `completeness` (jsonb 0/1 per section), `updated_at` | | |
| 138 | +| `ranking_snapshots` | One metric × one scope × one formula version at one time. | `metric_id`, `metric_slug`, `scope_key` (`geo=…|sex=…|age=…|year=…|level=…`), `geography`, `sex`, `age_group`, `year`, `entity_level` (top/all), `formula_version`, `eligible_entities`, `inputs_hash`, `source_ids[]`, `is_current`, `generated_at` | | |
| 139 | +| `rankings` | Rows of a snapshot with lineage. | `snapshot_id`, `cancer_id`, `rank` (competition ranking 1,2,2,4), `eligible_entities`, `percentile`, `value`, `unit`, `confidence` (HIGH/MEDIUM/LOW/INSUFFICIENT_DATA), `inputs` (observation ids, counters, formula inputs), `breakdown` (composite components, unused in Phase 1), `previous_rank` | | |
| 140 | +| `ai_answers` | Cached AI syntheses (Phase 5). | `kind`, `subject_id`, `question_hash`, `answer`, `model`, `prompt_version`, `source_snapshot`, `data_as_of` | | |
| 141 | + | |
| 142 | +Note on column naming: `entity_counters.computed_at`, `literature_counts.computed_at` and | |
| 143 | +`trial_pulse.computed_at` are declared with the shared `updatedAt()` helper, so their **database | |
| 144 | +column is `updated_at`**. Raw SQL must use `updated_at`; the API exposes it as `computedAt`. | |
| 145 | + | |
| 146 | +## 11. Provenance model in one picture | |
| 147 | + | |
| 148 | +``` | |
| 149 | +rankings.inputs.observationId ─▶ epidemiology_observations.provenance_id ─▶ provenance ─▶ source_records (payload_hash, raw_path) ─▶ data/raw/*.jsonl.gz | |
| 150 | + └▶ sources (license, attribution) | |
| 151 | +``` | |
| 152 | + | |
| 153 | +`traceValue(db, table, id)` (`packages/ranking/src/trace.ts`, exposed at | |
| 154 | +`GET /v1/admin/trace/:table/:id` and inside `GET /v1/rankings/:metric/:cancerId/explain`) returns | |
| 155 | +that chain for `rankings`, `epidemiology_observations`, `survival_observations`, | |
| 156 | +`cancer_gene_frequencies` and `literature_counts`. | |
added
docs/METHODOLOGY.md
+181 −0
@@ -0,0 +1,181 @@ | ||
| 1 | +# CancerIndex — Methodology | |
| 2 | + | |
| 3 | +This is the public methodology of CancerIndex.io. Every number shown on the site or returned by the | |
| 4 | +API is either an **observation** imported from a named source with its provenance, or a **computed** | |
| 5 | +value whose formula and version are listed here. CancerIndex is not a physician, not a diagnostic | |
| 6 | +tool and gives no treatment recommendations. Population statistics describe groups of people | |
| 7 | +diagnosed in the past; they never predict an individual outcome. | |
| 8 | + | |
| 9 | +## 1. Scientific safety labels | |
| 10 | + | |
| 11 | +Every value carries one of these categories and the categories are never merged: | |
| 12 | + | |
| 13 | +| Label | Meaning | Where it comes from | | |
| 14 | +|---|---|---| | |
| 15 | +| `observed_data` | Registry or database observation (counts, rates, cohort frequencies) | `epidemiology_observations`, `survival_observations`, `cancer_gene_frequencies`, trial records | | |
| 16 | +| `published_evidence` | A publication or a fact extracted from one | `publications`, `publication_entity_edges` | | |
| 17 | +| `curated_evidence` | Expert-curated assertion (CIViC, ClinVar) with source-native level | `civic_evidence_items`, `variant_clinical_significance` | | |
| 18 | +| `regulatory_status` | Jurisdiction-specific approval status | `drug_approvals` | | |
| 19 | +| `clinical_guideline` | Guideline statements (not ingested in Phase 1) | — | | |
| 20 | +| `computed_metric` | Derived by CancerIndex with a versioned formula | `entity_counters`, `literature_counts`, `rankings` | | |
| 21 | +| `ai_generated_synthesis` | Model-written text grounded in the above (not shipped in Phase 1) | `ai_answers` | | |
| 22 | + | |
| 23 | +## 2. Metric catalog | |
| 24 | + | |
| 25 | +Metric definitions are seeded from `packages/database/src/seed-data/metrics.ts` into | |
| 26 | +`metric_definitions` and served at `GET /v1/rankings/metrics`. The formula text below is the exact | |
| 27 | +text stored with each definition; `formula_version` changes whenever the computation changes, and | |
| 28 | +snapshots computed with different versions are never compared. | |
| 29 | + | |
| 30 | +### Burden (observations) | |
| 31 | + | |
| 32 | +| Slug | Name | Formula | Version | Unit | Eligibility | | |
| 33 | +|---|---|---|---|---|---| | |
| 34 | +| `incidence_count` | Annual new cases | `epidemiology_observations.value WHERE metric = incidence_count` | `ci-incidence-count-v1` | count | requires incidence_count; top level | | |
| 35 | +| `mortality_count` | Annual deaths | `epidemiology_observations.value WHERE metric = mortality_count` | `ci-mortality-count-v1` | count | requires mortality_count; top level | | |
| 36 | +| `as_incidence_rate` | Age-standardized incidence rate | `epidemiology_observations.value WHERE metric = as_incidence_rate (standard_population fixed per scope)` | `ci-asir-v1` | per 100 000 | requires as_incidence_rate; top level | | |
| 37 | +| `as_mortality_rate` | Age-standardized mortality rate | `epidemiology_observations.value WHERE metric = as_mortality_rate (standard_population fixed per scope)` | `ci-asmr-v1` | per 100 000 | requires as_mortality_rate; top level | | |
| 38 | + | |
| 39 | +Sources: CDC WONDER (US), SEER (US, awaiting credentials), IARC GLOBOCAN (global, license review). | |
| 40 | +Registry counts are `observed`; national/global figures may be `estimated` and are labeled as such. | |
| 41 | +Rates standardized to different standard populations are never compared in one ranking. | |
| 42 | + | |
| 43 | +### Lethality | |
| 44 | + | |
| 45 | +| Slug | Name | Formula | Version | Unit | Eligibility | | |
| 46 | +|---|---|---|---|---|---| | |
| 47 | +| `mortality_incidence_ratio` | Mortality-to-incidence ratio | `mortality_count / incidence_count (same geography, year, sex, source family)` | `ci-mir-v1` | ratio | both counts, incidence ≥ 100; top level | | |
| 48 | +| `five_year_survival` | 5-year relative survival | `survival_observations.probability WHERE survival_type = relative AND duration_months = 60 AND stage IS NULL` | `ci-5ys-v1` | probability | cohort ≥ 50; top level; source SEER | | |
| 49 | + | |
| 50 | +The mortality-to-incidence ratio is a crude proxy of lethality: it is not a survival probability and | |
| 51 | +is affected by incidence trends and registration completeness. Confidence is `HIGH` when incidence | |
| 52 | +≥ 1000, otherwise `MEDIUM`. | |
| 53 | + | |
| 54 | +### Clinical research (ClinicalTrials.gov) | |
| 55 | + | |
| 56 | +| Slug | Name | Formula | Version | Unit | | |
| 57 | +|---|---|---|---|---| | |
| 58 | +| `active_trials` | Active clinical trials | `COUNT(DISTINCT trial) FROM trial_conditions JOIN clinical_trials WHERE cancer_id IN descendants(cancer) AND study_type = INTERVENTIONAL AND overall_status IN (RECRUITING, NOT_YET_RECRUITING, ENROLLING_BY_INVITATION, ACTIVE_NOT_RECRUITING)` | `ci-active-trials-v1` | count | | |
| 59 | +| `recruiting_trials` | Recruiting clinical trials | `COUNT(DISTINCT trial) … overall_status = RECRUITING AND study_type = INTERVENTIONAL` | `ci-recruiting-trials-v1` | count | | |
| 60 | +| `phase3_trials` | Active Phase III trials | `COUNT(DISTINCT trial) … active statuses AND PHASE3 = ANY(phases)` | `ci-phase3-trials-v1` | count | | |
| 61 | + | |
| 62 | +Trials are attached to a cancer through its free-text conditions, mapped with a recorded | |
| 63 | +`match_type`; unmapped conditions sit in the curation queue and do not count. Counts aggregate over | |
| 64 | +the cancer's descendants in every hierarchy dimension (depth ≤ 12). | |
| 65 | + | |
| 66 | +### Research activity (PubMed) | |
| 67 | + | |
| 68 | +| Slug | Name | Formula | Version | Unit | Eligibility | | |
| 69 | +|---|---|---|---|---|---| | |
| 70 | +| `publications_5y` | Publications, last 5 years | `literature_counts.count WHERE window_key = 5y (query stored per row)` | `ci-pubs-5y-v1` | count | a stored PubMed query | | |
| 71 | +| `publications_12m` | Publications, last 12 months | `literature_counts.count WHERE window_key = 12m` | `ci-pubs-12m-v1` | count | a stored PubMed query | | |
| 72 | +| `publication_growth` | Publication growth | `publications_12m / (publications_5y_prior / 5)` | `ci-pub-growth-v1` | ratio | ≥ 50 publications in the prior 5-year window | | |
| 73 | + | |
| 74 | +The exact PubMed query string is stored next to every count (`literature_counts.query`), so any | |
| 75 | +count can be re-run against PubMed. | |
| 76 | + | |
| 77 | +### Molecular knowledge | |
| 78 | + | |
| 79 | +| Slug | Name | Formula | Version | Unit | | |
| 80 | +|---|---|---|---|---| | |
| 81 | +| `curated_evidence_items` | Curated clinical evidence items | `COUNT(civic_evidence_items) WHERE status = ACCEPTED AND cancer_id IN descendants(cancer)` | `ci-civic-evidence-v1` | count | | |
| 82 | +| `associated_genes` | Genes with curated or cohort evidence | `COUNT(DISTINCT gene) FROM (civic accepted evidence ∪ gdc frequency ≥ 0.05 AND cases_affected ≥ 20)` | `ci-genes-v1` | count | | |
| 83 | +| `genomic_cohorts` | Public genomic cohorts | `COUNT(genomic_cohorts) WHERE cancer_id IN descendants(cancer)` | `ci-cohorts-v1` | count | | |
| 84 | + | |
| 85 | +Only accepted CIViC evidence counts; CIViC levels (A–E) are shown as-is and never re-scaled. | |
| 86 | +Cohort frequencies always come with their denominator (`cases_affected / cases_profiled`). | |
| 87 | + | |
| 88 | +### Gap indexes (derived; require burden) | |
| 89 | + | |
| 90 | +| Slug | Name | Formula | Version | Unit | Eligibility | | |
| 91 | +|---|---|---|---|---|---| | |
| 92 | +| `trial_gap` | Trial Gap Index | `percentile(mortality_count) − percentile(active_trials)` | `ci-trial-gap-v1` | percentile points | mortality_count + active_trials in the same scope; ≥ 5 eligible entities | | |
| 93 | +| `research_gap` | Research Gap Index | `percentile(mortality_count) − percentile(publications_5y)` | `ci-research-gap-v1` | percentile points | mortality_count + publications_5y; ≥ 5 eligible entities | | |
| 94 | + | |
| 95 | +Positive values flag cancers with a high mortality burden but comparatively little activity. This is | |
| 96 | +a quantitative signal, not an accusation, and it depends on the epidemiology source of the scope. | |
| 97 | + | |
| 98 | +## 3. Scope and eligibility | |
| 99 | + | |
| 100 | +A **ranking snapshot** is one metric × one scope × one formula version at one time. The scope key is | |
| 101 | +`geo=<WORLD|ISO3>|sex=<all|male|female>|age=<all|…>|year=<YYYY|latest>|level=<top|all>` and is stored | |
| 102 | +on the snapshot with the `source_ids` that fed it. Snapshots are immutable; recomputation creates a | |
| 103 | +new snapshot and marks the previous one `is_current = false`, so history is preserved and | |
| 104 | +`previous_rank` can be explained. | |
| 105 | + | |
| 106 | +- **Entity levels.** `top` is the mutually exclusive set of 36 site groups (see §5); `all` is every | |
| 107 | + active malignant canonical entity. Count metrics are computed for both; burden, lethality and gap | |
| 108 | + metrics only for `top` (double-counting rule). | |
| 109 | +- **Geography-year scopes** are created only where a source provides ≥ 10 top-level observations for | |
| 110 | + the same geography, year, sex and source; each scope uses a single source (no mixing of registries). | |
| 111 | +- **Eligibility** rules per metric are in the `eligibility` column (`requires`, `minIncidence`, | |
| 112 | + `minCohort`, `minPublications5y`, `entityLevel`). Entities that do not meet them are not ranked | |
| 113 | + rather than ranked with a zero; publication metrics require a stored PubMed query. | |
| 114 | +- **Ties** share a rank (competition ranking: 1, 2, 2, 4). `percentile` is the share of eligible | |
| 115 | + entities ranked at or below the entity in the ranking direction (100 = most extreme). | |
| 116 | + | |
| 117 | +## 4. Uncertainty and confidence labels | |
| 118 | + | |
| 119 | +| Label | Rule | | |
| 120 | +|---|---| | |
| 121 | +| `HIGH` | Observed registry value (not an estimate); for MIR, incidence ≥ 1000 | | |
| 122 | +| `MEDIUM` | Default for computed counts and estimates | | |
| 123 | +| `LOW` | Reserved for small denominators / projected values | | |
| 124 | +| `INSUFFICIENT_DATA` | Entity excluded from the ranking; shown as "Data not yet available" | | |
| 125 | + | |
| 126 | +Estimates are always labeled `estimated` or `projected` on the observation; confidence intervals are | |
| 127 | +shown when the source provides them. | |
| 128 | + | |
| 129 | +## 5. The top-level ranking set and double counting | |
| 130 | + | |
| 131 | +Global burden rankings only make sense over **mutually exclusive** entities. Registry site groups | |
| 132 | +overlap with the disease taxonomy (a lung adenocarcinoma is also a lung cancer), so CancerIndex | |
| 133 | +ranks burden over a curated set of 36 site groups aligned with GLOBOCAN / ICD-10 chapters, each | |
| 134 | +anchored to an NCIt concept (`packages/ontology/src/top-level.ts`, `cancers.top_level = true`). | |
| 135 | +Subtypes, histologies and molecular entities are ranked separately at `level=all` for count metrics, | |
| 136 | +where aggregation over descendants is explicit and expected. A trial mapped to a subtype counts for | |
| 137 | +the subtype and for its ancestors; it is never counted twice within one snapshot because each | |
| 138 | +snapshot ranks one entity level and `COUNT(DISTINCT trial)` is applied per entity. | |
| 139 | + | |
| 140 | +## 6. What is not computed yet | |
| 141 | + | |
| 142 | +- **Composite scores** (impact/priority indexes): deliberately absent in Phase 1 (ADR-006). Any future | |
| 143 | + composite will publish its weights and components in `rankings.breakdown`. | |
| 144 | +- **Global incidence/mortality rankings**: GLOBOCAN remains under license review, so worldwide burden | |
| 145 | + metrics are shown as "awaiting license review"; US registry data (CDC WONDER) is the first burden | |
| 146 | + scope. | |
| 147 | +- **Survival**: requires SEER (awaiting credentials); until then `five_year_survival` has no snapshot. | |
| 148 | +- **Prevalence, DALYs, trends over time, age-specific rankings**: not implemented. | |
| 149 | +- **Guideline status and biomarker-defined subgroups**: entities exist in the model but no connector | |
| 150 | + ingests them in Phase 1. | |
| 151 | + | |
| 152 | +## 7. Reproducing a ranking | |
| 153 | + | |
| 154 | +Every snapshot stores an `inputs_hash`: the sha256 (first 24 hex chars) of the sorted | |
| 155 | +`<cancerId>=<value>;` pairs that entered the ranking. To reproduce: | |
| 156 | + | |
| 157 | +```bash | |
| 158 | +pnpm cix counters # rebuild entity_counters deterministically from canonical tables | |
| 159 | +pnpm cix rank # recompute every snapshot; prints metric, scope and eligible count | |
| 160 | +psql cancerindex -c "SELECT metric_slug, scope_key, formula_version, inputs_hash, eligible_entities FROM ranking_snapshots WHERE is_current" | |
| 161 | +``` | |
| 162 | + | |
| 163 | +If the underlying observations have not changed, the new snapshot carries the same `inputs_hash` | |
| 164 | +as the previous one. The API returns the hash in every ranking response (`snapshot.inputsHash`) and | |
| 165 | +in `GET /v1/rankings/:metric/:cancerId/explain`. | |
| 166 | + | |
| 167 | +## 8. TRACE — from a rank to a raw record | |
| 168 | + | |
| 169 | +"Why this rank?" is answered by `GET /v1/rankings/:metric/:cancerId/explain`, which returns the row's | |
| 170 | +`inputs` (observation ids, counter names, formula inputs), `previous_rank`, the snapshot metadata and | |
| 171 | +a `trace`: for each referenced observation, the observation row, its `provenance` row (source, | |
| 172 | +dataset, version, retrieval time, URL, license) and the `source_records` entry (payload hash and raw | |
| 173 | +lake path). The same lineage is available to operators with `pnpm cix trace <table> <id>` and | |
| 174 | +`GET /v1/admin/trace/:table/:id`. | |
| 175 | + | |
| 176 | +## 9. Change tracking | |
| 177 | + | |
| 178 | +`change_events` records creations, updates, new trials/approvals, ranking changes, merges and | |
| 179 | +deprecations with before/after snapshots and the ingest run that caused them | |
| 180 | +(`GET /v1/changes`). Ranking rows keep `previous_rank`, and every response states its | |
| 181 | +`dataRelease` (`CancerIndex YYYY-MM`) and `generatedAt`. | |
added
docs/SECURITY.md
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +# CancerIndex — Security | |
| 2 | + | |
| 3 | +Scope: the public API (`apps/api`), the worker (`workers/`), the web app and the database on the | |
| 4 | +MacLustr node (spec §160-163). | |
| 5 | + | |
| 6 | +## Threat model in one paragraph | |
| 7 | + | |
| 8 | +CancerIndex is a read-mostly public service over open scientific data. The valuable assets are data | |
| 9 | +integrity (no silent corruption of canonical or derived data), availability, upstream credentials | |
| 10 | +(NCBI, SEER), and the operator surface (admin endpoints, database). There is no end-user PII beyond | |
| 11 | +API-key owner e-mails. | |
| 12 | + | |
| 13 | +## Controls | |
| 14 | + | |
| 15 | +### Network and exposure | |
| 16 | + | |
| 17 | +- Only the web app (port 8250) is exposed through the ngrok tunnel for `www.cancerindex.io`. The API | |
| 18 | + listens on `127.0.0.1:8251` and is reached through the web app's `/api/v1/*` proxy; the worker has | |
| 19 | + no listener; PostgreSQL listens on localhost only. | |
| 20 | +- CORS allows browser reads from any origin (public data), exposes only rate-limit/request-id | |
| 21 | + headers, methods `GET, POST, OPTIONS`. | |
| 22 | +- The API sets `trustProxy: true` because it sits behind the web proxy and ngrok; rate limiting keys | |
| 23 | + on the forwarded client IP. | |
| 24 | + | |
| 25 | +### Read-only public API | |
| 26 | + | |
| 27 | +- All `/v1` public endpoints are `GET` and run only `SELECT` statements; the database role used in | |
| 28 | + production should be granted read-only privileges for the API process where possible. | |
| 29 | +- Every query parameter is validated by zod (types, enums, `limit ≤ 200`); all SQL uses | |
| 30 | + parameterised queries (`sql` template) — no string interpolation of user input. | |
| 31 | +- Numeric database ids are never accepted as public references (`classifyRef`), so enumeration of | |
| 32 | + internal ids is not possible. | |
| 33 | + | |
| 34 | +### Rate limiting and API keys | |
| 35 | + | |
| 36 | +- Anonymous: 60 requests/minute per IP; keyed: `api_keys.rate_limit_per_minute`. `X-RateLimit-*` | |
| 37 | + headers are always sent; 429 includes `retry-after`. | |
| 38 | +- API keys are random 192-bit values (`cix_` + base64url); only the sha256 hash and a 12-character | |
| 39 | + prefix are stored. Keys are shown once at creation (`apps/api/scripts/create-key.ts`), can be | |
| 40 | + deactivated (`active = false`) and record `last_used_at` (throttled write). | |
| 41 | +- An invalid key returns 401 rather than silently falling back to anonymous limits. | |
| 42 | + | |
| 43 | +### Admin surface | |
| 44 | + | |
| 45 | +- `/v1/admin/*` requires the `x-admin-token` header; comparison is constant-time. When | |
| 46 | + `ADMIN_TOKEN` is unset or left at the `.env.example` placeholder, admin endpoints answer 503 and | |
| 47 | + are effectively disabled. | |
| 48 | +- Admin mutations never run ingestion in the API process: they enqueue pg-boss jobs. Every | |
| 49 | + mutation writes an `audit_log` row (`actor`, `action`, `before`, `after`, `reason`). | |
| 50 | +- The admin token must be rotated whenever an operator leaves or the value is exposed; it is | |
| 51 | + injected by `mld` from the manifest placeholder `{{ADMIN_TOKEN}}`, never committed. | |
| 52 | + | |
| 53 | +### Secrets and configuration | |
| 54 | + | |
| 55 | +- `.env` is git-ignored; `.env.example` contains no real values. Production values live in the mld | |
| 56 | + manifest `env_overrides` / process env on the node, or in `~/.claude/.env`-style operator files | |
| 57 | + that never enter the repository. | |
| 58 | +- Upstream credentials (`NCBI_API_KEY`, `SEER_API_KEY`) are only read by connectors; a missing | |
| 59 | + credential gates the connector (`awaiting_credentials`) instead of failing loudly with the value | |
| 60 | + in logs. | |
| 61 | +- Logs are structured (pino) and must not include authorization headers or keys; Fastify's default | |
| 62 | + request serialiser logs method, URL and remote address only. | |
| 63 | + | |
| 64 | +### Data integrity | |
| 65 | + | |
| 66 | +- Connectors are idempotent (payload hash), restartable (cursor), rate-limited and guarded against | |
| 67 | + destructive updates (anomaly guard: never mass-mark `source_missing` on a shrunken response). | |
| 68 | +- Raw payloads are retained in the data lake so any canonical value can be re-derived and audited | |
| 69 | + (`traceValue`). | |
| 70 | +- Schema changes go through Drizzle migrations reviewed by the integrator; no manual production | |
| 71 | + mutation. | |
| 72 | + | |
| 73 | +### Dependencies and runtime | |
| 74 | + | |
| 75 | +- Node ≥ 22, pnpm with a committed lockfile (`--frozen-lockfile` in deployment). | |
| 76 | +- Only maintained, widely used packages: Fastify 5 and official `@fastify/*` plugins, zod 4, | |
| 77 | + drizzle-orm, postgres, pg-boss 12, pino. Run `pnpm audit` before releases. | |
| 78 | +- Processes run under PM2 with `max_memory_restart`; the worker stops gracefully on `SIGTERM` | |
| 79 | + (finishes the active job or lets pg-boss retry after expiry). | |
| 80 | + | |
| 81 | +### Content and liability | |
| 82 | + | |
| 83 | +- The platform states on every page and in the OpenAPI description that it is not a physician, not | |
| 84 | + a diagnostic tool and offers no treatment recommendations. Survival statistics are labelled as | |
| 85 | + population statistics. | |
| 86 | +- Source licenses and attribution are surfaced in every API response (`sources`) and on `/sources`; | |
| 87 | + connectors with unresolved licensing (`review`, `blocked`) never ingest. | |
| 88 | + | |
| 89 | +## Reporting | |
| 90 | + | |
| 91 | +Report vulnerabilities privately to the maintainer (see repository README). Please include the | |
| 92 | +`x-request-id` of any suspicious response and do not test against production data volumes. | |
added
docs/SPEC-original.md
+6980 −0
@@ -0,0 +1,8630 @@ | ||
| 1 | +> Original product specification supplied by the project owner on 2026-09-08. The repository CLAUDE.md is the condensed operational version. | |
| 2 | + | |
| 3 | +# CLAUDE.md — CancerIndex.io | |
| 4 | + | |
| 5 | +## 0. PROJECT IDENTITY | |
| 6 | + | |
| 7 | +**Project:** CancerIndex.io | |
| 8 | +**Product type:** Global cancer intelligence platform, database, search engine, ranking system, knowledge graph, analytics platform, and AI research interface. | |
| 9 | +**Primary language:** English. | |
| 10 | +**Domain:** `cancerindex.io` | |
| 11 | +**Mission:** Build the most comprehensive, structured, searchable, continuously updated and transparently sourced cancer intelligence database possible. | |
| 12 | + | |
| 13 | +CancerIndex.io must aim to become: | |
| 14 | + | |
| 15 | +> **The global index of cancer.** | |
| 16 | + | |
| 17 | +Think of the product as a combination of: | |
| 18 | + | |
| 19 | +* Bloomberg Terminal for oncology | |
| 20 | +* IMDb/Wikipedia-style entity coverage | |
| 21 | +* Our World in Data for cancer epidemiology | |
| 22 | +* ClinicalTrials.gov explorer | |
| 23 | +* cBioPortal genomic exploration | |
| 24 | +* PubMed intelligence layer | |
| 25 | +* drug/biomarker intelligence platform | |
| 26 | +* cancer knowledge graph | |
| 27 | +* transparent ranking engine | |
| 28 | +* research assistant | |
| 29 | +* cancer statistics terminal | |
| 30 | + | |
| 31 | +The product must not merely list the 20–50 most common cancers. | |
| 32 | + | |
| 33 | +CancerIndex must attempt to model **every identifiable malignant disease, histological subtype, molecular subtype, rare cancer, hematologic malignancy, pediatric malignancy, tumor family, anatomical site and recognized cancer entity that can be responsibly mapped from authoritative taxonomies.** | |
| 34 | + | |
| 35 | +This includes cancers that may affect only a tiny number of people annually. | |
| 36 | + | |
| 37 | +Do not hardcode a simplistic cancer list. | |
| 38 | + | |
| 39 | +Build an evolving oncology ontology. | |
| 40 | + | |
| 41 | +--- | |
| 42 | + | |
| 43 | +# 1. THE CORE PRINCIPLE | |
| 44 | + | |
| 45 | +CancerIndex must answer questions such as: | |
| 46 | + | |
| 47 | +* What cancers exist? | |
| 48 | +* How common is each cancer? | |
| 49 | +* Which cancers kill the most people? | |
| 50 | +* Which cancers have the highest mortality rate? | |
| 51 | +* Which cancers have the poorest survival? | |
| 52 | +* Which cancers are increasing fastest? | |
| 53 | +* Which cancers affect younger populations? | |
| 54 | +* Which cancers have the most treatments? | |
| 55 | +* Which cancers have the fewest treatments? | |
| 56 | +* Which cancers receive the most research? | |
| 57 | +* Which cancers receive the least research relative to burden? | |
| 58 | +* Which cancers currently have the most clinical trials? | |
| 59 | +* Which cancers have the most actionable mutations? | |
| 60 | +* Which cancers have the largest unmet need? | |
| 61 | +* Which cancers are associated with which genes? | |
| 62 | +* What variants occur in those genes? | |
| 63 | +* Which therapies target those abnormalities? | |
| 64 | +* Which drugs are approved? | |
| 65 | +* Which drugs are experimental? | |
| 66 | +* Which biomarkers predict response? | |
| 67 | +* Which trials are recruiting? | |
| 68 | +* Which publications support each relationship? | |
| 69 | +* Which countries have the highest incidence? | |
| 70 | +* How has incidence changed through time? | |
| 71 | +* How does survival differ by stage? | |
| 72 | +* How does age influence incidence? | |
| 73 | +* How does sex influence incidence? | |
| 74 | +* Which risk factors are associated with each malignancy? | |
| 75 | +* Which cancers can be screened for? | |
| 76 | +* Which cancers can be prevented? | |
| 77 | +* Which cancers are becoming more survivable? | |
| 78 | +* Where are the largest gaps in oncology research? | |
| 79 | + | |
| 80 | +Everything must be explorable from the web interface and eventually through an API. | |
| 81 | + | |
| 82 | +--- | |
| 83 | + | |
| 84 | +# 2. ABSOLUTE RULE: PROVENANCE FIRST | |
| 85 | + | |
| 86 | +CancerIndex must never display an important scientific number without knowing where it originated. | |
| 87 | + | |
| 88 | +Every imported fact should support metadata such as: | |
| 89 | + | |
| 90 | +```ts | |
| 91 | +interface Provenance { | |
| 92 | + sourceId: string | |
| 93 | + sourceName: string | |
| 94 | + sourceRecordId?: string | |
| 95 | + sourceUrl?: string | |
| 96 | + | |
| 97 | + dataset?: string | |
| 98 | + datasetVersion?: string | |
| 99 | + | |
| 100 | + publicationId?: string | |
| 101 | + pmid?: string | |
| 102 | + doi?: string | |
| 103 | + | |
| 104 | + retrievedAt: string | |
| 105 | + publishedAt?: string | |
| 106 | + updatedAt?: string | |
| 107 | + | |
| 108 | + geography?: string | |
| 109 | + population?: string | |
| 110 | + cohortSize?: number | |
| 111 | + | |
| 112 | + methodology?: string | |
| 113 | + | |
| 114 | + evidenceType: | |
| 115 | + | "registry" | |
| 116 | + | "clinical_trial" | |
| 117 | + | "meta_analysis" | |
| 118 | + | "systematic_review" | |
| 119 | + | "cohort" | |
| 120 | + | "case_control" | |
| 121 | + | "case_series" | |
| 122 | + | "case_report" | |
| 123 | + | "preclinical" | |
| 124 | + | "regulatory" | |
| 125 | + | "guideline" | |
| 126 | + | "expert_curation" | |
| 127 | + | "database" | |
| 128 | + | "computed" | |
| 129 | + | |
| 130 | + accessLevel: | |
| 131 | + | "open" | |
| 132 | + | "registration_required" | |
| 133 | + | "controlled" | |
| 134 | + | "licensed" | |
| 135 | + | |
| 136 | + confidence?: number | |
| 137 | + | |
| 138 | + license?: string | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +Every derived statistic must also be reproducible. | |
| 143 | + | |
| 144 | +Example: | |
| 145 | + | |
| 146 | +```json | |
| 147 | +{ | |
| 148 | + "metric": "mortality_to_incidence_ratio", | |
| 149 | + "value": 0.73, | |
| 150 | + "computed": true, | |
| 151 | + "formula_version": "ci-mir-v1", | |
| 152 | + "inputs": [ | |
| 153 | + "CINDEX-METRIC-102991", | |
| 154 | + "CINDEX-METRIC-102992" | |
| 155 | + ] | |
| 156 | +} | |
| 157 | +``` | |
| 158 | + | |
| 159 | +Never destroy raw source information during normalization. | |
| 160 | + | |
| 161 | +Architecture: | |
| 162 | + | |
| 163 | +```text | |
| 164 | +RAW | |
| 165 | + ↓ | |
| 166 | +NORMALIZED | |
| 167 | + ↓ | |
| 168 | +CANONICAL | |
| 169 | + ↓ | |
| 170 | +DERIVED | |
| 171 | + ↓ | |
| 172 | +RANKED | |
| 173 | + ↓ | |
| 174 | +AI SYNTHESIS | |
| 175 | +``` | |
| 176 | + | |
| 177 | +These layers must remain separable. | |
| 178 | + | |
| 179 | +--- | |
| 180 | + | |
| 181 | +# 3. SCIENTIFIC SAFETY | |
| 182 | + | |
| 183 | +CancerIndex is a research/information platform. | |
| 184 | + | |
| 185 | +It is NOT: | |
| 186 | + | |
| 187 | +* a physician | |
| 188 | +* a diagnostic system | |
| 189 | +* a treatment prescriber | |
| 190 | +* a replacement for professional medical care | |
| 191 | + | |
| 192 | +Never produce treatment recommendations based only on an AI-generated inference. | |
| 193 | + | |
| 194 | +Clearly distinguish: | |
| 195 | + | |
| 196 | +```text | |
| 197 | +OBSERVED DATA | |
| 198 | +PUBLISHED EVIDENCE | |
| 199 | +CURATED EVIDENCE | |
| 200 | +REGULATORY STATUS | |
| 201 | +CLINICAL GUIDELINE | |
| 202 | +COMPUTED METRIC | |
| 203 | +AI-GENERATED SYNTHESIS | |
| 204 | +``` | |
| 205 | + | |
| 206 | +Never silently merge these categories. | |
| 207 | + | |
| 208 | +--- | |
| 209 | + | |
| 210 | +# 4. COVERAGE GOAL — EVERY CANCER WE CAN MODEL | |
| 211 | + | |
| 212 | +CancerIndex needs a hierarchical disease model. | |
| 213 | + | |
| 214 | +A simple table: | |
| 215 | + | |
| 216 | +```text | |
| 217 | +lung cancer | |
| 218 | +breast cancer | |
| 219 | +brain cancer | |
| 220 | +... | |
| 221 | +``` | |
| 222 | + | |
| 223 | +is unacceptable. | |
| 224 | + | |
| 225 | +Cancer must be represented through a hierarchy. | |
| 226 | + | |
| 227 | +Example: | |
| 228 | + | |
| 229 | +```text | |
| 230 | +Cancer | |
| 231 | +└── Solid Tumor | |
| 232 | + └── Lung Cancer | |
| 233 | + └── Non-Small Cell Lung Cancer | |
| 234 | + └── Lung Adenocarcinoma | |
| 235 | + ├── EGFR-mutated LUAD | |
| 236 | + ├── KRAS-mutated LUAD | |
| 237 | + ├── ALK-positive LUAD | |
| 238 | + ├── ROS1-positive LUAD | |
| 239 | + └── RET-positive LUAD | |
| 240 | +``` | |
| 241 | + | |
| 242 | +Another: | |
| 243 | + | |
| 244 | +```text | |
| 245 | +Cancer | |
| 246 | +└── Hematologic Malignancy | |
| 247 | + └── Leukemia | |
| 248 | + └── Acute Leukemia | |
| 249 | + └── Acute Myeloid Leukemia | |
| 250 | + ├── AML with NPM1 mutation | |
| 251 | + ├── AML with CEBPA mutation | |
| 252 | + ├── APL | |
| 253 | + └── therapy-related AML | |
| 254 | +``` | |
| 255 | + | |
| 256 | +Another: | |
| 257 | + | |
| 258 | +```text | |
| 259 | +Cancer | |
| 260 | +└── CNS Tumor | |
| 261 | + └── Glioma | |
| 262 | + └── Diffuse Glioma | |
| 263 | + └── Glioblastoma | |
| 264 | +``` | |
| 265 | + | |
| 266 | +The hierarchy needs multiple dimensions. | |
| 267 | + | |
| 268 | +Do NOT force every entity into only one parent tree. | |
| 269 | + | |
| 270 | +Support: | |
| 271 | + | |
| 272 | +```text | |
| 273 | +anatomical hierarchy | |
| 274 | +histological hierarchy | |
| 275 | +molecular hierarchy | |
| 276 | +WHO-style disease classification | |
| 277 | +ICD hierarchy | |
| 278 | +ICD-O morphology | |
| 279 | +ICD-O topography | |
| 280 | +NCI Thesaurus | |
| 281 | +Disease Ontology | |
| 282 | +OncoTree | |
| 283 | +SEER classification | |
| 284 | +pediatric classification | |
| 285 | +hematologic classification | |
| 286 | +``` | |
| 287 | + | |
| 288 | +--- | |
| 289 | + | |
| 290 | +# 5. CANONICAL CANCER ENTITY | |
| 291 | + | |
| 292 | +Create: | |
| 293 | + | |
| 294 | +```ts | |
| 295 | +CancerEntity | |
| 296 | +``` | |
| 297 | + | |
| 298 | +Example schema: | |
| 299 | + | |
| 300 | +```ts | |
| 301 | +interface CancerEntity { | |
| 302 | + id: string | |
| 303 | + slug: string | |
| 304 | + | |
| 305 | + canonicalName: string | |
| 306 | + shortName?: string | |
| 307 | + | |
| 308 | + aliases: string[] | |
| 309 | + abbreviations: string[] | |
| 310 | + | |
| 311 | + entityType: | |
| 312 | + | "cancer" | |
| 313 | + | "cancer_family" | |
| 314 | + | "histology" | |
| 315 | + | "subtype" | |
| 316 | + | "molecular_subtype" | |
| 317 | + | "hematologic_malignancy" | |
| 318 | + | "precursor_condition" | |
| 319 | + | "other" | |
| 320 | + | |
| 321 | + parentIds: string[] | |
| 322 | + childIds: string[] | |
| 323 | + | |
| 324 | + anatomyIds: string[] | |
| 325 | + histologyIds: string[] | |
| 326 | + | |
| 327 | + ncitCodes: string[] | |
| 328 | + icd10Codes: string[] | |
| 329 | + icdoTopographyCodes: string[] | |
| 330 | + icdoMorphologyCodes: string[] | |
| 331 | + doidCodes: string[] | |
| 332 | + oncotreeCodes: string[] | |
| 333 | + umlsCodes: string[] | |
| 334 | + meshIds: string[] | |
| 335 | + mondoIds: string[] | |
| 336 | + | |
| 337 | + malignant: boolean | |
| 338 | + solidTumor: boolean | |
| 339 | + hematologic: boolean | |
| 340 | + pediatricRelevant: boolean | |
| 341 | + rareCancer: boolean | |
| 342 | + | |
| 343 | + description?: string | |
| 344 | + | |
| 345 | + epidemiology?: CancerEpidemiologySummary | |
| 346 | + survival?: CancerSurvivalSummary | |
| 347 | + | |
| 348 | + geneAssociations?: string[] | |
| 349 | + biomarkerAssociations?: string[] | |
| 350 | + drugAssociations?: string[] | |
| 351 | + trialAssociations?: string[] | |
| 352 | + | |
| 353 | + ranking?: CancerRanking | |
| 354 | + | |
| 355 | + provenanceIds: string[] | |
| 356 | + | |
| 357 | + createdAt: string | |
| 358 | + updatedAt: string | |
| 359 | +} | |
| 360 | +``` | |
| 361 | + | |
| 362 | +--- | |
| 363 | + | |
| 364 | +# 6. CANCERINDEX IDENTIFIERS | |
| 365 | + | |
| 366 | +CancerIndex needs its own stable ID namespace. | |
| 367 | + | |
| 368 | +Examples: | |
| 369 | + | |
| 370 | +```text | |
| 371 | +CI-CAN-00000001 | |
| 372 | +CI-CAN-00000002 | |
| 373 | +CI-GENE-00000001 | |
| 374 | +CI-VAR-00000001 | |
| 375 | +CI-DRUG-00000001 | |
| 376 | +CI-TRIAL-00000001 | |
| 377 | +CI-PUB-00000001 | |
| 378 | +CI-BIO-00000001 | |
| 379 | +CI-STUDY-00000001 | |
| 380 | +CI-METRIC-00000001 | |
| 381 | +CI-SOURCE-00000001 | |
| 382 | +CI-ORG-00000001 | |
| 383 | +CI-TRT-00000001 | |
| 384 | +``` | |
| 385 | + | |
| 386 | +Never expose database auto-increment integers as public identifiers. | |
| 387 | + | |
| 388 | +IDs must remain stable forever. | |
| 389 | + | |
| 390 | +--- | |
| 391 | + | |
| 392 | +# 7. ENTITY UNIVERSE | |
| 393 | + | |
| 394 | +CancerIndex should eventually contain first-class entities for: | |
| 395 | + | |
| 396 | +## Cancer | |
| 397 | + | |
| 398 | +```text | |
| 399 | +Cancer | |
| 400 | +Cancer subtype | |
| 401 | +Histology | |
| 402 | +Molecular subtype | |
| 403 | +Tumor family | |
| 404 | +Precancerous condition where relevant | |
| 405 | +Metastatic disease state | |
| 406 | +``` | |
| 407 | + | |
| 408 | +## Anatomy | |
| 409 | + | |
| 410 | +```text | |
| 411 | +Organ | |
| 412 | +Tissue | |
| 413 | +Anatomical site | |
| 414 | +Primary site | |
| 415 | +Metastatic site | |
| 416 | +``` | |
| 417 | + | |
| 418 | +## Genes | |
| 419 | + | |
| 420 | +```text | |
| 421 | +Gene | |
| 422 | +Transcript | |
| 423 | +Protein | |
| 424 | +Pathway | |
| 425 | +Gene family | |
| 426 | +``` | |
| 427 | + | |
| 428 | +## Genetic alterations | |
| 429 | + | |
| 430 | +```text | |
| 431 | +SNV | |
| 432 | +MNV | |
| 433 | +Insertion | |
| 434 | +Deletion | |
| 435 | +Indel | |
| 436 | +Fusion | |
| 437 | +Rearrangement | |
| 438 | +Amplification | |
| 439 | +Deletion/CNA | |
| 440 | +Loss of heterozygosity | |
| 441 | +Promoter mutation | |
| 442 | +Splice alteration | |
| 443 | +Expression change | |
| 444 | +Epigenetic alteration | |
| 445 | +Structural variant | |
| 446 | +``` | |
| 447 | + | |
| 448 | +## Biomarkers | |
| 449 | + | |
| 450 | +```text | |
| 451 | +Gene mutation | |
| 452 | +Protein expression | |
| 453 | +Hormone receptor | |
| 454 | +PD-L1 | |
| 455 | +MSI | |
| 456 | +TMB | |
| 457 | +HRD | |
| 458 | +ctDNA | |
| 459 | +methylation | |
| 460 | +gene signature | |
| 461 | +expression signature | |
| 462 | +cell surface marker | |
| 463 | +immune marker | |
| 464 | +``` | |
| 465 | + | |
| 466 | +## Drugs | |
| 467 | + | |
| 468 | +```text | |
| 469 | +small molecule | |
| 470 | +monoclonal antibody | |
| 471 | +ADC | |
| 472 | +bispecific antibody | |
| 473 | +CAR-T | |
| 474 | +cell therapy | |
| 475 | +gene therapy | |
| 476 | +cancer vaccine | |
| 477 | +radiopharmaceutical | |
| 478 | +chemotherapy | |
| 479 | +hormonal therapy | |
| 480 | +immunotherapy | |
| 481 | +targeted therapy | |
| 482 | +``` | |
| 483 | + | |
| 484 | +## Treatment concepts | |
| 485 | + | |
| 486 | +```text | |
| 487 | +drug | |
| 488 | +drug combination | |
| 489 | +surgery | |
| 490 | +radiotherapy | |
| 491 | +brachytherapy | |
| 492 | +proton therapy | |
| 493 | +transplantation | |
| 494 | +cell therapy | |
| 495 | +watchful waiting | |
| 496 | +active surveillance | |
| 497 | +``` | |
| 498 | + | |
| 499 | +## Clinical research | |
| 500 | + | |
| 501 | +```text | |
| 502 | +clinical trial | |
| 503 | +trial arm | |
| 504 | +intervention | |
| 505 | +cohort | |
| 506 | +endpoint | |
| 507 | +study | |
| 508 | +publication | |
| 509 | +investigator | |
| 510 | +institution | |
| 511 | +sponsor | |
| 512 | +``` | |
| 513 | + | |
| 514 | +## Population | |
| 515 | + | |
| 516 | +```text | |
| 517 | +country | |
| 518 | +territory | |
| 519 | +state/province | |
| 520 | +region | |
| 521 | +registry | |
| 522 | +age group | |
| 523 | +sex | |
| 524 | +calendar year | |
| 525 | +``` | |
| 526 | + | |
| 527 | +--- | |
| 528 | + | |
| 529 | +# 8. THE CONNECTOR PHILOSOPHY | |
| 530 | + | |
| 531 | +CancerIndex must be built around connectors. | |
| 532 | + | |
| 533 | +Do not manually populate the platform except for explicitly curated metadata. | |
| 534 | + | |
| 535 | +Every external system must have an independent connector. | |
| 536 | + | |
| 537 | +Structure: | |
| 538 | + | |
| 539 | +```text | |
| 540 | +/connectors | |
| 541 | + /nci | |
| 542 | + /gdc | |
| 543 | + /seer | |
| 544 | + /iarc | |
| 545 | + /clinicaltrials | |
| 546 | + /pubmed | |
| 547 | + /clinvar | |
| 548 | + /cbioportal | |
| 549 | + /civic | |
| 550 | + /hgnc | |
| 551 | + /ensembl | |
| 552 | + /chembl | |
| 553 | + /opentargets | |
| 554 | + /dgidb | |
| 555 | + /fda | |
| 556 | + ... | |
| 557 | +``` | |
| 558 | + | |
| 559 | +Every connector implements something similar to: | |
| 560 | + | |
| 561 | +```ts | |
| 562 | +interface Connector { | |
| 563 | + id: string | |
| 564 | + name: string | |
| 565 | + | |
| 566 | + discover(): Promise<void> | |
| 567 | + fetch(): Promise<void> | |
| 568 | + normalize(): Promise<void> | |
| 569 | + reconcile(): Promise<void> | |
| 570 | + validate(): Promise<void> | |
| 571 | + persist(): Promise<void> | |
| 572 | + | |
| 573 | + healthCheck(): Promise<ConnectorHealth> | |
| 574 | + | |
| 575 | + getCursor(): Promise<ConnectorCursor> | |
| 576 | + setCursor(cursor: ConnectorCursor): Promise<void> | |
| 577 | +} | |
| 578 | +``` | |
| 579 | + | |
| 580 | +--- | |
| 581 | + | |
| 582 | +# 9. CONNECTOR MANIFEST | |
| 583 | + | |
| 584 | +Every connector needs: | |
| 585 | + | |
| 586 | +```yaml | |
| 587 | +id: | |
| 588 | +name: | |
| 589 | +organization: | |
| 590 | +category: | |
| 591 | + | |
| 592 | +access: | |
| 593 | + type: api|bulk|rss|ftp|graphql|rest|scrape|manual | |
| 594 | + auth: none|api_key|oauth|account|controlled | |
| 595 | + | |
| 596 | +license: | |
| 597 | +terms_reviewed: | |
| 598 | +commercial_use_status: | |
| 599 | + | |
| 600 | +update_frequency: | |
| 601 | +expected_latency: | |
| 602 | + | |
| 603 | +supports_incremental_sync: | |
| 604 | + | |
| 605 | +entities: | |
| 606 | +metrics: | |
| 607 | + | |
| 608 | +rate_limits: | |
| 609 | + | |
| 610 | +retry_policy: | |
| 611 | + | |
| 612 | +raw_retention: | |
| 613 | + | |
| 614 | +schema_version: | |
| 615 | + | |
| 616 | +documentation_verified_at: | |
| 617 | + | |
| 618 | +owner: | |
| 619 | +status: | |
| 620 | +``` | |
| 621 | + | |
| 622 | +--- | |
| 623 | + | |
| 624 | +# 10. CONNECTOR PRIORITY TIERS | |
| 625 | + | |
| 626 | +## TIER 0 — FOUNDATIONAL | |
| 627 | + | |
| 628 | +These must be implemented first. | |
| 629 | + | |
| 630 | +### 10.1 NCI Enterprise Vocabulary Services | |
| 631 | + | |
| 632 | +Purpose: | |
| 633 | + | |
| 634 | +* cancer terminology | |
| 635 | +* canonical disease names | |
| 636 | +* disease aliases | |
| 637 | +* drug concepts | |
| 638 | +* biomarkers | |
| 639 | +* terminology mappings | |
| 640 | +* controlled oncology concepts | |
| 641 | + | |
| 642 | +Use NCI terminology as one of the anchors of the CancerIndex normalization system. | |
| 643 | + | |
| 644 | +Store NCI identifiers on entities. | |
| 645 | + | |
| 646 | +--- | |
| 647 | + | |
| 648 | +### 10.2 NCI Genomic Data Commons — GDC | |
| 649 | + | |
| 650 | +Use for: | |
| 651 | + | |
| 652 | +* TCGA | |
| 653 | +* TARGET | |
| 654 | +* CPTAC where available through the platform | |
| 655 | +* case metadata | |
| 656 | +* tumor metadata | |
| 657 | +* genomic files | |
| 658 | +* mutation information | |
| 659 | +* copy-number information | |
| 660 | +* expression | |
| 661 | +* molecular features | |
| 662 | +* survival-related analyses | |
| 663 | +* cancer cohorts | |
| 664 | + | |
| 665 | +Connector must support: | |
| 666 | + | |
| 667 | +```text | |
| 668 | +projects | |
| 669 | +cases | |
| 670 | +files | |
| 671 | +annotations | |
| 672 | +genes | |
| 673 | +mutations | |
| 674 | +CNV | |
| 675 | +metadata | |
| 676 | +manifests | |
| 677 | +``` | |
| 678 | + | |
| 679 | +Do not ingest controlled-access patient-identifiable/low-level data without the proper authorization model. | |
| 680 | + | |
| 681 | +Prioritize open, aggregated and de-identified data. | |
| 682 | + | |
| 683 | +--- | |
| 684 | + | |
| 685 | +### 10.3 SEER | |
| 686 | + | |
| 687 | +Purpose: | |
| 688 | + | |
| 689 | +* US cancer incidence | |
| 690 | +* mortality where available/appropriate | |
| 691 | +* survival | |
| 692 | +* age | |
| 693 | +* sex | |
| 694 | +* race/ethnicity where datasets permit | |
| 695 | +* staging | |
| 696 | +* disease classification | |
| 697 | +* registry geography | |
| 698 | +* trends | |
| 699 | + | |
| 700 | +Use SEER for American cancer burden and survival analytics. | |
| 701 | + | |
| 702 | +Never represent a SEER population estimate as a global estimate. | |
| 703 | + | |
| 704 | +--- | |
| 705 | + | |
| 706 | +### 10.4 IARC Global Cancer Observatory / GLOBOCAN | |
| 707 | + | |
| 708 | +This should be the major global epidemiology layer. | |
| 709 | + | |
| 710 | +Capture when permitted: | |
| 711 | + | |
| 712 | +```text | |
| 713 | +incidence | |
| 714 | +mortality | |
| 715 | +prevalence | |
| 716 | +age-standardized rates | |
| 717 | +sex | |
| 718 | +country | |
| 719 | +region | |
| 720 | +cancer type | |
| 721 | +year | |
| 722 | +``` | |
| 723 | + | |
| 724 | +Build CancerIndex country and global rankings from this layer. | |
| 725 | + | |
| 726 | +Terms and redistribution rights MUST be reviewed before automated bulk ingestion. | |
| 727 | + | |
| 728 | +Do not assume that because data are publicly viewable they can automatically be republished wholesale. | |
| 729 | + | |
| 730 | +--- | |
| 731 | + | |
| 732 | +### 10.5 ClinicalTrials.gov | |
| 733 | + | |
| 734 | +Build an extremely robust ClinicalTrials.gov connector. | |
| 735 | + | |
| 736 | +Capture: | |
| 737 | + | |
| 738 | +```text | |
| 739 | +NCT ID | |
| 740 | +official title | |
| 741 | +brief title | |
| 742 | +study type | |
| 743 | +phase | |
| 744 | +status | |
| 745 | +conditions | |
| 746 | +interventions | |
| 747 | +arms | |
| 748 | +sponsor | |
| 749 | +collaborators | |
| 750 | +eligibility | |
| 751 | +sex | |
| 752 | +age | |
| 753 | +enrollment | |
| 754 | +locations | |
| 755 | +countries | |
| 756 | +investigators | |
| 757 | +primary outcomes | |
| 758 | +secondary outcomes | |
| 759 | +start date | |
| 760 | +completion date | |
| 761 | +study results | |
| 762 | +references | |
| 763 | +last update | |
| 764 | +``` | |
| 765 | + | |
| 766 | +CancerIndex must map free-text conditions to canonical cancer IDs. | |
| 767 | + | |
| 768 | +CancerIndex must map: | |
| 769 | + | |
| 770 | +```text | |
| 771 | +trial → cancer | |
| 772 | +trial → drug | |
| 773 | +trial → biomarker | |
| 774 | +trial → gene | |
| 775 | +trial → institution | |
| 776 | +trial → country | |
| 777 | +``` | |
| 778 | + | |
| 779 | +Incrementally synchronize changed records. | |
| 780 | + | |
| 781 | +--- | |
| 782 | + | |
| 783 | +### 10.6 PubMed | |
| 784 | + | |
| 785 | +Massive literature connector. | |
| 786 | + | |
| 787 | +Capture: | |
| 788 | + | |
| 789 | +```text | |
| 790 | +PMID | |
| 791 | +title | |
| 792 | +abstract | |
| 793 | +authors | |
| 794 | +affiliations | |
| 795 | +journal | |
| 796 | +publication date | |
| 797 | +publication types | |
| 798 | +MeSH | |
| 799 | +DOI | |
| 800 | +references where accessible | |
| 801 | +retractions/corrections | |
| 802 | +``` | |
| 803 | + | |
| 804 | +Build mappings: | |
| 805 | + | |
| 806 | +```text | |
| 807 | +publication → cancer | |
| 808 | +publication → gene | |
| 809 | +publication → variant | |
| 810 | +publication → biomarker | |
| 811 | +publication → drug | |
| 812 | +publication → trial | |
| 813 | +``` | |
| 814 | + | |
| 815 | +Do not make LLM entity extraction authoritative. | |
| 816 | + | |
| 817 | +LLM extraction creates candidate relationships. | |
| 818 | + | |
| 819 | +Those candidates must be labeled accordingly until validated. | |
| 820 | + | |
| 821 | +--- | |
| 822 | + | |
| 823 | +### 10.7 ClinVar | |
| 824 | + | |
| 825 | +Use for: | |
| 826 | + | |
| 827 | +```text | |
| 828 | +variants | |
| 829 | +clinical significance | |
| 830 | +conditions | |
| 831 | +review status | |
| 832 | +submitter information | |
| 833 | +variation IDs | |
| 834 | +HGVS | |
| 835 | +genes | |
| 836 | +citations | |
| 837 | +drug response | |
| 838 | +``` | |
| 839 | + | |
| 840 | +Map cancer-associated ClinVar records into the graph. | |
| 841 | + | |
| 842 | +--- | |
| 843 | + | |
| 844 | +### 10.8 cBioPortal | |
| 845 | + | |
| 846 | +Use for cancer cohort/genomics exploration. | |
| 847 | + | |
| 848 | +Import where licensing permits: | |
| 849 | + | |
| 850 | +```text | |
| 851 | +studies | |
| 852 | +patients | |
| 853 | +samples | |
| 854 | +mutations | |
| 855 | +CNA | |
| 856 | +expression | |
| 857 | +clinical attributes | |
| 858 | +survival | |
| 859 | +molecular profiles | |
| 860 | +``` | |
| 861 | + | |
| 862 | +CancerIndex should preserve original study IDs. | |
| 863 | + | |
| 864 | +--- | |
| 865 | + | |
| 866 | +### 10.9 CIViC | |
| 867 | + | |
| 868 | +Extremely important for curated clinical interpretation of cancer variants. | |
| 869 | + | |
| 870 | +Map: | |
| 871 | + | |
| 872 | +```text | |
| 873 | +gene | |
| 874 | +variant | |
| 875 | +molecular profile | |
| 876 | +disease | |
| 877 | +therapy | |
| 878 | +evidence item | |
| 879 | +assertion | |
| 880 | +publication | |
| 881 | +evidence level | |
| 882 | +evidence direction | |
| 883 | +clinical significance | |
| 884 | +``` | |
| 885 | + | |
| 886 | +Do not flatten CIViC evidence into a binary: | |
| 887 | + | |
| 888 | +```text | |
| 889 | +works / doesn't work | |
| 890 | +``` | |
| 891 | + | |
| 892 | +Preserve its structured evidence. | |
| 893 | + | |
| 894 | +--- | |
| 895 | + | |
| 896 | +# 11. TIER 1 — MOLECULAR INTELLIGENCE | |
| 897 | + | |
| 898 | +Implement these after foundational ingest. | |
| 899 | + | |
| 900 | +## HGNC | |
| 901 | + | |
| 902 | +Canonical human gene nomenclature. | |
| 903 | + | |
| 904 | +Capture: | |
| 905 | + | |
| 906 | +```text | |
| 907 | +HGNC ID | |
| 908 | +approved symbol | |
| 909 | +approved name | |
| 910 | +aliases | |
| 911 | +previous symbols | |
| 912 | +chromosomal location | |
| 913 | +cross references | |
| 914 | +``` | |
| 915 | + | |
| 916 | +HGNC should be authoritative for canonical human gene symbol reconciliation. | |
| 917 | + | |
| 918 | +--- | |
| 919 | + | |
| 920 | +## Ensembl | |
| 921 | + | |
| 922 | +Capture: | |
| 923 | + | |
| 924 | +```text | |
| 925 | +genes | |
| 926 | +transcripts | |
| 927 | +variants | |
| 928 | +coordinates | |
| 929 | +assemblies | |
| 930 | +regulatory information | |
| 931 | +homology where useful | |
| 932 | +``` | |
| 933 | + | |
| 934 | +Always retain genome assembly. | |
| 935 | + | |
| 936 | +Never store a coordinate without: | |
| 937 | + | |
| 938 | +```text | |
| 939 | +assembly | |
| 940 | +chromosome | |
| 941 | +position | |
| 942 | +reference | |
| 943 | +alternate | |
| 944 | +``` | |
| 945 | + | |
| 946 | +--- | |
| 947 | + | |
| 948 | +## NCBI Gene | |
| 949 | + | |
| 950 | +Use as additional cross-reference and annotation source. | |
| 951 | + | |
| 952 | +--- | |
| 953 | + | |
| 954 | +## dbSNP | |
| 955 | + | |
| 956 | +Variant identifiers and genomic cross references. | |
| 957 | + | |
| 958 | +--- | |
| 959 | + | |
| 960 | +## dbVar | |
| 961 | + | |
| 962 | +Structural variation. | |
| 963 | + | |
| 964 | +--- | |
| 965 | + | |
| 966 | +## Sequence Ontology | |
| 967 | + | |
| 968 | +Normalize variant types. | |
| 969 | + | |
| 970 | +--- | |
| 971 | + | |
| 972 | +## Gene Ontology | |
| 973 | + | |
| 974 | +Functional annotation. | |
| 975 | + | |
| 976 | +--- | |
| 977 | + | |
| 978 | +## UniProt | |
| 979 | + | |
| 980 | +Protein entities and protein annotation. | |
| 981 | + | |
| 982 | +--- | |
| 983 | + | |
| 984 | +## Reactome | |
| 985 | + | |
| 986 | +Pathways. | |
| 987 | + | |
| 988 | +Build: | |
| 989 | + | |
| 990 | +```text | |
| 991 | +gene → pathway | |
| 992 | +protein → pathway | |
| 993 | +drug target → pathway | |
| 994 | +cancer → dysregulated pathway | |
| 995 | +``` | |
| 996 | + | |
| 997 | +--- | |
| 998 | + | |
| 999 | +## WikiPathways | |
| 1000 | + | |
| 1001 | +Secondary pathway source. | |
| 1002 | + | |
| 1003 | +--- | |
| 1004 | + | |
| 1005 | +## Protein Data Bank | |
| 1006 | + | |
| 1007 | +Connect cancer proteins and drug targets to experimental structures. | |
| 1008 | + | |
| 1009 | +--- | |
| 1010 | + | |
| 1011 | +## AlphaFold Protein Structure Database | |
| 1012 | + | |
| 1013 | +Optional structural biology layer. | |
| 1014 | + | |
| 1015 | +Do not imply predicted structure equals experimental structure. | |
| 1016 | + | |
| 1017 | +--- | |
| 1018 | + | |
| 1019 | +# 12. TIER 2 — DRUG INTELLIGENCE | |
| 1020 | + | |
| 1021 | +## ChEMBL | |
| 1022 | + | |
| 1023 | +Capture: | |
| 1024 | + | |
| 1025 | +```text | |
| 1026 | +molecules | |
| 1027 | +mechanisms | |
| 1028 | +targets | |
| 1029 | +assays | |
| 1030 | +activities | |
| 1031 | +indications | |
| 1032 | +development phase | |
| 1033 | +``` | |
| 1034 | + | |
| 1035 | +--- | |
| 1036 | + | |
| 1037 | +## Open Targets | |
| 1038 | + | |
| 1039 | +Use as a disease-target-drug evidence layer. | |
| 1040 | + | |
| 1041 | +Map: | |
| 1042 | + | |
| 1043 | +```text | |
| 1044 | +target ↔ disease | |
| 1045 | +drug ↔ target | |
| 1046 | +evidence source | |
| 1047 | +association score | |
| 1048 | +``` | |
| 1049 | + | |
| 1050 | +Never convert another database's association score directly into a CancerIndex evidence score without documenting transformation. | |
| 1051 | + | |
| 1052 | +--- | |
| 1053 | + | |
| 1054 | +## DGIdb | |
| 1055 | + | |
| 1056 | +Drug-gene interactions. | |
| 1057 | + | |
| 1058 | +Preserve contributing source information. | |
| 1059 | + | |
| 1060 | +--- | |
| 1061 | + | |
| 1062 | +## DrugBank | |
| 1063 | + | |
| 1064 | +Potential connector. | |
| 1065 | + | |
| 1066 | +**Important:** licensing must be verified before implementation or redistribution. | |
| 1067 | + | |
| 1068 | +Do not scrape or reproduce licensed content without permission. | |
| 1069 | + | |
| 1070 | +--- | |
| 1071 | + | |
| 1072 | +## PubChem | |
| 1073 | + | |
| 1074 | +Use for: | |
| 1075 | + | |
| 1076 | +```text | |
| 1077 | +compound IDs | |
| 1078 | +structures | |
| 1079 | +synonyms | |
| 1080 | +chemical identifiers | |
| 1081 | +``` | |
| 1082 | + | |
| 1083 | +--- | |
| 1084 | + | |
| 1085 | +## DrugCentral | |
| 1086 | + | |
| 1087 | +Candidate drug information source. | |
| 1088 | + | |
| 1089 | +Verify current terms and downloadable datasets. | |
| 1090 | + | |
| 1091 | +--- | |
| 1092 | + | |
| 1093 | +## DailyMed | |
| 1094 | + | |
| 1095 | +Structured FDA label information. | |
| 1096 | + | |
| 1097 | +Potential fields: | |
| 1098 | + | |
| 1099 | +```text | |
| 1100 | +drug label | |
| 1101 | +indications | |
| 1102 | +contraindications | |
| 1103 | +warnings | |
| 1104 | +dose language | |
| 1105 | +adverse reactions | |
| 1106 | +manufacturer | |
| 1107 | +label version | |
| 1108 | +``` | |
| 1109 | + | |
| 1110 | +Never paraphrase a drug label and then present the paraphrase as the legal label. | |
| 1111 | + | |
| 1112 | +--- | |
| 1113 | + | |
| 1114 | +## OpenFDA | |
| 1115 | + | |
| 1116 | +Use where useful for structured FDA data. | |
| 1117 | + | |
| 1118 | +Potential: | |
| 1119 | + | |
| 1120 | +```text | |
| 1121 | +labels | |
| 1122 | +adverse event aggregates | |
| 1123 | +drug metadata | |
| 1124 | +``` | |
| 1125 | + | |
| 1126 | +Adverse-event reports must include strong caveats. | |
| 1127 | + | |
| 1128 | +Spontaneous reporting cannot be treated as incidence or causal proof. | |
| 1129 | + | |
| 1130 | +--- | |
| 1131 | + | |
| 1132 | +# 13. TIER 3 — REGULATORY INTELLIGENCE | |
| 1133 | + | |
| 1134 | +Build country-aware regulatory status. | |
| 1135 | + | |
| 1136 | +Never use: | |
| 1137 | + | |
| 1138 | +```text | |
| 1139 | +approved = true | |
| 1140 | +``` | |
| 1141 | + | |
| 1142 | +alone. | |
| 1143 | + | |
| 1144 | +Use: | |
| 1145 | + | |
| 1146 | +```ts | |
| 1147 | +interface DrugApproval { | |
| 1148 | + drugId: string | |
| 1149 | + cancerId?: string | |
| 1150 | + biomarkerIds: string[] | |
| 1151 | + | |
| 1152 | + jurisdiction: | |
| 1153 | + | "US" | |
| 1154 | + | "CA" | |
| 1155 | + | "EU" | |
| 1156 | + | "UK" | |
| 1157 | + | "AU" | |
| 1158 | + | "JP" | |
| 1159 | + | "OTHER" | |
| 1160 | + | |
| 1161 | + authority: | |
| 1162 | + | "FDA" | |
| 1163 | + | "Health Canada" | |
| 1164 | + | "EMA" | |
| 1165 | + | "MHRA" | |
| 1166 | + | "TGA" | |
| 1167 | + | "PMDA" | |
| 1168 | + | string | |
| 1169 | + | |
| 1170 | + indication: string | |
| 1171 | + | |
| 1172 | + lineOfTherapy?: string | |
| 1173 | + diseaseStage?: string | |
| 1174 | + | |
| 1175 | + approvalType?: string | |
| 1176 | + accelerated?: boolean | |
| 1177 | + conditional?: boolean | |
| 1178 | + | |
| 1179 | + approvalDate?: string | |
| 1180 | + withdrawalDate?: string | |
| 1181 | + | |
| 1182 | + status: | |
| 1183 | + | "approved" | |
| 1184 | + | "conditional" | |
| 1185 | + | "accelerated" | |
| 1186 | + | "withdrawn" | |
| 1187 | + | "superseded" | |
| 1188 | + | |
| 1189 | + sourceId: string | |
| 1190 | +} | |
| 1191 | +``` | |
| 1192 | + | |
| 1193 | +--- | |
| 1194 | + | |
| 1195 | +## FDA Oncology | |
| 1196 | + | |
| 1197 | +Connect: | |
| 1198 | + | |
| 1199 | +* oncology approval announcements | |
| 1200 | +* Oncology Center of Excellence | |
| 1201 | +* Project Confirm | |
| 1202 | +* accelerated approvals | |
| 1203 | +* withdrawn accelerated approvals | |
| 1204 | +* verified clinical benefit | |
| 1205 | +* Project Orbis | |
| 1206 | +* labeling | |
| 1207 | +* regulatory reviews | |
| 1208 | + | |
| 1209 | +CancerIndex should have an: | |
| 1210 | + | |
| 1211 | +**Oncology Approval Timeline** | |
| 1212 | + | |
| 1213 | +--- | |
| 1214 | + | |
| 1215 | +## Health Canada | |
| 1216 | + | |
| 1217 | +CancerIndex is global; Canada must be properly represented. | |
| 1218 | + | |
| 1219 | +Potential datasets: | |
| 1220 | + | |
| 1221 | +* Drug Product Database | |
| 1222 | +* Notice of Compliance | |
| 1223 | +* Summary Basis of Decision | |
| 1224 | +* Product Monographs | |
| 1225 | +* Project Orbis-related approvals | |
| 1226 | + | |
| 1227 | +Review redistribution and API availability before implementation. | |
| 1228 | + | |
| 1229 | +--- | |
| 1230 | + | |
| 1231 | +## EMA | |
| 1232 | + | |
| 1233 | +Capture: | |
| 1234 | + | |
| 1235 | +```text | |
| 1236 | +European Public Assessment Reports | |
| 1237 | +indications | |
| 1238 | +marketing authorization | |
| 1239 | +authorization dates | |
| 1240 | +withdrawals | |
| 1241 | +safety changes | |
| 1242 | +``` | |
| 1243 | + | |
| 1244 | +--- | |
| 1245 | + | |
| 1246 | +## MHRA | |
| 1247 | + | |
| 1248 | +UK regulatory layer. | |
| 1249 | + | |
| 1250 | +--- | |
| 1251 | + | |
| 1252 | +## TGA | |
| 1253 | + | |
| 1254 | +Australia regulatory layer. | |
| 1255 | + | |
| 1256 | +--- | |
| 1257 | + | |
| 1258 | +## PMDA | |
| 1259 | + | |
| 1260 | +Japan regulatory layer. | |
| 1261 | + | |
| 1262 | +--- | |
| 1263 | + | |
| 1264 | +## Swissmedic | |
| 1265 | + | |
| 1266 | +Swiss layer. | |
| 1267 | + | |
| 1268 | +--- | |
| 1269 | + | |
| 1270 | +# 14. TIER 4 — GLOBAL EPIDEMIOLOGY | |
| 1271 | + | |
| 1272 | +Additional country sources should augment IARC rather than blindly override it. | |
| 1273 | + | |
| 1274 | +Possible connectors: | |
| 1275 | + | |
| 1276 | +## WHO | |
| 1277 | + | |
| 1278 | +Global health statistics and relevant cancer-related datasets. | |
| 1279 | + | |
| 1280 | +## CDC | |
| 1281 | + | |
| 1282 | +US cancer statistics where useful. | |
| 1283 | + | |
| 1284 | +## Statistics Canada | |
| 1285 | + | |
| 1286 | +Canadian mortality/population data. | |
| 1287 | + | |
| 1288 | +## Canadian Cancer Statistics | |
| 1289 | + | |
| 1290 | +Evaluate licensing and machine-readable availability. | |
| 1291 | + | |
| 1292 | +## Canadian Cancer Registry | |
| 1293 | + | |
| 1294 | +Integrate permitted aggregated data where accessible. | |
| 1295 | + | |
| 1296 | +## European Cancer Information System | |
| 1297 | + | |
| 1298 | +European epidemiology. | |
| 1299 | + | |
| 1300 | +## EUROCARE | |
| 1301 | + | |
| 1302 | +European survival research where permitted. | |
| 1303 | + | |
| 1304 | +## National cancer registries | |
| 1305 | + | |
| 1306 | +Build country-specific connectors where high-quality public data exist. | |
| 1307 | + | |
| 1308 | +Examples: | |
| 1309 | + | |
| 1310 | +```text | |
| 1311 | +UK | |
| 1312 | +Australia | |
| 1313 | +New Zealand | |
| 1314 | +Nordic countries | |
| 1315 | +France | |
| 1316 | +Germany | |
| 1317 | +Netherlands | |
| 1318 | +Japan | |
| 1319 | +South Korea | |
| 1320 | +Singapore | |
| 1321 | +Canada | |
| 1322 | +United States | |
| 1323 | +``` | |
| 1324 | + | |
| 1325 | +Do NOT mix incompatible epidemiological definitions without harmonization. | |
| 1326 | + | |
| 1327 | +--- | |
| 1328 | + | |
| 1329 | +# 15. TIER 5 — LITERATURE | |
| 1330 | + | |
| 1331 | +## PubMed | |
| 1332 | + | |
| 1333 | +Primary. | |
| 1334 | + | |
| 1335 | +## Europe PMC | |
| 1336 | + | |
| 1337 | +Use as a complementary literature graph. | |
| 1338 | + | |
| 1339 | +Potential: | |
| 1340 | + | |
| 1341 | +```text | |
| 1342 | +abstracts | |
| 1343 | +full-text availability | |
| 1344 | +citations | |
| 1345 | +references | |
| 1346 | +grants | |
| 1347 | +preprints | |
| 1348 | +``` | |
| 1349 | + | |
| 1350 | +## Crossref | |
| 1351 | + | |
| 1352 | +DOI and publication metadata. | |
| 1353 | + | |
| 1354 | +## OpenAlex | |
| 1355 | + | |
| 1356 | +Useful for: | |
| 1357 | + | |
| 1358 | +```text | |
| 1359 | +citation graph | |
| 1360 | +institutions | |
| 1361 | +authors | |
| 1362 | +topics | |
| 1363 | +research trends | |
| 1364 | +``` | |
| 1365 | + | |
| 1366 | +Verify licensing/current API conditions. | |
| 1367 | + | |
| 1368 | +## Semantic Scholar | |
| 1369 | + | |
| 1370 | +Potential secondary citation/AI-literature layer. | |
| 1371 | + | |
| 1372 | +Review API conditions. | |
| 1373 | + | |
| 1374 | +## bioRxiv | |
| 1375 | + | |
| 1376 | +Preprints. | |
| 1377 | + | |
| 1378 | +## medRxiv | |
| 1379 | + | |
| 1380 | +Preprints. | |
| 1381 | + | |
| 1382 | +Always label preprints prominently. | |
| 1383 | + | |
| 1384 | +Never rank a preprint as equivalent to peer-reviewed evidence. | |
| 1385 | + | |
| 1386 | +--- | |
| 1387 | + | |
| 1388 | +# 16. TIER 6 — CLINICAL GUIDELINES | |
| 1389 | + | |
| 1390 | +Potential sources: | |
| 1391 | + | |
| 1392 | +```text | |
| 1393 | +NCI | |
| 1394 | +ASCO | |
| 1395 | +ESMO | |
| 1396 | +NCCN | |
| 1397 | +Cancer Care Ontario | |
| 1398 | +NICE | |
| 1399 | +other national oncology organizations | |
| 1400 | +``` | |
| 1401 | + | |
| 1402 | +CRITICAL: | |
| 1403 | + | |
| 1404 | +Guideline copyright and licensing vary substantially. | |
| 1405 | + | |
| 1406 | +CancerIndex must NOT automatically scrape and reproduce paid/copyrighted guidelines. | |
| 1407 | + | |
| 1408 | +For restricted sources: | |
| 1409 | + | |
| 1410 | +store only permitted: | |
| 1411 | + | |
| 1412 | +```text | |
| 1413 | +citation | |
| 1414 | +title | |
| 1415 | +publication date | |
| 1416 | +organization | |
| 1417 | +external reference | |
| 1418 | +metadata | |
| 1419 | +``` | |
| 1420 | + | |
| 1421 | +unless licensing permits more. | |
| 1422 | + | |
| 1423 | +--- | |
| 1424 | + | |
| 1425 | +# 17. TIER 7 — PRECISION ONCOLOGY | |
| 1426 | + | |
| 1427 | +Potential integrations: | |
| 1428 | + | |
| 1429 | +```text | |
| 1430 | +CIViC | |
| 1431 | +ClinVar | |
| 1432 | +OncoKB | |
| 1433 | +Cancer Genome Interpreter | |
| 1434 | +JAX-CKB | |
| 1435 | +MolecularMatch | |
| 1436 | +My Cancer Genome | |
| 1437 | +``` | |
| 1438 | + | |
| 1439 | +But: | |
| 1440 | + | |
| 1441 | +**Licensing must be checked individually.** | |
| 1442 | + | |
| 1443 | +Never assume commercial reuse. | |
| 1444 | + | |
| 1445 | +Build the CancerIndex precision oncology layer first from sources with clear reuse rights. | |
| 1446 | + | |
| 1447 | +--- | |
| 1448 | + | |
| 1449 | +# 18. TIER 8 — CANCER CELL LINES AND PRECLINICAL DATA | |
| 1450 | + | |
| 1451 | +Potential sources: | |
| 1452 | + | |
| 1453 | +## DepMap | |
| 1454 | + | |
| 1455 | +```text | |
| 1456 | +cell lines | |
| 1457 | +gene dependencies | |
| 1458 | +CRISPR screens | |
| 1459 | +drug sensitivity | |
| 1460 | +molecular features | |
| 1461 | +``` | |
| 1462 | + | |
| 1463 | +## Cancer Cell Line Encyclopedia | |
| 1464 | + | |
| 1465 | +Integrate where licensing permits. | |
| 1466 | + | |
| 1467 | +## GDSC | |
| 1468 | + | |
| 1469 | +Genomics of Drug Sensitivity in Cancer. | |
| 1470 | + | |
| 1471 | +## Cell Model Passports | |
| 1472 | + | |
| 1473 | +Cancer model information. | |
| 1474 | + | |
| 1475 | +## PDX resources | |
| 1476 | + | |
| 1477 | +Patient-derived xenograft data where publicly available. | |
| 1478 | + | |
| 1479 | +Keep: | |
| 1480 | + | |
| 1481 | +```text | |
| 1482 | +PRECLINICAL | |
| 1483 | +``` | |
| 1484 | + | |
| 1485 | +clearly separated from human clinical evidence. | |
| 1486 | + | |
| 1487 | +--- | |
| 1488 | + | |
| 1489 | +# 19. TIER 9 — IMMUNO-ONCOLOGY | |
| 1490 | + | |
| 1491 | +Model: | |
| 1492 | + | |
| 1493 | +```text | |
| 1494 | +immune checkpoints | |
| 1495 | +immune cell populations | |
| 1496 | +neoantigens | |
| 1497 | +HLA | |
| 1498 | +PD-1 | |
| 1499 | +PD-L1 | |
| 1500 | +CTLA-4 | |
| 1501 | +LAG-3 | |
| 1502 | +TIGIT | |
| 1503 | +TIM-3 | |
| 1504 | +TMB | |
| 1505 | +MSI | |
| 1506 | +immune gene signatures | |
| 1507 | +``` | |
| 1508 | + | |
| 1509 | +Sources can include: | |
| 1510 | + | |
| 1511 | +```text | |
| 1512 | +GDC | |
| 1513 | +cBioPortal | |
| 1514 | +CIViC | |
| 1515 | +clinical trials | |
| 1516 | +publications | |
| 1517 | +Open Targets | |
| 1518 | +``` | |
| 1519 | + | |
| 1520 | +--- | |
| 1521 | + | |
| 1522 | +# 20. TIER 10 — PEDIATRIC ONCOLOGY | |
| 1523 | + | |
| 1524 | +CancerIndex must NOT treat pediatric cancers as simply adult cancers in younger people. | |
| 1525 | + | |
| 1526 | +Build dedicated pediatric taxonomy. | |
| 1527 | + | |
| 1528 | +Sources could include: | |
| 1529 | + | |
| 1530 | +```text | |
| 1531 | +TARGET | |
| 1532 | +NCI | |
| 1533 | +SEER | |
| 1534 | +IARC pediatric resources | |
| 1535 | +St. Jude public resources | |
| 1536 | +pediatric clinical trials | |
| 1537 | +literature | |
| 1538 | +``` | |
| 1539 | + | |
| 1540 | +Add: | |
| 1541 | + | |
| 1542 | +```text | |
| 1543 | +age at diagnosis | |
| 1544 | +pediatric incidence | |
| 1545 | +AYA incidence | |
| 1546 | +survival | |
| 1547 | +molecular subtype | |
| 1548 | +treatment landscape | |
| 1549 | +late effects evidence | |
| 1550 | +``` | |
| 1551 | + | |
| 1552 | +--- | |
| 1553 | + | |
| 1554 | +# 21. TIER 11 — RARE CANCERS | |
| 1555 | + | |
| 1556 | +Rare cancers are a core differentiator. | |
| 1557 | + | |
| 1558 | +CancerIndex should attempt to index cancers even when: | |
| 1559 | + | |
| 1560 | +```text | |
| 1561 | +incidence < 1 / 100,000 | |
| 1562 | +``` | |
| 1563 | + | |
| 1564 | +Do not hide them because data are sparse. | |
| 1565 | + | |
| 1566 | +Create: | |
| 1567 | + | |
| 1568 | +**Rare Cancer Explorer** | |
| 1569 | + | |
| 1570 | +Metrics: | |
| 1571 | + | |
| 1572 | +```text | |
| 1573 | +estimated incidence | |
| 1574 | +number of known cases/cohorts | |
| 1575 | +number of publications | |
| 1576 | +number of clinical trials | |
| 1577 | +number of approved therapies | |
| 1578 | +number of targeted therapies | |
| 1579 | +available genomic studies | |
| 1580 | +research activity | |
| 1581 | +``` | |
| 1582 | + | |
| 1583 | +Data scarcity itself should be displayed. | |
| 1584 | + | |
| 1585 | +--- | |
| 1586 | + | |
| 1587 | +# 22. CONNECTOR FALLBACK SYSTEM | |
| 1588 | + | |
| 1589 | +Preferred connector hierarchy: | |
| 1590 | + | |
| 1591 | +```text | |
| 1592 | +1. official API | |
| 1593 | +2. official bulk download | |
| 1594 | +3. official structured feed | |
| 1595 | +4. official database export | |
| 1596 | +5. official static dataset | |
| 1597 | +6. compliant website extraction | |
| 1598 | +7. publication extraction | |
| 1599 | +8. manual curator review | |
| 1600 | +``` | |
| 1601 | + | |
| 1602 | +Do NOT start by scraping if an API exists. | |
| 1603 | + | |
| 1604 | +--- | |
| 1605 | + | |
| 1606 | +# 23. FIRECRAWL + SCRAPFLY | |
| 1607 | + | |
| 1608 | +CancerIndex may use Firecrawl and Scrapfly for sources without suitable APIs. | |
| 1609 | + | |
| 1610 | +Architecture: | |
| 1611 | + | |
| 1612 | +```text | |
| 1613 | +official API | |
| 1614 | + ↓ unavailable | |
| 1615 | +official bulk | |
| 1616 | + ↓ unavailable | |
| 1617 | +Firecrawl | |
| 1618 | + ↓ blocked / inadequate | |
| 1619 | +Scrapfly | |
| 1620 | +``` | |
| 1621 | + | |
| 1622 | +Firecrawl is primarily useful for: | |
| 1623 | + | |
| 1624 | +```text | |
| 1625 | +documentation | |
| 1626 | +regulatory pages | |
| 1627 | +research institution pages | |
| 1628 | +structured public pages | |
| 1629 | +public tables | |
| 1630 | +release notes | |
| 1631 | +``` | |
| 1632 | + | |
| 1633 | +Scrapfly should be a fallback for technically difficult public pages when use is permitted. | |
| 1634 | + | |
| 1635 | +Do NOT use anti-bot tooling to bypass: | |
| 1636 | + | |
| 1637 | +* authentication | |
| 1638 | +* paywalls | |
| 1639 | +* explicit access restrictions | |
| 1640 | +* licensing controls | |
| 1641 | +* patient privacy protections | |
| 1642 | +* controlled genomic datasets | |
| 1643 | + | |
| 1644 | +Store source terms/compliance status per connector. | |
| 1645 | + | |
| 1646 | +--- | |
| 1647 | + | |
| 1648 | +# 24. CONNECTOR OBSERVABILITY | |
| 1649 | + | |
| 1650 | +Every connector receives an admin dashboard. | |
| 1651 | + | |
| 1652 | +Display: | |
| 1653 | + | |
| 1654 | +```text | |
| 1655 | +status | |
| 1656 | +last successful sync | |
| 1657 | +last attempt | |
| 1658 | +duration | |
| 1659 | +records fetched | |
| 1660 | +records created | |
| 1661 | +records updated | |
| 1662 | +records rejected | |
| 1663 | +schema drift | |
| 1664 | +HTTP failures | |
| 1665 | +rate limit events | |
| 1666 | +validation failures | |
| 1667 | +freshness | |
| 1668 | +``` | |
| 1669 | + | |
| 1670 | +Example: | |
| 1671 | + | |
| 1672 | +```text | |
| 1673 | +GDC HEALTHY 11 min ago | |
| 1674 | +ClinicalTrials HEALTHY 4 min ago | |
| 1675 | +PubMed HEALTHY 8 min ago | |
| 1676 | +SEER HEALTHY 2 hr ago | |
| 1677 | +FDA DEGRADED 37 min ago | |
| 1678 | +IARC REVIEW license check | |
| 1679 | +``` | |
| 1680 | + | |
| 1681 | +--- | |
| 1682 | + | |
| 1683 | +# 25. SCHEMA DRIFT DETECTION | |
| 1684 | + | |
| 1685 | +External APIs change. | |
| 1686 | + | |
| 1687 | +Every connector must detect: | |
| 1688 | + | |
| 1689 | +```text | |
| 1690 | +new fields | |
| 1691 | +removed fields | |
| 1692 | +changed enum values | |
| 1693 | +changed types | |
| 1694 | +unexpected nullability | |
| 1695 | +pagination behavior changes | |
| 1696 | +authentication changes | |
| 1697 | +``` | |
| 1698 | + | |
| 1699 | +When drift occurs: | |
| 1700 | + | |
| 1701 | +```text | |
| 1702 | +DO NOT silently discard data. | |
| 1703 | +``` | |
| 1704 | + | |
| 1705 | +Alert the administrator. | |
| 1706 | + | |
| 1707 | +--- | |
| 1708 | + | |
| 1709 | +# 26. RAW DATA LAKE | |
| 1710 | + | |
| 1711 | +Every source payload should be retained when licensing allows. | |
| 1712 | + | |
| 1713 | +Use object storage: | |
| 1714 | + | |
| 1715 | +```text | |
| 1716 | +/raw/{source}/{date}/{entity}/{id}.json | |
| 1717 | +``` | |
| 1718 | + | |
| 1719 | +or compressed batch files. | |
| 1720 | + | |
| 1721 | +Benefits: | |
| 1722 | + | |
| 1723 | +* auditability | |
| 1724 | +* reproducibility | |
| 1725 | +* reprocessing | |
| 1726 | +* parser upgrades | |
| 1727 | +* debugging | |
| 1728 | +* historical snapshots | |
| 1729 | + | |
| 1730 | +--- | |
| 1731 | + | |
| 1732 | +# 27. CANONICAL DATA MODEL | |
| 1733 | + | |
| 1734 | +Core relational tables: | |
| 1735 | + | |
| 1736 | +```text | |
| 1737 | +cancers | |
| 1738 | +cancer_aliases | |
| 1739 | +cancer_hierarchy | |
| 1740 | +cancer_codes | |
| 1741 | + | |
| 1742 | +anatomical_sites | |
| 1743 | + | |
| 1744 | +genes | |
| 1745 | +gene_aliases | |
| 1746 | +proteins | |
| 1747 | +transcripts | |
| 1748 | + | |
| 1749 | +variants | |
| 1750 | +variant_coordinates | |
| 1751 | +variant_aliases | |
| 1752 | + | |
| 1753 | +biomarkers | |
| 1754 | + | |
| 1755 | +drugs | |
| 1756 | +drug_aliases | |
| 1757 | +drug_targets | |
| 1758 | +drug_indications | |
| 1759 | +drug_approvals | |
| 1760 | + | |
| 1761 | +treatments | |
| 1762 | +treatment_regimens | |
| 1763 | + | |
| 1764 | +clinical_trials | |
| 1765 | +trial_conditions | |
| 1766 | +trial_interventions | |
| 1767 | +trial_locations | |
| 1768 | +trial_outcomes | |
| 1769 | +trial_eligibility | |
| 1770 | + | |
| 1771 | +publications | |
| 1772 | +authors | |
| 1773 | +institutions | |
| 1774 | + | |
| 1775 | +studies | |
| 1776 | +cohorts | |
| 1777 | + | |
| 1778 | +epidemiology_observations | |
| 1779 | +survival_observations | |
| 1780 | + | |
| 1781 | +cancer_gene_edges | |
| 1782 | +cancer_variant_edges | |
| 1783 | +cancer_biomarker_edges | |
| 1784 | +cancer_drug_edges | |
| 1785 | +drug_gene_edges | |
| 1786 | +drug_variant_edges | |
| 1787 | +trial_cancer_edges | |
| 1788 | +publication_entity_edges | |
| 1789 | + | |
| 1790 | +sources | |
| 1791 | +source_records | |
| 1792 | +provenance | |
| 1793 | + | |
| 1794 | +rankings | |
| 1795 | +ranking_snapshots | |
| 1796 | +``` | |
| 1797 | + | |
| 1798 | +--- | |
| 1799 | + | |
| 1800 | +# 28. KNOWLEDGE GRAPH | |
| 1801 | + | |
| 1802 | +CancerIndex must be graph-native conceptually, even if PostgreSQL remains the primary transactional database. | |
| 1803 | + | |
| 1804 | +Graph: | |
| 1805 | + | |
| 1806 | +```text | |
| 1807 | +Cancer | |
| 1808 | + ├── HAS_SUBTYPE → Cancer | |
| 1809 | + ├── OCCURS_IN → Anatomy | |
| 1810 | + ├── ASSOCIATED_WITH → Gene | |
| 1811 | + ├── HAS_VARIANT → Variant | |
| 1812 | + ├── HAS_BIOMARKER → Biomarker | |
| 1813 | + ├── TREATED_BY → Drug | |
| 1814 | + ├── STUDIED_IN → Trial | |
| 1815 | + ├── DESCRIBED_BY → Publication | |
| 1816 | + └── OBSERVED_IN → Cohort | |
| 1817 | + | |
| 1818 | +Gene | |
| 1819 | + ├── HAS_VARIANT → Variant | |
| 1820 | + ├── ENCODES → Protein | |
| 1821 | + ├── MEMBER_OF → Pathway | |
| 1822 | + └── TARGETED_BY → Drug | |
| 1823 | + | |
| 1824 | +Variant | |
| 1825 | + ├── OCCURS_IN → Cancer | |
| 1826 | + ├── PREDICTS_RESPONSE_TO → Drug | |
| 1827 | + ├── CONFERS_RESISTANCE_TO → Drug | |
| 1828 | + └── SUPPORTED_BY → Evidence | |
| 1829 | + | |
| 1830 | +Drug | |
| 1831 | + ├── TARGETS → Gene | |
| 1832 | + ├── APPROVED_FOR → Cancer | |
| 1833 | + ├── INVESTIGATED_FOR → Cancer | |
| 1834 | + └── USED_IN → Trial | |
| 1835 | +``` | |
| 1836 | + | |
| 1837 | +Relationships need provenance. | |
| 1838 | + | |
| 1839 | +--- | |
| 1840 | + | |
| 1841 | +# 29. EDGE MODEL | |
| 1842 | + | |
| 1843 | +Never store: | |
| 1844 | + | |
| 1845 | +```text | |
| 1846 | +EGFR mutation → osimertinib | |
| 1847 | +``` | |
| 1848 | + | |
| 1849 | +without context. | |
| 1850 | + | |
| 1851 | +Use: | |
| 1852 | + | |
| 1853 | +```ts | |
| 1854 | +interface KnowledgeEdge { | |
| 1855 | + id: string | |
| 1856 | + | |
| 1857 | + sourceEntityId: string | |
| 1858 | + targetEntityId: string | |
| 1859 | + | |
| 1860 | + relationshipType: string | |
| 1861 | + | |
| 1862 | + cancerContextIds?: string[] | |
| 1863 | + | |
| 1864 | + predictive?: boolean | |
| 1865 | + prognostic?: boolean | |
| 1866 | + diagnostic?: boolean | |
| 1867 | + predisposing?: boolean | |
| 1868 | + | |
| 1869 | + direction?: | |
| 1870 | + | "supports" | |
| 1871 | + | "resistance" | |
| 1872 | + | "sensitivity" | |
| 1873 | + | "neutral" | |
| 1874 | + | "unknown" | |
| 1875 | + | |
| 1876 | + evidenceLevel?: string | |
| 1877 | + evidenceScore?: number | |
| 1878 | + | |
| 1879 | + provenanceIds: string[] | |
| 1880 | + | |
| 1881 | + firstSeenAt: string | |
| 1882 | + lastSeenAt: string | |
| 1883 | +} | |
| 1884 | +``` | |
| 1885 | + | |
| 1886 | +--- | |
| 1887 | + | |
| 1888 | +# 30. CANCER RANKING ENGINE | |
| 1889 | + | |
| 1890 | +This is one of the signature features. | |
| 1891 | + | |
| 1892 | +CancerIndex must rank every eligible cancer across MANY metrics. | |
| 1893 | + | |
| 1894 | +There must never be one unexplained “danger ranking.” | |
| 1895 | + | |
| 1896 | +--- | |
| 1897 | + | |
| 1898 | +# 31. RANKING DIMENSIONS | |
| 1899 | + | |
| 1900 | +Every cancer can potentially have: | |
| 1901 | + | |
| 1902 | +## Burden | |
| 1903 | + | |
| 1904 | +```text | |
| 1905 | +global incidence count | |
| 1906 | +global mortality count | |
| 1907 | +global prevalence | |
| 1908 | +age-standardized incidence | |
| 1909 | +age-standardized mortality | |
| 1910 | +DALYs if source available | |
| 1911 | +YLL if source available | |
| 1912 | +``` | |
| 1913 | + | |
| 1914 | +## Lethality | |
| 1915 | + | |
| 1916 | +```text | |
| 1917 | +mortality / incidence ratio | |
| 1918 | +1-year survival | |
| 1919 | +5-year survival | |
| 1920 | +10-year survival | |
| 1921 | +stage IV survival | |
| 1922 | +median OS where meaningful | |
| 1923 | +``` | |
| 1924 | + | |
| 1925 | +## Trend | |
| 1926 | + | |
| 1927 | +```text | |
| 1928 | +incidence CAGR | |
| 1929 | +mortality CAGR | |
| 1930 | +survival improvement | |
| 1931 | +age-adjusted incidence trend | |
| 1932 | +``` | |
| 1933 | + | |
| 1934 | +## Rarity | |
| 1935 | + | |
| 1936 | +```text | |
| 1937 | +global incidence rank | |
| 1938 | +incidence per 100k | |
| 1939 | +estimated annual patients | |
| 1940 | +``` | |
| 1941 | + | |
| 1942 | +## Treatment Landscape | |
| 1943 | + | |
| 1944 | +```text | |
| 1945 | +number of approved therapies | |
| 1946 | +number of targeted therapies | |
| 1947 | +number of immunotherapies | |
| 1948 | +number of biomarker-directed therapies | |
| 1949 | +number of treatment classes | |
| 1950 | +``` | |
| 1951 | + | |
| 1952 | +## Clinical Research | |
| 1953 | + | |
| 1954 | +```text | |
| 1955 | +active trials | |
| 1956 | +recruiting trials | |
| 1957 | +phase I trials | |
| 1958 | +phase II trials | |
| 1959 | +phase III trials | |
| 1960 | +interventional trial count | |
| 1961 | +trial enrollment | |
| 1962 | +``` | |
| 1963 | + | |
| 1964 | +## Research Activity | |
| 1965 | + | |
| 1966 | +```text | |
| 1967 | +publications last 12 months | |
| 1968 | +publications last 5 years | |
| 1969 | +publication growth | |
| 1970 | +citations | |
| 1971 | +research institutions | |
| 1972 | +``` | |
| 1973 | + | |
| 1974 | +## Molecular Knowledge | |
| 1975 | + | |
| 1976 | +```text | |
| 1977 | +known recurrent genes | |
| 1978 | +actionable variants | |
| 1979 | +validated biomarkers | |
| 1980 | +genomic studies | |
| 1981 | +sequenced cohorts | |
| 1982 | +``` | |
| 1983 | + | |
| 1984 | +## Unmet Need | |
| 1985 | + | |
| 1986 | +Derived carefully from: | |
| 1987 | + | |
| 1988 | +```text | |
| 1989 | +mortality burden | |
| 1990 | +poor survival | |
| 1991 | +few approved therapies | |
| 1992 | +few active trials | |
| 1993 | +low research activity | |
| 1994 | +lack of actionable biomarkers | |
| 1995 | +``` | |
| 1996 | + | |
| 1997 | +--- | |
| 1998 | + | |
| 1999 | +# 32. RANK EVERY CANCER BY DEFAULT | |
| 2000 | + | |
| 2001 | +Cancer detail pages should display: | |
| 2002 | + | |
| 2003 | +```text | |
| 2004 | +Global incidence rank | |
| 2005 | +Global mortality rank | |
| 2006 | +5-year survival rank | |
| 2007 | +Lethality rank | |
| 2008 | +Research activity rank | |
| 2009 | +Clinical trial rank | |
| 2010 | +Treatment availability rank | |
| 2011 | +Genomic knowledge rank | |
| 2012 | +Unmet need rank | |
| 2013 | +CancerIndex composite rank | |
| 2014 | +``` | |
| 2015 | + | |
| 2016 | +Example: | |
| 2017 | + | |
| 2018 | +```text | |
| 2019 | +Pancreatic Adenocarcinoma | |
| 2020 | + | |
| 2021 | +Mortality burden #7 | |
| 2022 | +Incidence burden #14 | |
| 2023 | +Lethality #3 | |
| 2024 | +Five-year survival #4 poorest | |
| 2025 | +Research activity #11 | |
| 2026 | +Active trials #15 | |
| 2027 | +Treatment options #82 | |
| 2028 | +Unmet need #5 | |
| 2029 | +CancerIndex Impact #8 | |
| 2030 | +``` | |
| 2031 | + | |
| 2032 | +These are examples only. | |
| 2033 | + | |
| 2034 | +Never hardcode examples as actual statistics. | |
| 2035 | + | |
| 2036 | +--- | |
| 2037 | + | |
| 2038 | +# 33. RANKING SCOPE | |
| 2039 | + | |
| 2040 | +Rankings need scope. | |
| 2041 | + | |
| 2042 | +Example: | |
| 2043 | + | |
| 2044 | +```text | |
| 2045 | +WORLD | |
| 2046 | +CANADA | |
| 2047 | +UNITED STATES | |
| 2048 | +EUROPE | |
| 2049 | +QUEBEC | |
| 2050 | +MALE | |
| 2051 | +FEMALE | |
| 2052 | +CHILDREN | |
| 2053 | +AYA | |
| 2054 | +AGE 65+ | |
| 2055 | +2024 | |
| 2056 | +2025 | |
| 2057 | +historical | |
| 2058 | +``` | |
| 2059 | + | |
| 2060 | +A rank is meaningless without a population and reference year. | |
| 2061 | + | |
| 2062 | +Schema: | |
| 2063 | + | |
| 2064 | +```ts | |
| 2065 | +interface Ranking { | |
| 2066 | + cancerId: string | |
| 2067 | + | |
| 2068 | + metricId: string | |
| 2069 | + | |
| 2070 | + rank: number | |
| 2071 | + eligibleEntities: number | |
| 2072 | + | |
| 2073 | + percentile: number | |
| 2074 | + | |
| 2075 | + geography: string | |
| 2076 | + sex?: string | |
| 2077 | + ageGroup?: string | |
| 2078 | + year?: number | |
| 2079 | + | |
| 2080 | + value: number | |
| 2081 | + unit: string | |
| 2082 | + | |
| 2083 | + sourceIds: string[] | |
| 2084 | + | |
| 2085 | + formulaVersion?: string | |
| 2086 | + | |
| 2087 | + generatedAt: string | |
| 2088 | +} | |
| 2089 | +``` | |
| 2090 | + | |
| 2091 | +--- | |
| 2092 | + | |
| 2093 | +# 34. COMPOSITE CANCERINDEX SCORE | |
| 2094 | + | |
| 2095 | +Create an optional composite metric. | |
| 2096 | + | |
| 2097 | +Do NOT present it as biological truth. | |
| 2098 | + | |
| 2099 | +Possible conceptual model: | |
| 2100 | + | |
| 2101 | +```text | |
| 2102 | +CancerIndex Impact Score | |
| 2103 | +``` | |
| 2104 | + | |
| 2105 | +0–100. | |
| 2106 | + | |
| 2107 | +Possible components: | |
| 2108 | + | |
| 2109 | +```text | |
| 2110 | +25% mortality burden | |
| 2111 | +20% lethality | |
| 2112 | +15% incidence burden | |
| 2113 | +15% unmet treatment need | |
| 2114 | +10% adverse trend | |
| 2115 | +10% research deficit | |
| 2116 | +5% clinical trial deficit | |
| 2117 | +``` | |
| 2118 | + | |
| 2119 | +Weights must be: | |
| 2120 | + | |
| 2121 | +* visible | |
| 2122 | +* versioned | |
| 2123 | +* configurable | |
| 2124 | +* documented | |
| 2125 | + | |
| 2126 | +Example: | |
| 2127 | + | |
| 2128 | +```text | |
| 2129 | +CancerIndex Impact Score v1.0 | |
| 2130 | +``` | |
| 2131 | + | |
| 2132 | +Display: | |
| 2133 | + | |
| 2134 | +```text | |
| 2135 | +Score: 87.4 / 100 | |
| 2136 | +Rank: 6 / 412 | |
| 2137 | +``` | |
| 2138 | + | |
| 2139 | +and a breakdown. | |
| 2140 | + | |
| 2141 | +Never show only 87.4. | |
| 2142 | + | |
| 2143 | +Show: | |
| 2144 | + | |
| 2145 | +```text | |
| 2146 | +Mortality burden 93 | |
| 2147 | +Lethality 97 | |
| 2148 | +Incidence 78 | |
| 2149 | +Treatment deficit 84 | |
| 2150 | +Research deficit 63 | |
| 2151 | +Trend 71 | |
| 2152 | +``` | |
| 2153 | + | |
| 2154 | +--- | |
| 2155 | + | |
| 2156 | +# 35. UNCERTAINTY | |
| 2157 | + | |
| 2158 | +Rankings must account for uncertainty. | |
| 2159 | + | |
| 2160 | +A rare cancer may have: | |
| 2161 | + | |
| 2162 | +```text | |
| 2163 | +n = 22 | |
| 2164 | +``` | |
| 2165 | + | |
| 2166 | +Do not rank survival estimates derived from tiny datasets as equivalent to huge registry datasets. | |
| 2167 | + | |
| 2168 | +Store: | |
| 2169 | + | |
| 2170 | +```text | |
| 2171 | +sample size | |
| 2172 | +confidence interval | |
| 2173 | +standard error | |
| 2174 | +estimate method | |
| 2175 | +source quality | |
| 2176 | +data completeness | |
| 2177 | +``` | |
| 2178 | + | |
| 2179 | +Optionally display: | |
| 2180 | + | |
| 2181 | +```text | |
| 2182 | +Ranking confidence | |
| 2183 | + | |
| 2184 | +HIGH | |
| 2185 | +MEDIUM | |
| 2186 | +LOW | |
| 2187 | +INSUFFICIENT DATA | |
| 2188 | +``` | |
| 2189 | + | |
| 2190 | +--- | |
| 2191 | + | |
| 2192 | +# 36. DATA COMPLETENESS SCORE | |
| 2193 | + | |
| 2194 | +Every cancer gets a completeness profile. | |
| 2195 | + | |
| 2196 | +Example: | |
| 2197 | + | |
| 2198 | +```text | |
| 2199 | +Epidemiology 92% | |
| 2200 | +Survival 81% | |
| 2201 | +Genomics 97% | |
| 2202 | +Trials 100% | |
| 2203 | +Therapies 93% | |
| 2204 | +Biomarkers 88% | |
| 2205 | +Literature 100% | |
| 2206 | +Pathology 76% | |
| 2207 | +``` | |
| 2208 | + | |
| 2209 | +This is separate from scientific confidence. | |
| 2210 | + | |
| 2211 | +--- | |
| 2212 | + | |
| 2213 | +# 37. RESEARCH GAP INDEX | |
| 2214 | + | |
| 2215 | +Create a major CancerIndex innovation: | |
| 2216 | + | |
| 2217 | +## Research Gap Index | |
| 2218 | + | |
| 2219 | +Question: | |
| 2220 | + | |
| 2221 | +> Which cancers have a large burden but disproportionately little research? | |
| 2222 | + | |
| 2223 | +Possible formula: | |
| 2224 | + | |
| 2225 | +```text | |
| 2226 | +burden percentile | |
| 2227 | +÷ | |
| 2228 | +research activity percentile | |
| 2229 | +``` | |
| 2230 | + | |
| 2231 | +More sophisticated version: | |
| 2232 | + | |
| 2233 | +```text | |
| 2234 | +expected research activity = | |
| 2235 | +f( | |
| 2236 | + incidence, | |
| 2237 | + mortality, | |
| 2238 | + years_of_life_lost, | |
| 2239 | + lethality | |
| 2240 | +) | |
| 2241 | + | |
| 2242 | +research gap = | |
| 2243 | +expected activity - observed activity | |
| 2244 | +``` | |
| 2245 | + | |
| 2246 | +Display: | |
| 2247 | + | |
| 2248 | +**Most Under-Researched Cancers** | |
| 2249 | + | |
| 2250 | +This could be extremely compelling. | |
| 2251 | + | |
| 2252 | +--- | |
| 2253 | + | |
| 2254 | +# 38. TRIAL GAP INDEX | |
| 2255 | + | |
| 2256 | +Another ranking: | |
| 2257 | + | |
| 2258 | +```text | |
| 2259 | +disease burden | |
| 2260 | +vs | |
| 2261 | +active interventional trials | |
| 2262 | +``` | |
| 2263 | + | |
| 2264 | +Identify: | |
| 2265 | + | |
| 2266 | +> High-burden cancers with few active trials. | |
| 2267 | + | |
| 2268 | +--- | |
| 2269 | + | |
| 2270 | +# 39. TREATMENT GAP INDEX | |
| 2271 | + | |
| 2272 | +Rank cancers based on: | |
| 2273 | + | |
| 2274 | +```text | |
| 2275 | +mortality | |
| 2276 | +survival | |
| 2277 | +approved drug count | |
| 2278 | +effective targeted treatment count | |
| 2279 | +biomarker-directed therapies | |
| 2280 | +``` | |
| 2281 | + | |
| 2282 | +Again, label as CancerIndex-derived metric. | |
| 2283 | + | |
| 2284 | +--- | |
| 2285 | + | |
| 2286 | +# 40. PROGRESS INDEX | |
| 2287 | + | |
| 2288 | +Create: | |
| 2289 | + | |
| 2290 | +**Cancer Progress Index** | |
| 2291 | + | |
| 2292 | +Track over 5/10/20 years: | |
| 2293 | + | |
| 2294 | +```text | |
| 2295 | +mortality improvement | |
| 2296 | +survival improvement | |
| 2297 | +treatment approvals | |
| 2298 | +trial growth | |
| 2299 | +biomarker growth | |
| 2300 | +research growth | |
| 2301 | +``` | |
| 2302 | + | |
| 2303 | +Show: | |
| 2304 | + | |
| 2305 | +```text | |
| 2306 | +Most rapidly improving cancers | |
| 2307 | +Least improving cancers | |
| 2308 | +``` | |
| 2309 | + | |
| 2310 | +--- | |
| 2311 | + | |
| 2312 | +# 41. MOMENTUM INDEX | |
| 2313 | + | |
| 2314 | +Short-term research momentum. | |
| 2315 | + | |
| 2316 | +Components: | |
| 2317 | + | |
| 2318 | +```text | |
| 2319 | +new trials | |
| 2320 | +new publications | |
| 2321 | +new drugs | |
| 2322 | +new FDA approvals | |
| 2323 | +new biomarkers | |
| 2324 | +new genomic studies | |
| 2325 | +``` | |
| 2326 | + | |
| 2327 | +Windows: | |
| 2328 | + | |
| 2329 | +```text | |
| 2330 | +30 days | |
| 2331 | +90 days | |
| 2332 | +1 year | |
| 2333 | +5 years | |
| 2334 | +``` | |
| 2335 | + | |
| 2336 | +--- | |
| 2337 | + | |
| 2338 | +# 42. CANCER ENTITY DETAIL PAGE | |
| 2339 | + | |
| 2340 | +Route: | |
| 2341 | + | |
| 2342 | +```text | |
| 2343 | +/cancer/{slug} | |
| 2344 | +``` | |
| 2345 | + | |
| 2346 | +Example layout: | |
| 2347 | + | |
| 2348 | +```text | |
| 2349 | +┌─────────────────────────────────────────────┐ | |
| 2350 | +│ Pancreatic Ductal Adenocarcinoma │ | |
| 2351 | +│ PDAC │ | |
| 2352 | +│ CI-CAN-0000342 │ | |
| 2353 | +└─────────────────────────────────────────────┘ | |
| 2354 | + | |
| 2355 | +CancerIndex Score | |
| 2356 | +89.2 | |
| 2357 | + | |
| 2358 | +Global Rank | |
| 2359 | +#5 | |
| 2360 | + | |
| 2361 | +Tabs: | |
| 2362 | +Overview | |
| 2363 | +Statistics | |
| 2364 | +Survival | |
| 2365 | +Stages | |
| 2366 | +Genomics | |
| 2367 | +Genes | |
| 2368 | +Variants | |
| 2369 | +Biomarkers | |
| 2370 | +Treatments | |
| 2371 | +Drugs | |
| 2372 | +Trials | |
| 2373 | +Research | |
| 2374 | +Publications | |
| 2375 | +Risk Factors | |
| 2376 | +Screening | |
| 2377 | +Prevention | |
| 2378 | +Countries | |
| 2379 | +Trends | |
| 2380 | +Sources | |
| 2381 | +``` | |
| 2382 | + | |
| 2383 | +--- | |
| 2384 | + | |
| 2385 | +# 43. CANCER OVERVIEW HERO | |
| 2386 | + | |
| 2387 | +Show: | |
| 2388 | + | |
| 2389 | +```text | |
| 2390 | +Global annual cases | |
| 2391 | +Global annual deaths | |
| 2392 | +5-year survival | |
| 2393 | +median diagnosis age | |
| 2394 | +male/female distribution | |
| 2395 | + | |
| 2396 | +Impact rank | |
| 2397 | +Mortality rank | |
| 2398 | +Lethality rank | |
| 2399 | +Research rank | |
| 2400 | +Unmet need rank | |
| 2401 | +``` | |
| 2402 | + | |
| 2403 | +Never display unsupported values. | |
| 2404 | + | |
| 2405 | +--- | |
| 2406 | + | |
| 2407 | +# 44. CANCER SUMMARY | |
| 2408 | + | |
| 2409 | +AI-generated summary should contain: | |
| 2410 | + | |
| 2411 | +```text | |
| 2412 | +What it is | |
| 2413 | +Where it originates | |
| 2414 | +Major subtypes | |
| 2415 | +Epidemiology | |
| 2416 | +Typical molecular features | |
| 2417 | +Major treatment modalities | |
| 2418 | +Current research landscape | |
| 2419 | +``` | |
| 2420 | + | |
| 2421 | +Every paragraph needs citations. | |
| 2422 | + | |
| 2423 | +AI summaries must be cached with: | |
| 2424 | + | |
| 2425 | +```text | |
| 2426 | +model | |
| 2427 | +prompt version | |
| 2428 | +source snapshot | |
| 2429 | +generation date | |
| 2430 | +``` | |
| 2431 | + | |
| 2432 | +--- | |
| 2433 | + | |
| 2434 | +# 45. GLOBAL CANCER RANKING PAGE | |
| 2435 | + | |
| 2436 | +Route: | |
| 2437 | + | |
| 2438 | +```text | |
| 2439 | +/rankings | |
| 2440 | +``` | |
| 2441 | + | |
| 2442 | +Filters: | |
| 2443 | + | |
| 2444 | +```text | |
| 2445 | +metric | |
| 2446 | +year | |
| 2447 | +country | |
| 2448 | +region | |
| 2449 | +sex | |
| 2450 | +age | |
| 2451 | +cancer category | |
| 2452 | +minimum cases | |
| 2453 | +data confidence | |
| 2454 | +``` | |
| 2455 | + | |
| 2456 | +Columns: | |
| 2457 | + | |
| 2458 | +```text | |
| 2459 | +Rank | |
| 2460 | +Cancer | |
| 2461 | +Score/value | |
| 2462 | +Cases | |
| 2463 | +Deaths | |
| 2464 | +Mortality/incidence | |
| 2465 | +5-year survival | |
| 2466 | +Active trials | |
| 2467 | +Publications | |
| 2468 | +Trend | |
| 2469 | +``` | |
| 2470 | + | |
| 2471 | +--- | |
| 2472 | + | |
| 2473 | +# 46. RANKING PRESETS | |
| 2474 | + | |
| 2475 | +Routes or presets: | |
| 2476 | + | |
| 2477 | +```text | |
| 2478 | +/rankings/incidence | |
| 2479 | +/rankings/mortality | |
| 2480 | +/rankings/lethality | |
| 2481 | +/rankings/survival | |
| 2482 | +/rankings/research | |
| 2483 | +/rankings/trials | |
| 2484 | +/rankings/treatment-gap | |
| 2485 | +/rankings/research-gap | |
| 2486 | +/rankings/momentum | |
| 2487 | +/rankings/progress | |
| 2488 | +/rankings/rare-cancers | |
| 2489 | +``` | |
| 2490 | + | |
| 2491 | +--- | |
| 2492 | + | |
| 2493 | +# 47. COUNTRY PAGES | |
| 2494 | + | |
| 2495 | +Route: | |
| 2496 | + | |
| 2497 | +```text | |
| 2498 | +/country/canada | |
| 2499 | +/country/united-states | |
| 2500 | +/country/france | |
| 2501 | +``` | |
| 2502 | + | |
| 2503 | +Display: | |
| 2504 | + | |
| 2505 | +```text | |
| 2506 | +population | |
| 2507 | +annual cancer cases | |
| 2508 | +annual cancer deaths | |
| 2509 | +ASIR | |
| 2510 | +ASMR | |
| 2511 | + | |
| 2512 | +Top cancers by incidence | |
| 2513 | +Top cancers by mortality | |
| 2514 | +male | |
| 2515 | +female | |
| 2516 | + | |
| 2517 | +historical trend | |
| 2518 | +age distribution | |
| 2519 | +``` | |
| 2520 | + | |
| 2521 | +Map visualization. | |
| 2522 | + | |
| 2523 | +--- | |
| 2524 | + | |
| 2525 | +# 48. GLOBAL CANCER MAP | |
| 2526 | + | |
| 2527 | +Interactive world map. | |
| 2528 | + | |
| 2529 | +Filters: | |
| 2530 | + | |
| 2531 | +```text | |
| 2532 | +cancer | |
| 2533 | +incidence | |
| 2534 | +mortality | |
| 2535 | +ASR | |
| 2536 | +sex | |
| 2537 | +year | |
| 2538 | +age | |
| 2539 | +``` | |
| 2540 | + | |
| 2541 | +Click country → country dashboard. | |
| 2542 | + | |
| 2543 | +--- | |
| 2544 | + | |
| 2545 | +# 49. GENE PAGES | |
| 2546 | + | |
| 2547 | +Route: | |
| 2548 | + | |
| 2549 | +```text | |
| 2550 | +/gene/TP53 | |
| 2551 | +``` | |
| 2552 | + | |
| 2553 | +Display: | |
| 2554 | + | |
| 2555 | +```text | |
| 2556 | +gene overview | |
| 2557 | +HGNC identity | |
| 2558 | +chromosome | |
| 2559 | +protein | |
| 2560 | +pathways | |
| 2561 | + | |
| 2562 | +cancers | |
| 2563 | +variants | |
| 2564 | +mutation frequencies | |
| 2565 | +biomarkers | |
| 2566 | +therapies | |
| 2567 | +clinical trials | |
| 2568 | +publications | |
| 2569 | +``` | |
| 2570 | + | |
| 2571 | +--- | |
| 2572 | + | |
| 2573 | +# 50. VARIANT PAGES | |
| 2574 | + | |
| 2575 | +Example: | |
| 2576 | + | |
| 2577 | +```text | |
| 2578 | +/variant/BRAF-V600E | |
| 2579 | +``` | |
| 2580 | + | |
| 2581 | +Display: | |
| 2582 | + | |
| 2583 | +```text | |
| 2584 | +gene | |
| 2585 | +HGVS | |
| 2586 | +protein change | |
| 2587 | +coordinates by assembly | |
| 2588 | +ClinVar | |
| 2589 | +CIViC | |
| 2590 | +cancers | |
| 2591 | +frequencies | |
| 2592 | +drug sensitivity evidence | |
| 2593 | +drug resistance evidence | |
| 2594 | +clinical trials | |
| 2595 | +publications | |
| 2596 | +``` | |
| 2597 | + | |
| 2598 | +Separate evidence by cancer. | |
| 2599 | + | |
| 2600 | +BRAF V600E in one cancer must not automatically inherit evidence from another cancer. | |
| 2601 | + | |
| 2602 | +--- | |
| 2603 | + | |
| 2604 | +# 51. BIOMARKER PAGES | |
| 2605 | + | |
| 2606 | +Examples: | |
| 2607 | + | |
| 2608 | +```text | |
| 2609 | +PD-L1 | |
| 2610 | +MSI-H | |
| 2611 | +TMB-high | |
| 2612 | +HER2 | |
| 2613 | +HRD | |
| 2614 | +ER | |
| 2615 | +PR | |
| 2616 | +PSMA | |
| 2617 | +ctDNA | |
| 2618 | +``` | |
| 2619 | + | |
| 2620 | +Display: | |
| 2621 | + | |
| 2622 | +```text | |
| 2623 | +definition | |
| 2624 | +measurement method | |
| 2625 | +cancers | |
| 2626 | +therapies | |
| 2627 | +FDA-approved indications | |
| 2628 | +clinical evidence | |
| 2629 | +trials | |
| 2630 | +publications | |
| 2631 | +``` | |
| 2632 | + | |
| 2633 | +--- | |
| 2634 | + | |
| 2635 | +# 52. DRUG PAGES | |
| 2636 | + | |
| 2637 | +Route: | |
| 2638 | + | |
| 2639 | +```text | |
| 2640 | +/drug/osimertinib | |
| 2641 | +``` | |
| 2642 | + | |
| 2643 | +Hero: | |
| 2644 | + | |
| 2645 | +```text | |
| 2646 | +Generic name | |
| 2647 | +Brand names | |
| 2648 | +Drug class | |
| 2649 | +Targets | |
| 2650 | +Mechanism | |
| 2651 | +Developer | |
| 2652 | +First approval | |
| 2653 | +Current jurisdictions | |
| 2654 | +``` | |
| 2655 | + | |
| 2656 | +Tabs: | |
| 2657 | + | |
| 2658 | +```text | |
| 2659 | +Overview | |
| 2660 | +Mechanism | |
| 2661 | +Targets | |
| 2662 | +Cancer indications | |
| 2663 | +Biomarkers | |
| 2664 | +Approvals | |
| 2665 | +Clinical trials | |
| 2666 | +Publications | |
| 2667 | +Combinations | |
| 2668 | +Resistance | |
| 2669 | +Safety | |
| 2670 | +Sources | |
| 2671 | +``` | |
| 2672 | + | |
| 2673 | +--- | |
| 2674 | + | |
| 2675 | +# 53. DRUG COMBINATION ENTITY | |
| 2676 | + | |
| 2677 | +Do not treat: | |
| 2678 | + | |
| 2679 | +```text | |
| 2680 | +Drug A + Drug B | |
| 2681 | +``` | |
| 2682 | + | |
| 2683 | +as two unrelated drugs. | |
| 2684 | + | |
| 2685 | +Create: | |
| 2686 | + | |
| 2687 | +```text | |
| 2688 | +TreatmentRegimen | |
| 2689 | +``` | |
| 2690 | + | |
| 2691 | +Examples: | |
| 2692 | + | |
| 2693 | +```text | |
| 2694 | +FOLFOX | |
| 2695 | +FOLFIRINOX | |
| 2696 | +R-CHOP | |
| 2697 | +ABVD | |
| 2698 | +drug A + drug B | |
| 2699 | +``` | |
| 2700 | + | |
| 2701 | +--- | |
| 2702 | + | |
| 2703 | +# 54. CLINICAL TRIAL PAGES | |
| 2704 | + | |
| 2705 | +Route: | |
| 2706 | + | |
| 2707 | +```text | |
| 2708 | +/trial/NCT... | |
| 2709 | +``` | |
| 2710 | + | |
| 2711 | +Display: | |
| 2712 | + | |
| 2713 | +```text | |
| 2714 | +status | |
| 2715 | +phase | |
| 2716 | +title | |
| 2717 | +cancers | |
| 2718 | +biomarkers | |
| 2719 | +interventions | |
| 2720 | +enrollment | |
| 2721 | +sponsor | |
| 2722 | +locations | |
| 2723 | +eligibility | |
| 2724 | +dates | |
| 2725 | +outcomes | |
| 2726 | +publications | |
| 2727 | +results | |
| 2728 | +``` | |
| 2729 | + | |
| 2730 | +--- | |
| 2731 | + | |
| 2732 | +# 55. TRIAL MATCH EXPLORER | |
| 2733 | + | |
| 2734 | +Research use only. | |
| 2735 | + | |
| 2736 | +Filters: | |
| 2737 | + | |
| 2738 | +```text | |
| 2739 | +cancer | |
| 2740 | +stage | |
| 2741 | +gene | |
| 2742 | +variant | |
| 2743 | +biomarker | |
| 2744 | +drug | |
| 2745 | +phase | |
| 2746 | +country | |
| 2747 | +recruiting status | |
| 2748 | +age | |
| 2749 | +sex | |
| 2750 | +``` | |
| 2751 | + | |
| 2752 | +Do not claim a patient is eligible solely from automated filtering. | |
| 2753 | + | |
| 2754 | +Use: | |
| 2755 | + | |
| 2756 | +> Potentially relevant trials — verify full eligibility criteria with the study team. | |
| 2757 | + | |
| 2758 | +--- | |
| 2759 | + | |
| 2760 | +# 56. PUBLICATION PAGES | |
| 2761 | + | |
| 2762 | +Route: | |
| 2763 | + | |
| 2764 | +```text | |
| 2765 | +/publication/{pmid} | |
| 2766 | +``` | |
| 2767 | + | |
| 2768 | +Show: | |
| 2769 | + | |
| 2770 | +```text | |
| 2771 | +title | |
| 2772 | +authors | |
| 2773 | +journal | |
| 2774 | +date | |
| 2775 | +abstract | |
| 2776 | +DOI | |
| 2777 | +publication type | |
| 2778 | + | |
| 2779 | +linked cancers | |
| 2780 | +linked genes | |
| 2781 | +linked variants | |
| 2782 | +linked drugs | |
| 2783 | +linked trials | |
| 2784 | +``` | |
| 2785 | + | |
| 2786 | +AI: | |
| 2787 | + | |
| 2788 | +```text | |
| 2789 | +structured research summary | |
| 2790 | +``` | |
| 2791 | + | |
| 2792 | +Only where legally permitted from available text. | |
| 2793 | + | |
| 2794 | +--- | |
| 2795 | + | |
| 2796 | +# 57. RESEARCHER PAGES | |
| 2797 | + | |
| 2798 | +Optional later stage: | |
| 2799 | + | |
| 2800 | +```text | |
| 2801 | +/researcher/{id} | |
| 2802 | +``` | |
| 2803 | + | |
| 2804 | +Metrics: | |
| 2805 | + | |
| 2806 | +```text | |
| 2807 | +oncology publications | |
| 2808 | +cancers studied | |
| 2809 | +genes studied | |
| 2810 | +clinical trials | |
| 2811 | +citations | |
| 2812 | +institutions | |
| 2813 | +``` | |
| 2814 | + | |
| 2815 | +Avoid misleading researcher ranking based on raw citation count alone. | |
| 2816 | + | |
| 2817 | +--- | |
| 2818 | + | |
| 2819 | +# 58. INSTITUTION PAGES | |
| 2820 | + | |
| 2821 | +Examples: | |
| 2822 | + | |
| 2823 | +```text | |
| 2824 | +MD Anderson | |
| 2825 | +Memorial Sloan Kettering | |
| 2826 | +Dana-Farber | |
| 2827 | +Princess Margaret | |
| 2828 | +Mayo Clinic | |
| 2829 | +Gustave Roussy | |
| 2830 | +``` | |
| 2831 | + | |
| 2832 | +Automatically derived from: | |
| 2833 | + | |
| 2834 | +```text | |
| 2835 | +trials | |
| 2836 | +authors | |
| 2837 | +affiliations | |
| 2838 | +publications | |
| 2839 | +``` | |
| 2840 | + | |
| 2841 | +Rank institutions by transparent criteria, not prestige claims. | |
| 2842 | + | |
| 2843 | +--- | |
| 2844 | + | |
| 2845 | +# 59. CANCER RESEARCH DASHBOARD | |
| 2846 | + | |
| 2847 | +Route: | |
| 2848 | + | |
| 2849 | +```text | |
| 2850 | +/research | |
| 2851 | +``` | |
| 2852 | + | |
| 2853 | +Show: | |
| 2854 | + | |
| 2855 | +```text | |
| 2856 | +publications/year | |
| 2857 | +trials/year | |
| 2858 | +new drugs/year | |
| 2859 | +new targets/year | |
| 2860 | +new biomarkers/year | |
| 2861 | +research funding when reliable data exists | |
| 2862 | +``` | |
| 2863 | + | |
| 2864 | +--- | |
| 2865 | + | |
| 2866 | +# 60. RESEARCH TRENDS | |
| 2867 | + | |
| 2868 | +Detect emerging topics. | |
| 2869 | + | |
| 2870 | +Examples: | |
| 2871 | + | |
| 2872 | +```text | |
| 2873 | +KRAS G12D | |
| 2874 | +T-cell engagers | |
| 2875 | +ADC | |
| 2876 | +ctDNA | |
| 2877 | +personalized vaccines | |
| 2878 | +radioligand therapy | |
| 2879 | +CAR-T in solid tumors | |
| 2880 | +``` | |
| 2881 | + | |
| 2882 | +Do not hardcode trends. | |
| 2883 | + | |
| 2884 | +Calculate from publication/trial growth. | |
| 2885 | + | |
| 2886 | +--- | |
| 2887 | + | |
| 2888 | +# 61. CANCER NEWS | |
| 2889 | + | |
| 2890 | +Potential later connector layer: | |
| 2891 | + | |
| 2892 | +```text | |
| 2893 | +FDA | |
| 2894 | +NCI | |
| 2895 | +NIH | |
| 2896 | +major journals | |
| 2897 | +cancer centers | |
| 2898 | +regulators | |
| 2899 | +clinical trial updates | |
| 2900 | +``` | |
| 2901 | + | |
| 2902 | +Use original source and publication timestamp. | |
| 2903 | + | |
| 2904 | +AI clustering: | |
| 2905 | + | |
| 2906 | +```text | |
| 2907 | +multiple reports → one story cluster | |
| 2908 | +``` | |
| 2909 | + | |
| 2910 | +--- | |
| 2911 | + | |
| 2912 | +# 62. AI — ASK CANCERINDEX | |
| 2913 | + | |
| 2914 | +CancerIndex should contain a research assistant. | |
| 2915 | + | |
| 2916 | +Route: | |
| 2917 | + | |
| 2918 | +```text | |
| 2919 | +/ask | |
| 2920 | +``` | |
| 2921 | + | |
| 2922 | +Example questions: | |
| 2923 | + | |
| 2924 | +```text | |
| 2925 | +Which cancers have the highest mortality-to-incidence ratio? | |
| 2926 | + | |
| 2927 | +What are the most frequent genomic alterations in LUAD? | |
| 2928 | + | |
| 2929 | +Compare KRAS G12C in lung and colorectal cancer. | |
| 2930 | + | |
| 2931 | +Which recruiting Phase III trials are testing therapies for pancreatic cancer? | |
| 2932 | + | |
| 2933 | +Which rare cancers have the fewest active clinical trials relative to incidence? | |
| 2934 | + | |
| 2935 | +What cancers have seen the largest improvement in survival over 20 years? | |
| 2936 | +``` | |
| 2937 | + | |
| 2938 | +--- | |
| 2939 | + | |
| 2940 | +# 63. AI MUST QUERY STRUCTURED DATA FIRST | |
| 2941 | + | |
| 2942 | +Never do: | |
| 2943 | + | |
| 2944 | +```text | |
| 2945 | +question | |
| 2946 | +↓ | |
| 2947 | +LLM general knowledge | |
| 2948 | +↓ | |
| 2949 | +answer | |
| 2950 | +``` | |
| 2951 | + | |
| 2952 | +Do: | |
| 2953 | + | |
| 2954 | +```text | |
| 2955 | +question | |
| 2956 | +↓ | |
| 2957 | +intent parser | |
| 2958 | +↓ | |
| 2959 | +CancerIndex query plan | |
| 2960 | +↓ | |
| 2961 | +SQL / graph / search | |
| 2962 | +↓ | |
| 2963 | +source records | |
| 2964 | +↓ | |
| 2965 | +LLM synthesis | |
| 2966 | +↓ | |
| 2967 | +citations | |
| 2968 | +``` | |
| 2969 | + | |
| 2970 | +--- | |
| 2971 | + | |
| 2972 | +# 64. AI ANSWER CONTRACT | |
| 2973 | + | |
| 2974 | +Every answer returns: | |
| 2975 | + | |
| 2976 | +```json | |
| 2977 | +{ | |
| 2978 | + "answer": "...", | |
| 2979 | + "entities": [], | |
| 2980 | + "citations": [], | |
| 2981 | + "data_as_of": "...", | |
| 2982 | + "confidence": "...", | |
| 2983 | + "limitations": [] | |
| 2984 | +} | |
| 2985 | +``` | |
| 2986 | + | |
| 2987 | +--- | |
| 2988 | + | |
| 2989 | +# 65. AI CITATIONS | |
| 2990 | + | |
| 2991 | +Every important assertion must point to: | |
| 2992 | + | |
| 2993 | +```text | |
| 2994 | +source | |
| 2995 | +dataset | |
| 2996 | +publication | |
| 2997 | +or regulatory record | |
| 2998 | +``` | |
| 2999 | + | |
| 3000 | +Click citation → source drawer. | |
| 3001 | + | |
| 3002 | +Source drawer: | |
| 3003 | + | |
| 3004 | +```text | |
| 3005 | +Source | |
| 3006 | +Organization | |
| 3007 | +Dataset | |
| 3008 | +Version | |
| 3009 | +Record | |
| 3010 | +Retrieved | |
| 3011 | +Raw value | |
| 3012 | +Normalized value | |
| 3013 | +Transformation | |
| 3014 | +``` | |
| 3015 | + | |
| 3016 | +--- | |
| 3017 | + | |
| 3018 | +# 66. AI MODEL PROVIDER ABSTRACTION | |
| 3019 | + | |
| 3020 | +Do not couple the application to one LLM. | |
| 3021 | + | |
| 3022 | +Interface: | |
| 3023 | + | |
| 3024 | +```ts | |
| 3025 | +interface LLMProvider { | |
| 3026 | + generate() | |
| 3027 | + stream() | |
| 3028 | + structuredOutput() | |
| 3029 | + embed() | |
| 3030 | +} | |
| 3031 | +``` | |
| 3032 | + | |
| 3033 | +Support configurable providers. | |
| 3034 | + | |
| 3035 | +Possible: | |
| 3036 | + | |
| 3037 | +```text | |
| 3038 | +OpenAI | |
| 3039 | +Anthropic | |
| 3040 | ||
| 3041 | +xAI | |
| 3042 | +local OpenAI-compatible endpoint | |
| 3043 | +``` | |
| 3044 | + | |
| 3045 | +CancerIndex should operate without requiring AI for core database functionality. | |
| 3046 | + | |
| 3047 | +--- | |
| 3048 | + | |
| 3049 | +# 67. EMBEDDINGS | |
| 3050 | + | |
| 3051 | +Generate embeddings for: | |
| 3052 | + | |
| 3053 | +```text | |
| 3054 | +cancer descriptions | |
| 3055 | +publication abstracts | |
| 3056 | +trial descriptions | |
| 3057 | +drug mechanisms | |
| 3058 | +biomarker descriptions | |
| 3059 | +evidence summaries | |
| 3060 | +``` | |
| 3061 | + | |
| 3062 | +Use pgvector initially. | |
| 3063 | + | |
| 3064 | +Store embedding model/version. | |
| 3065 | + | |
| 3066 | +Never mix embeddings generated by incompatible models in one vector column without model metadata. | |
| 3067 | + | |
| 3068 | +--- | |
| 3069 | + | |
| 3070 | +# 68. SEARCH ENGINE | |
| 3071 | + | |
| 3072 | +Global search should support: | |
| 3073 | + | |
| 3074 | +```text | |
| 3075 | +cancers | |
| 3076 | +subtypes | |
| 3077 | +genes | |
| 3078 | +variants | |
| 3079 | +biomarkers | |
| 3080 | +drugs | |
| 3081 | +trials | |
| 3082 | +publications | |
| 3083 | +institutions | |
| 3084 | +researchers | |
| 3085 | +``` | |
| 3086 | + | |
| 3087 | +Examples: | |
| 3088 | + | |
| 3089 | +```text | |
| 3090 | +panc | |
| 3091 | +→ Pancreatic Cancer | |
| 3092 | +→ Pancreatic Ductal Adenocarcinoma | |
| 3093 | + | |
| 3094 | +G12C | |
| 3095 | +→ KRAS G12C | |
| 3096 | + | |
| 3097 | +HER2 low | |
| 3098 | +→ HER2-low Breast Cancer | |
| 3099 | +→ HER2-low biomarker concept | |
| 3100 | +``` | |
| 3101 | + | |
| 3102 | +Implement: | |
| 3103 | + | |
| 3104 | +```text | |
| 3105 | +exact | |
| 3106 | +alias | |
| 3107 | +prefix | |
| 3108 | +fuzzy | |
| 3109 | +semantic | |
| 3110 | +cross-entity | |
| 3111 | +``` | |
| 3112 | + | |
| 3113 | +--- | |
| 3114 | + | |
| 3115 | +# 69. ENTITY RECONCILIATION ENGINE | |
| 3116 | + | |
| 3117 | +This is one of the hardest parts. | |
| 3118 | + | |
| 3119 | +Example source names: | |
| 3120 | + | |
| 3121 | +```text | |
| 3122 | +NSCLC | |
| 3123 | +Non-small-cell lung cancer | |
| 3124 | +Non Small Cell Lung Carcinoma | |
| 3125 | +non-small cell carcinoma of lung | |
| 3126 | +``` | |
| 3127 | + | |
| 3128 | +must resolve appropriately. | |
| 3129 | + | |
| 3130 | +Use: | |
| 3131 | + | |
| 3132 | +```text | |
| 3133 | +exact IDs | |
| 3134 | +ontology mappings | |
| 3135 | +canonical aliases | |
| 3136 | +normalized strings | |
| 3137 | +context | |
| 3138 | +LLM only as fallback candidate generator | |
| 3139 | +human review | |
| 3140 | +``` | |
| 3141 | + | |
| 3142 | +Never merge two cancer entities solely because embeddings are similar. | |
| 3143 | + | |
| 3144 | +--- | |
| 3145 | + | |
| 3146 | +# 70. ENTITY MERGE QUEUE | |
| 3147 | + | |
| 3148 | +Admin system: | |
| 3149 | + | |
| 3150 | +```text | |
| 3151 | +Possible duplicate | |
| 3152 | + | |
| 3153 | +Cancer A | |
| 3154 | +Cancer B | |
| 3155 | + | |
| 3156 | +Evidence: | |
| 3157 | +name similarity: 0.94 | |
| 3158 | +NCIt match: yes | |
| 3159 | +OncoTree match: yes | |
| 3160 | + | |
| 3161 | +[MERGE] | |
| 3162 | +[KEEP SEPARATE] | |
| 3163 | +[REVIEW] | |
| 3164 | +``` | |
| 3165 | + | |
| 3166 | +Every merge must be auditable and reversible. | |
| 3167 | + | |
| 3168 | +--- | |
| 3169 | + | |
| 3170 | +# 71. TEMPORAL DATA | |
| 3171 | + | |
| 3172 | +Every observation is time-aware. | |
| 3173 | + | |
| 3174 | +Never overwrite: | |
| 3175 | + | |
| 3176 | +```text | |
| 3177 | +incidence 2022 | |
| 3178 | +``` | |
| 3179 | + | |
| 3180 | +with: | |
| 3181 | + | |
| 3182 | +```text | |
| 3183 | +incidence 2024 | |
| 3184 | +``` | |
| 3185 | + | |
| 3186 | +Store both. | |
| 3187 | + | |
| 3188 | +Core observation: | |
| 3189 | + | |
| 3190 | +```ts | |
| 3191 | +interface EpidemiologyObservation { | |
| 3192 | + cancerId: string | |
| 3193 | + | |
| 3194 | + geographyId: string | |
| 3195 | + | |
| 3196 | + year: number | |
| 3197 | + | |
| 3198 | + sex?: string | |
| 3199 | + ageGroup?: string | |
| 3200 | + | |
| 3201 | + metric: | |
| 3202 | + | "incidence_count" | |
| 3203 | + | "incidence_rate" | |
| 3204 | + | "as_incidence_rate" | |
| 3205 | + | "mortality_count" | |
| 3206 | + | "mortality_rate" | |
| 3207 | + | "as_mortality_rate" | |
| 3208 | + | "prevalence" | |
| 3209 | + | |
| 3210 | + value: number | |
| 3211 | + unit: string | |
| 3212 | + | |
| 3213 | + lowerCI?: number | |
| 3214 | + upperCI?: number | |
| 3215 | + | |
| 3216 | + sourceId: string | |
| 3217 | +} | |
| 3218 | +``` | |
| 3219 | + | |
| 3220 | +--- | |
| 3221 | + | |
| 3222 | +# 72. SURVIVAL DATA MODEL | |
| 3223 | + | |
| 3224 | +Survival requires context. | |
| 3225 | + | |
| 3226 | +Never store simply: | |
| 3227 | + | |
| 3228 | +```text | |
| 3229 | +survival = 32% | |
| 3230 | +``` | |
| 3231 | + | |
| 3232 | +Use: | |
| 3233 | + | |
| 3234 | +```ts | |
| 3235 | +interface SurvivalObservation { | |
| 3236 | + cancerId: string | |
| 3237 | + geographyId?: string | |
| 3238 | + | |
| 3239 | + stage?: string | |
| 3240 | + sex?: string | |
| 3241 | + ageGroup?: string | |
| 3242 | + diagnosisPeriod?: string | |
| 3243 | + | |
| 3244 | + survivalType: | |
| 3245 | + | "overall" | |
| 3246 | + | "relative" | |
| 3247 | + | "cause_specific" | |
| 3248 | + | "progression_free" | |
| 3249 | + | "disease_free" | |
| 3250 | + | |
| 3251 | + durationMonths: number | |
| 3252 | + probability?: number | |
| 3253 | + medianMonths?: number | |
| 3254 | + | |
| 3255 | + cohortSize?: number | |
| 3256 | + | |
| 3257 | + lowerCI?: number | |
| 3258 | + upperCI?: number | |
| 3259 | + | |
| 3260 | + sourceId: string | |
| 3261 | +} | |
| 3262 | +``` | |
| 3263 | + | |
| 3264 | +--- | |
| 3265 | + | |
| 3266 | +# 73. STAGING | |
| 3267 | + | |
| 3268 | +CancerIndex must support multiple staging systems. | |
| 3269 | + | |
| 3270 | +Do not pretend all cancers use identical Stage I–IV systems. | |
| 3271 | + | |
| 3272 | +Model: | |
| 3273 | + | |
| 3274 | +```text | |
| 3275 | +AJCC/TNM | |
| 3276 | +FIGO | |
| 3277 | +Ann Arbor | |
| 3278 | +Lugano | |
| 3279 | +Durie-Salmon | |
| 3280 | +ISS/R-ISS | |
| 3281 | +Binet | |
| 3282 | +Rai | |
| 3283 | +disease-specific systems | |
| 3284 | +``` | |
| 3285 | + | |
| 3286 | +Licensing must be checked before reproducing proprietary staging definitions. | |
| 3287 | + | |
| 3288 | +--- | |
| 3289 | + | |
| 3290 | +# 74. RISK FACTORS | |
| 3291 | + | |
| 3292 | +Risk factor entities: | |
| 3293 | + | |
| 3294 | +```text | |
| 3295 | +smoking | |
| 3296 | +alcohol | |
| 3297 | +UV | |
| 3298 | +obesity | |
| 3299 | +infection | |
| 3300 | +occupational exposure | |
| 3301 | +radiation | |
| 3302 | +genetic predisposition | |
| 3303 | +hormonal factors | |
| 3304 | +age | |
| 3305 | +``` | |
| 3306 | + | |
| 3307 | +Relations require evidence. | |
| 3308 | + | |
| 3309 | +Example: | |
| 3310 | + | |
| 3311 | +```text | |
| 3312 | +RiskFactor → ASSOCIATED_WITH → Cancer | |
| 3313 | +``` | |
| 3314 | + | |
| 3315 | +Store: | |
| 3316 | + | |
| 3317 | +```text | |
| 3318 | +relative risk | |
| 3319 | +odds ratio | |
| 3320 | +hazard ratio | |
| 3321 | +population attributable fraction | |
| 3322 | +confidence interval | |
| 3323 | +study | |
| 3324 | +``` | |
| 3325 | + | |
| 3326 | +Do not translate association into causality automatically. | |
| 3327 | + | |
| 3328 | +--- | |
| 3329 | + | |
| 3330 | +# 75. HEREDITARY CANCER | |
| 3331 | + | |
| 3332 | +Dedicated hereditary layer. | |
| 3333 | + | |
| 3334 | +Entities: | |
| 3335 | + | |
| 3336 | +```text | |
| 3337 | +germline gene | |
| 3338 | +syndrome | |
| 3339 | +variant | |
| 3340 | +cancer risk | |
| 3341 | +penetrance estimate | |
| 3342 | +``` | |
| 3343 | + | |
| 3344 | +Examples conceptually: | |
| 3345 | + | |
| 3346 | +```text | |
| 3347 | +BRCA1 | |
| 3348 | +BRCA2 | |
| 3349 | +Lynch syndrome | |
| 3350 | +TP53/Li-Fraumeni | |
| 3351 | +APC/FAP | |
| 3352 | +VHL | |
| 3353 | +``` | |
| 3354 | + | |
| 3355 | +Use trusted genetic sources. | |
| 3356 | + | |
| 3357 | +Strong warning: | |
| 3358 | + | |
| 3359 | +CancerIndex must not interpret a user's personal germline result as medical advice. | |
| 3360 | + | |
| 3361 | +--- | |
| 3362 | + | |
| 3363 | +# 76. SCREENING | |
| 3364 | + | |
| 3365 | +Store: | |
| 3366 | + | |
| 3367 | +```text | |
| 3368 | +screening method | |
| 3369 | +eligible population | |
| 3370 | +cancer | |
| 3371 | +country | |
| 3372 | +organization | |
| 3373 | +recommendation date | |
| 3374 | +evidence level | |
| 3375 | +``` | |
| 3376 | + | |
| 3377 | +Guidelines are geography and organization specific. | |
| 3378 | + | |
| 3379 | +Never display a universal screening recommendation when there isn't one. | |
| 3380 | + | |
| 3381 | +--- | |
| 3382 | + | |
| 3383 | +# 77. PREVENTION | |
| 3384 | + | |
| 3385 | +Represent prevention evidence separately. | |
| 3386 | + | |
| 3387 | +Potential: | |
| 3388 | + | |
| 3389 | +```text | |
| 3390 | +vaccination | |
| 3391 | +smoking cessation | |
| 3392 | +UV protection | |
| 3393 | +risk-reducing surgery | |
| 3394 | +screening | |
| 3395 | +infection prevention | |
| 3396 | +occupational exposure reduction | |
| 3397 | +``` | |
| 3398 | + | |
| 3399 | +--- | |
| 3400 | + | |
| 3401 | +# 78. PATHOLOGY | |
| 3402 | + | |
| 3403 | +Future module. | |
| 3404 | + | |
| 3405 | +Data: | |
| 3406 | + | |
| 3407 | +```text | |
| 3408 | +histology | |
| 3409 | +pathology images | |
| 3410 | +stains | |
| 3411 | +IHC | |
| 3412 | +morphology | |
| 3413 | +grade | |
| 3414 | +``` | |
| 3415 | + | |
| 3416 | +Use public datasets with explicit image usage rights. | |
| 3417 | + | |
| 3418 | +--- | |
| 3419 | + | |
| 3420 | +# 79. RADIOLOGY | |
| 3421 | + | |
| 3422 | +Future module. | |
| 3423 | + | |
| 3424 | +Potential public datasets: | |
| 3425 | + | |
| 3426 | +```text | |
| 3427 | +TCIA and other properly licensed collections | |
| 3428 | +``` | |
| 3429 | + | |
| 3430 | +Separate: | |
| 3431 | + | |
| 3432 | +```text | |
| 3433 | +CT | |
| 3434 | +MRI | |
| 3435 | +PET | |
| 3436 | +X-ray | |
| 3437 | +ultrasound | |
| 3438 | +``` | |
| 3439 | + | |
| 3440 | +Do not expose patient-identifiable DICOM metadata. | |
| 3441 | + | |
| 3442 | +--- | |
| 3443 | + | |
| 3444 | +# 80. CANCER INDEX API | |
| 3445 | + | |
| 3446 | +Public API: | |
| 3447 | + | |
| 3448 | +```text | |
| 3449 | +api.cancerindex.io | |
| 3450 | +``` | |
| 3451 | + | |
| 3452 | +Version: | |
| 3453 | + | |
| 3454 | +```text | |
| 3455 | +/v1/ | |
| 3456 | +``` | |
| 3457 | + | |
| 3458 | +Possible endpoints: | |
| 3459 | + | |
| 3460 | +```text | |
| 3461 | +GET /v1/cancers | |
| 3462 | +GET /v1/cancers/{id} | |
| 3463 | +GET /v1/cancers/{id}/statistics | |
| 3464 | +GET /v1/cancers/{id}/survival | |
| 3465 | +GET /v1/cancers/{id}/genes | |
| 3466 | +GET /v1/cancers/{id}/variants | |
| 3467 | +GET /v1/cancers/{id}/drugs | |
| 3468 | +GET /v1/cancers/{id}/trials | |
| 3469 | +GET /v1/cancers/{id}/publications | |
| 3470 | + | |
| 3471 | +GET /v1/genes | |
| 3472 | +GET /v1/genes/{symbol} | |
| 3473 | + | |
| 3474 | +GET /v1/variants/{id} | |
| 3475 | + | |
| 3476 | +GET /v1/drugs | |
| 3477 | +GET /v1/drugs/{id} | |
| 3478 | + | |
| 3479 | +GET /v1/trials/{nct} | |
| 3480 | + | |
| 3481 | +GET /v1/rankings | |
| 3482 | +``` | |
| 3483 | + | |
| 3484 | +--- | |
| 3485 | + | |
| 3486 | +# 81. GRAPHQL | |
| 3487 | + | |
| 3488 | +Consider later: | |
| 3489 | + | |
| 3490 | +```text | |
| 3491 | +/graphql | |
| 3492 | +``` | |
| 3493 | + | |
| 3494 | +Example conceptual query: | |
| 3495 | + | |
| 3496 | +```graphql | |
| 3497 | +cancer(id: "CI-CAN-...") { | |
| 3498 | + name | |
| 3499 | + ranking { | |
| 3500 | + incidence | |
| 3501 | + mortality | |
| 3502 | + } | |
| 3503 | + genes { | |
| 3504 | + gene { | |
| 3505 | + symbol | |
| 3506 | + } | |
| 3507 | + frequency | |
| 3508 | + } | |
| 3509 | + trials(status: RECRUITING) { | |
| 3510 | + nctId | |
| 3511 | + phase | |
| 3512 | + } | |
| 3513 | +} | |
| 3514 | +``` | |
| 3515 | + | |
| 3516 | +--- | |
| 3517 | + | |
| 3518 | +# 82. MCP SERVER | |
| 3519 | + | |
| 3520 | +CancerIndex should eventually expose an MCP server. | |
| 3521 | + | |
| 3522 | +Purpose: | |
| 3523 | + | |
| 3524 | +Allow AI agents to query CancerIndex directly. | |
| 3525 | + | |
| 3526 | +Tools: | |
| 3527 | + | |
| 3528 | +```text | |
| 3529 | +search_cancers | |
| 3530 | +get_cancer | |
| 3531 | +rank_cancers | |
| 3532 | +get_epidemiology | |
| 3533 | +get_survival | |
| 3534 | +get_gene | |
| 3535 | +get_variant | |
| 3536 | +get_drug | |
| 3537 | +search_trials | |
| 3538 | +search_publications | |
| 3539 | +query_knowledge_graph | |
| 3540 | +``` | |
| 3541 | + | |
| 3542 | +Read-only initially. | |
| 3543 | + | |
| 3544 | +--- | |
| 3545 | + | |
| 3546 | +# 83. BULK DATA | |
| 3547 | + | |
| 3548 | +Eventually provide permitted CancerIndex-derived datasets. | |
| 3549 | + | |
| 3550 | +Formats: | |
| 3551 | + | |
| 3552 | +```text | |
| 3553 | +CSV | |
| 3554 | +JSON | |
| 3555 | +JSONL | |
| 3556 | +Parquet | |
| 3557 | +``` | |
| 3558 | + | |
| 3559 | +Never redistribute restricted upstream source material. | |
| 3560 | + | |
| 3561 | +--- | |
| 3562 | + | |
| 3563 | +# 84. ARCHITECTURE | |
| 3564 | + | |
| 3565 | +Recommended: | |
| 3566 | + | |
| 3567 | +```text | |
| 3568 | +Next.js | |
| 3569 | +TypeScript | |
| 3570 | +React | |
| 3571 | + | |
| 3572 | +PostgreSQL | |
| 3573 | +pgvector | |
| 3574 | + | |
| 3575 | +ClickHouse | |
| 3576 | +Redis | |
| 3577 | + | |
| 3578 | +OpenSearch or Elasticsearch | |
| 3579 | + | |
| 3580 | +MinIO/S3 | |
| 3581 | + | |
| 3582 | +Python ingestion workers | |
| 3583 | +FastAPI scientific services | |
| 3584 | + | |
| 3585 | +Temporal or durable job orchestration | |
| 3586 | +``` | |
| 3587 | + | |
| 3588 | +Graph: | |
| 3589 | + | |
| 3590 | +Start with relational edge tables. | |
| 3591 | + | |
| 3592 | +Introduce Neo4j/Memgraph only if graph workloads justify operational complexity. | |
| 3593 | + | |
| 3594 | +Do not add infrastructure merely because it sounds sophisticated. | |
| 3595 | + | |
| 3596 | +--- | |
| 3597 | + | |
| 3598 | +# 85. POSTGRESQL | |
| 3599 | + | |
| 3600 | +Primary store for: | |
| 3601 | + | |
| 3602 | +```text | |
| 3603 | +canonical entities | |
| 3604 | +relationships | |
| 3605 | +users | |
| 3606 | +API metadata | |
| 3607 | +source registry | |
| 3608 | +provenance | |
| 3609 | +admin | |
| 3610 | +ranking snapshots | |
| 3611 | +``` | |
| 3612 | + | |
| 3613 | +--- | |
| 3614 | + | |
| 3615 | +# 86. CLICKHOUSE | |
| 3616 | + | |
| 3617 | +Use for large analytical observations: | |
| 3618 | + | |
| 3619 | +```text | |
| 3620 | +epidemiology | |
| 3621 | +variant frequencies | |
| 3622 | +publication timelines | |
| 3623 | +trial timelines | |
| 3624 | +ranking datasets | |
| 3625 | +event logs | |
| 3626 | +``` | |
| 3627 | + | |
| 3628 | +--- | |
| 3629 | + | |
| 3630 | +# 87. OPENSEARCH | |
| 3631 | + | |
| 3632 | +Use for global full-text search. | |
| 3633 | + | |
| 3634 | +Indexes: | |
| 3635 | + | |
| 3636 | +```text | |
| 3637 | +cancers | |
| 3638 | +genes | |
| 3639 | +variants | |
| 3640 | +drugs | |
| 3641 | +trials | |
| 3642 | +publications | |
| 3643 | +``` | |
| 3644 | + | |
| 3645 | +--- | |
| 3646 | + | |
| 3647 | +# 88. OBJECT STORAGE | |
| 3648 | + | |
| 3649 | +Use for: | |
| 3650 | + | |
| 3651 | +```text | |
| 3652 | +raw connector snapshots | |
| 3653 | +bulk source archives | |
| 3654 | +large datasets | |
| 3655 | +export files | |
| 3656 | +images where licensed | |
| 3657 | +``` | |
| 3658 | + | |
| 3659 | +--- | |
| 3660 | + | |
| 3661 | +# 89. REDIS | |
| 3662 | + | |
| 3663 | +Use for: | |
| 3664 | + | |
| 3665 | +```text | |
| 3666 | +hot cache | |
| 3667 | +rate limiting | |
| 3668 | +jobs | |
| 3669 | +distributed locks | |
| 3670 | +temporary AI streams | |
| 3671 | +``` | |
| 3672 | + | |
| 3673 | +Do not use Redis as canonical storage. | |
| 3674 | + | |
| 3675 | +--- | |
| 3676 | + | |
| 3677 | +# 90. INGESTION JOB SYSTEM | |
| 3678 | + | |
| 3679 | +Every ingest must be restartable. | |
| 3680 | + | |
| 3681 | +Use: | |
| 3682 | + | |
| 3683 | +```text | |
| 3684 | +connector | |
| 3685 | +↓ | |
| 3686 | +discovery | |
| 3687 | +↓ | |
| 3688 | +fetch | |
| 3689 | +↓ | |
| 3690 | +raw persist | |
| 3691 | +↓ | |
| 3692 | +parse | |
| 3693 | +↓ | |
| 3694 | +validate | |
| 3695 | +↓ | |
| 3696 | +normalize | |
| 3697 | +↓ | |
| 3698 | +reconcile | |
| 3699 | +↓ | |
| 3700 | +canonical persist | |
| 3701 | +↓ | |
| 3702 | +index | |
| 3703 | +↓ | |
| 3704 | +derived metrics | |
| 3705 | +↓ | |
| 3706 | +ranking recompute | |
| 3707 | +``` | |
| 3708 | + | |
| 3709 | +--- | |
| 3710 | + | |
| 3711 | +# 91. IDEMPOTENCY | |
| 3712 | + | |
| 3713 | +Running a connector twice must not duplicate data. | |
| 3714 | + | |
| 3715 | +Use source-native IDs. | |
| 3716 | + | |
| 3717 | +Example: | |
| 3718 | + | |
| 3719 | +```text | |
| 3720 | +source = PubMed | |
| 3721 | +source_record_id = 12345678 | |
| 3722 | +``` | |
| 3723 | + | |
| 3724 | +Unique constraint. | |
| 3725 | + | |
| 3726 | +--- | |
| 3727 | + | |
| 3728 | +# 92. SOFT DELETION | |
| 3729 | + | |
| 3730 | +Sources can retract or remove records. | |
| 3731 | + | |
| 3732 | +Never immediately hard-delete. | |
| 3733 | + | |
| 3734 | +Use: | |
| 3735 | + | |
| 3736 | +```text | |
| 3737 | +active | |
| 3738 | +deprecated | |
| 3739 | +retracted | |
| 3740 | +withdrawn | |
| 3741 | +source_missing | |
| 3742 | +``` | |
| 3743 | + | |
| 3744 | +Retain history. | |
| 3745 | + | |
| 3746 | +--- | |
| 3747 | + | |
| 3748 | +# 93. PUBLICATION RETRACTIONS | |
| 3749 | + | |
| 3750 | +CancerIndex must track retracted publications when data permit. | |
| 3751 | + | |
| 3752 | +Relationships based only on retracted evidence should be flagged. | |
| 3753 | + | |
| 3754 | +--- | |
| 3755 | + | |
| 3756 | +# 94. DATA FRESHNESS | |
| 3757 | + | |
| 3758 | +Every page needs: | |
| 3759 | + | |
| 3760 | +```text | |
| 3761 | +Data updated | |
| 3762 | +Source updated | |
| 3763 | +CancerIndex synchronized | |
| 3764 | +``` | |
| 3765 | + | |
| 3766 | +Example: | |
| 3767 | + | |
| 3768 | +```text | |
| 3769 | +Clinical trials updated: today | |
| 3770 | +Genomics updated: Aug 2026 | |
| 3771 | +Global incidence dataset: 2024 estimate | |
| 3772 | +``` | |
| 3773 | + | |
| 3774 | +--- | |
| 3775 | + | |
| 3776 | +# 95. SOURCE PAGE | |
| 3777 | + | |
| 3778 | +Route: | |
| 3779 | + | |
| 3780 | +```text | |
| 3781 | +/source/{source} | |
| 3782 | +``` | |
| 3783 | + | |
| 3784 | +Display: | |
| 3785 | + | |
| 3786 | +```text | |
| 3787 | +provider | |
| 3788 | +description | |
| 3789 | +dataset | |
| 3790 | +access method | |
| 3791 | +last sync | |
| 3792 | +records | |
| 3793 | +coverage | |
| 3794 | +license status | |
| 3795 | +data version | |
| 3796 | +connector health | |
| 3797 | +``` | |
| 3798 | + | |
| 3799 | +Transparency is a feature. | |
| 3800 | + | |
| 3801 | +--- | |
| 3802 | + | |
| 3803 | +# 96. CHANGE HISTORY | |
| 3804 | + | |
| 3805 | +Every entity should support change history. | |
| 3806 | + | |
| 3807 | +Example: | |
| 3808 | + | |
| 3809 | +```text | |
| 3810 | +Aug 19: | |
| 3811 | +FDA approval added | |
| 3812 | + | |
| 3813 | +Aug 14: | |
| 3814 | +3 new trials | |
| 3815 | + | |
| 3816 | +Aug 10: | |
| 3817 | +GDC mutation frequency refreshed | |
| 3818 | + | |
| 3819 | +Aug 03: | |
| 3820 | +CancerIndex score changed 84.1 → 84.7 | |
| 3821 | +``` | |
| 3822 | + | |
| 3823 | +--- | |
| 3824 | + | |
| 3825 | +# 97. CANCER WATCH | |
| 3826 | + | |
| 3827 | +Users can follow: | |
| 3828 | + | |
| 3829 | +```text | |
| 3830 | +cancer | |
| 3831 | +gene | |
| 3832 | +variant | |
| 3833 | +drug | |
| 3834 | +trial | |
| 3835 | +``` | |
| 3836 | + | |
| 3837 | +Notifications: | |
| 3838 | + | |
| 3839 | +```text | |
| 3840 | +new clinical trial | |
| 3841 | +trial status change | |
| 3842 | +FDA approval | |
| 3843 | +publication | |
| 3844 | +new genomic finding | |
| 3845 | +ranking change | |
| 3846 | +``` | |
| 3847 | + | |
| 3848 | +--- | |
| 3849 | + | |
| 3850 | +# 98. USER ACCOUNTS | |
| 3851 | + | |
| 3852 | +Account system: | |
| 3853 | + | |
| 3854 | +```text | |
| 3855 | ||
| 3856 | +password | |
| 3857 | +email verification | |
| 3858 | +password reset | |
| 3859 | +session management | |
| 3860 | +``` | |
| 3861 | + | |
| 3862 | +Optional: | |
| 3863 | + | |
| 3864 | +```text | |
| 3865 | ||
| 3866 | +Apple | |
| 3867 | +ORCID | |
| 3868 | +``` | |
| 3869 | + | |
| 3870 | +Do not store sensitive health profiles by default. | |
| 3871 | + | |
| 3872 | +--- | |
| 3873 | + | |
| 3874 | +# 99. RESEARCH WORKSPACE | |
| 3875 | + | |
| 3876 | +Users can save: | |
| 3877 | + | |
| 3878 | +```text | |
| 3879 | +cancers | |
| 3880 | +genes | |
| 3881 | +variants | |
| 3882 | +drugs | |
| 3883 | +trials | |
| 3884 | +papers | |
| 3885 | +queries | |
| 3886 | +charts | |
| 3887 | +``` | |
| 3888 | + | |
| 3889 | +Create collections: | |
| 3890 | + | |
| 3891 | +```text | |
| 3892 | +"My KRAS research" | |
| 3893 | +"Rare sarcomas" | |
| 3894 | +"Pancreatic cancer trials" | |
| 3895 | +``` | |
| 3896 | + | |
| 3897 | +--- | |
| 3898 | + | |
| 3899 | +# 100. COMPARISON ENGINE | |
| 3900 | + | |
| 3901 | +Route: | |
| 3902 | + | |
| 3903 | +```text | |
| 3904 | +/compare | |
| 3905 | +``` | |
| 3906 | + | |
| 3907 | +Compare up to several cancers. | |
| 3908 | + | |
| 3909 | +Example: | |
| 3910 | + | |
| 3911 | +```text | |
| 3912 | +Pancreatic cancer | |
| 3913 | +Glioblastoma | |
| 3914 | +Lung adenocarcinoma | |
| 3915 | +Melanoma | |
| 3916 | +``` | |
| 3917 | + | |
| 3918 | +Compare: | |
| 3919 | + | |
| 3920 | +```text | |
| 3921 | +incidence | |
| 3922 | +mortality | |
| 3923 | +survival | |
| 3924 | +trends | |
| 3925 | +genes | |
| 3926 | +biomarkers | |
| 3927 | +treatments | |
| 3928 | +trials | |
| 3929 | +research | |
| 3930 | +``` | |
| 3931 | + | |
| 3932 | +--- | |
| 3933 | + | |
| 3934 | +# 101. VISUALIZATION SYSTEM | |
| 3935 | + | |
| 3936 | +CancerIndex should be visually exceptional. | |
| 3937 | + | |
| 3938 | +Visualizations: | |
| 3939 | + | |
| 3940 | +```text | |
| 3941 | +ranked bar charts | |
| 3942 | +time-series | |
| 3943 | +survival curves | |
| 3944 | +heatmaps | |
| 3945 | +world maps | |
| 3946 | +genomic frequency plots | |
| 3947 | +co-occurrence matrices | |
| 3948 | +oncoprints | |
| 3949 | +trial timelines | |
| 3950 | +drug approval timelines | |
| 3951 | +knowledge graphs | |
| 3952 | +Sankey charts | |
| 3953 | +bubble plots | |
| 3954 | +``` | |
| 3955 | + | |
| 3956 | +Charts need: | |
| 3957 | + | |
| 3958 | +```text | |
| 3959 | +source | |
| 3960 | +unit | |
| 3961 | +population | |
| 3962 | +time period | |
| 3963 | +download | |
| 3964 | +``` | |
| 3965 | + | |
| 3966 | +--- | |
| 3967 | + | |
| 3968 | +# 102. KNOWLEDGE GRAPH UI | |
| 3969 | + | |
| 3970 | +Users can start from: | |
| 3971 | + | |
| 3972 | +```text | |
| 3973 | +KRAS | |
| 3974 | +``` | |
| 3975 | + | |
| 3976 | +and visually explore: | |
| 3977 | + | |
| 3978 | +```text | |
| 3979 | +KRAS | |
| 3980 | +├── G12C | |
| 3981 | +│ ├── NSCLC | |
| 3982 | +│ ├── colorectal cancer | |
| 3983 | +│ ├── therapies | |
| 3984 | +│ └── trials | |
| 3985 | +├── G12D | |
| 3986 | +├── G12V | |
| 3987 | +└── pathways | |
| 3988 | +``` | |
| 3989 | + | |
| 3990 | +Click nodes dynamically. | |
| 3991 | + | |
| 3992 | +Avoid rendering thousands of nodes at once. | |
| 3993 | + | |
| 3994 | +--- | |
| 3995 | + | |
| 3996 | +# 103. DESIGN SYSTEM | |
| 3997 | + | |
| 3998 | +CancerIndex must NOT look like a generic SaaS dashboard. | |
| 3999 | + | |
| 4000 | +Target aesthetic: | |
| 4001 | + | |
| 4002 | +```text | |
| 4003 | +scientific | |
| 4004 | +editorial | |
| 4005 | +premium | |
| 4006 | +institutional | |
| 4007 | +modern | |
| 4008 | +high-information-density | |
| 4009 | +trustworthy | |
| 4010 | +``` | |
| 4011 | + | |
| 4012 | +Think: | |
| 4013 | + | |
| 4014 | +```text | |
| 4015 | +Nature | |
| 4016 | +Bloomberg | |
| 4017 | +Our World in Data | |
| 4018 | +high-end scientific visualization | |
| 4019 | +``` | |
| 4020 | + | |
| 4021 | +Avoid: | |
| 4022 | + | |
| 4023 | +```text | |
| 4024 | +giant gradients everywhere | |
| 4025 | +dozens of rounded cards | |
| 4026 | +cartoon health icons | |
| 4027 | +generic AI sparkle graphics | |
| 4028 | +``` | |
| 4029 | + | |
| 4030 | +--- | |
| 4031 | + | |
| 4032 | +# 104. COLOR | |
| 4033 | + | |
| 4034 | +Base: | |
| 4035 | + | |
| 4036 | +```text | |
| 4037 | +off-white / white | |
| 4038 | +deep charcoal | |
| 4039 | +muted scientific neutrals | |
| 4040 | +``` | |
| 4041 | + | |
| 4042 | +Cancer-specific color coding can exist but must not compromise accessibility. | |
| 4043 | + | |
| 4044 | +Never rely on color alone. | |
| 4045 | + | |
| 4046 | +--- | |
| 4047 | + | |
| 4048 | +# 105. HOME PAGE | |
| 4049 | + | |
| 4050 | +Hero: | |
| 4051 | + | |
| 4052 | +```text | |
| 4053 | +CancerIndex | |
| 4054 | + | |
| 4055 | +The global index of cancer. | |
| 4056 | + | |
| 4057 | +Explore every cancer. | |
| 4058 | +Rank global burden. | |
| 4059 | +Follow treatments. | |
| 4060 | +Search genomics. | |
| 4061 | +Track clinical research. | |
| 4062 | +``` | |
| 4063 | + | |
| 4064 | +Global search immediately visible. | |
| 4065 | + | |
| 4066 | +Below: | |
| 4067 | + | |
| 4068 | +```text | |
| 4069 | +Cancer burden today | |
| 4070 | +Global rankings | |
| 4071 | +Fastest-rising cancers | |
| 4072 | +Highest mortality | |
| 4073 | +Poorest survival | |
| 4074 | +Most active research | |
| 4075 | +Largest treatment gaps | |
| 4076 | +Rare cancers | |
| 4077 | +Latest oncology approvals | |
| 4078 | +New clinical trials | |
| 4079 | +``` | |
| 4080 | + | |
| 4081 | +--- | |
| 4082 | + | |
| 4083 | +# 106. LIVE DATA TICKER | |
| 4084 | + | |
| 4085 | +Tasteful top-line statistics: | |
| 4086 | + | |
| 4087 | +```text | |
| 4088 | +Cancer entities indexed | |
| 4089 | +Genes indexed | |
| 4090 | +Variants indexed | |
| 4091 | +Clinical trials | |
| 4092 | +Publications | |
| 4093 | +Drug indications | |
| 4094 | +Countries | |
| 4095 | +Sources | |
| 4096 | +``` | |
| 4097 | + | |
| 4098 | +Values must come from database counts. | |
| 4099 | + | |
| 4100 | +--- | |
| 4101 | + | |
| 4102 | +# 107. "ALL CANCERS" EXPLORER | |
| 4103 | + | |
| 4104 | +Route: | |
| 4105 | + | |
| 4106 | +```text | |
| 4107 | +/cancers | |
| 4108 | +``` | |
| 4109 | + | |
| 4110 | +Do not show only a few dozen cards. | |
| 4111 | + | |
| 4112 | +Build a powerful explorer. | |
| 4113 | + | |
| 4114 | +Filters: | |
| 4115 | + | |
| 4116 | +```text | |
| 4117 | +anatomical system | |
| 4118 | +histology | |
| 4119 | +solid/hematologic | |
| 4120 | +adult/pediatric | |
| 4121 | +rare/common | |
| 4122 | +molecular subtype | |
| 4123 | +incidence | |
| 4124 | +mortality | |
| 4125 | +survival | |
| 4126 | +research level | |
| 4127 | +trial count | |
| 4128 | +treatment availability | |
| 4129 | +``` | |
| 4130 | + | |
| 4131 | +Support thousands of entities. | |
| 4132 | + | |
| 4133 | +--- | |
| 4134 | + | |
| 4135 | +# 108. TAXONOMY EXPLORER | |
| 4136 | + | |
| 4137 | +Tree/browser: | |
| 4138 | + | |
| 4139 | +```text | |
| 4140 | +Blood | |
| 4141 | +Breast | |
| 4142 | +CNS | |
| 4143 | +Digestive | |
| 4144 | +Endocrine | |
| 4145 | +Gynecologic | |
| 4146 | +Head & Neck | |
| 4147 | +Lung | |
| 4148 | +Skin | |
| 4149 | +Soft tissue | |
| 4150 | +Urinary | |
| 4151 | +... | |
| 4152 | +``` | |
| 4153 | + | |
| 4154 | +Also: | |
| 4155 | + | |
| 4156 | +```text | |
| 4157 | +histology view | |
| 4158 | +molecular view | |
| 4159 | +WHO view | |
| 4160 | +NCI view | |
| 4161 | +``` | |
| 4162 | + | |
| 4163 | +--- | |
| 4164 | + | |
| 4165 | +# 109. RARE CANCER DISCOVERY | |
| 4166 | + | |
| 4167 | +Feature: | |
| 4168 | + | |
| 4169 | +**Random Rare Cancer** | |
| 4170 | + | |
| 4171 | +Useful for discovery. | |
| 4172 | + | |
| 4173 | +Shows: | |
| 4174 | + | |
| 4175 | +```text | |
| 4176 | +what it is | |
| 4177 | +annual incidence | |
| 4178 | +known cases/data | |
| 4179 | +research count | |
| 4180 | +trials | |
| 4181 | +genes | |
| 4182 | +treatments | |
| 4183 | +``` | |
| 4184 | + | |
| 4185 | +--- | |
| 4186 | + | |
| 4187 | +# 110. DATA QUALITY ENGINE | |
| 4188 | + | |
| 4189 | +Every normalized record runs validation. | |
| 4190 | + | |
| 4191 | +Examples: | |
| 4192 | + | |
| 4193 | +```text | |
| 4194 | +incidence >= 0 | |
| 4195 | +deaths >= 0 | |
| 4196 | +survival between 0 and 1 | |
| 4197 | +year reasonable | |
| 4198 | +country valid | |
| 4199 | +gene symbol canonical | |
| 4200 | +variant syntax valid where possible | |
| 4201 | +trial phase enum recognized | |
| 4202 | +``` | |
| 4203 | + | |
| 4204 | +--- | |
| 4205 | + | |
| 4206 | +# 111. CROSS-SOURCE CONFLICTS | |
| 4207 | + | |
| 4208 | +Sources will disagree. | |
| 4209 | + | |
| 4210 | +Never silently average everything. | |
| 4211 | + | |
| 4212 | +Store each observation. | |
| 4213 | + | |
| 4214 | +Example: | |
| 4215 | + | |
| 4216 | +```text | |
| 4217 | +Source A: | |
| 4218 | +5-year survival = 31% | |
| 4219 | + | |
| 4220 | +Source B: | |
| 4221 | +5-year survival = 36% | |
| 4222 | +``` | |
| 4223 | + | |
| 4224 | +CancerIndex may compute a harmonized estimate only with a documented method. | |
| 4225 | + | |
| 4226 | +Show: | |
| 4227 | + | |
| 4228 | +```text | |
| 4229 | +Why estimates differ | |
| 4230 | +``` | |
| 4231 | + | |
| 4232 | +--- | |
| 4233 | + | |
| 4234 | +# 112. EVIDENCE ENGINE | |
| 4235 | + | |
| 4236 | +Create CancerIndex evidence hierarchy. | |
| 4237 | + | |
| 4238 | +Possible dimensions: | |
| 4239 | + | |
| 4240 | +```text | |
| 4241 | +study design | |
| 4242 | +sample size | |
| 4243 | +replication | |
| 4244 | +publication quality | |
| 4245 | +clinical relevance | |
| 4246 | +regulatory validation | |
| 4247 | +expert curation | |
| 4248 | +recency | |
| 4249 | +``` | |
| 4250 | + | |
| 4251 | +Do NOT reduce all scientific truth to one score. | |
| 4252 | + | |
| 4253 | +Use multi-dimensional evidence badges. | |
| 4254 | + | |
| 4255 | +--- | |
| 4256 | + | |
| 4257 | +# 113. CLINICAL EVIDENCE LABELS | |
| 4258 | + | |
| 4259 | +Example: | |
| 4260 | + | |
| 4261 | +```text | |
| 4262 | +REGULATORY APPROVED | |
| 4263 | +GUIDELINE SUPPORTED | |
| 4264 | +PHASE III | |
| 4265 | +PHASE II | |
| 4266 | +PHASE I | |
| 4267 | +RETROSPECTIVE CLINICAL | |
| 4268 | +CASE SERIES | |
| 4269 | +CASE REPORT | |
| 4270 | +PRECLINICAL | |
| 4271 | +COMPUTATIONAL | |
| 4272 | +``` | |
| 4273 | + | |
| 4274 | +--- | |
| 4275 | + | |
| 4276 | +# 114. STATISTICAL INTEGRITY | |
| 4277 | + | |
| 4278 | +Never calculate survival by dividing unrelated values. | |
| 4279 | + | |
| 4280 | +Never compare crude incidence with age-standardized incidence without labeling. | |
| 4281 | + | |
| 4282 | +Never mix: | |
| 4283 | + | |
| 4284 | +```text | |
| 4285 | +incidence | |
| 4286 | +prevalence | |
| 4287 | +mortality | |
| 4288 | +case fatality | |
| 4289 | +overall survival | |
| 4290 | +relative survival | |
| 4291 | +``` | |
| 4292 | + | |
| 4293 | +Every metric needs precise definition. | |
| 4294 | + | |
| 4295 | +--- | |
| 4296 | + | |
| 4297 | +# 115. CANCER "DEADLINESS" | |
| 4298 | + | |
| 4299 | +Avoid an undefined “deadliest cancer” metric. | |
| 4300 | + | |
| 4301 | +The interface should let users choose: | |
| 4302 | + | |
| 4303 | +```text | |
| 4304 | +Most deaths | |
| 4305 | +Highest mortality rate | |
| 4306 | +Highest mortality/incidence ratio | |
| 4307 | +Lowest 5-year survival | |
| 4308 | +Highest CancerIndex Impact | |
| 4309 | +``` | |
| 4310 | + | |
| 4311 | +This distinction is important. | |
| 4312 | + | |
| 4313 | +--- | |
| 4314 | + | |
| 4315 | +# 116. AGE STANDARDIZATION | |
| 4316 | + | |
| 4317 | +For international comparison prioritize appropriately standardized rates. | |
| 4318 | + | |
| 4319 | +Store standard population used if source provides it. | |
| 4320 | + | |
| 4321 | +Do not present crude rates as directly comparable across countries with radically different age structures. | |
| 4322 | + | |
| 4323 | +--- | |
| 4324 | + | |
| 4325 | +# 117. GEOGRAPHIC NORMALIZATION | |
| 4326 | + | |
| 4327 | +Canonical geography entity: | |
| 4328 | + | |
| 4329 | +```text | |
| 4330 | +ISO country | |
| 4331 | +ISO subdivision | |
| 4332 | +region | |
| 4333 | +continent | |
| 4334 | +WHO region | |
| 4335 | +IARC region if appropriate | |
| 4336 | +``` | |
| 4337 | + | |
| 4338 | +Keep source geography separately. | |
| 4339 | + | |
| 4340 | +--- | |
| 4341 | + | |
| 4342 | +# 118. CURRENCY | |
| 4343 | + | |
| 4344 | +Not central initially. | |
| 4345 | + | |
| 4346 | +If later adding: | |
| 4347 | + | |
| 4348 | +```text | |
| 4349 | +drug cost | |
| 4350 | +economic burden | |
| 4351 | +research funding | |
| 4352 | +``` | |
| 4353 | + | |
| 4354 | +always store: | |
| 4355 | + | |
| 4356 | +```text | |
| 4357 | +currency | |
| 4358 | +year | |
| 4359 | +country | |
| 4360 | +nominal/real | |
| 4361 | +source | |
| 4362 | +``` | |
| 4363 | + | |
| 4364 | +--- | |
| 4365 | + | |
| 4366 | +# 119. RESEARCH FUNDING | |
| 4367 | + | |
| 4368 | +Future innovation: | |
| 4369 | + | |
| 4370 | +Connect: | |
| 4371 | + | |
| 4372 | +```text | |
| 4373 | +NIH RePORTER | |
| 4374 | +CIHR | |
| 4375 | +EU grants | |
| 4376 | +UKRI | |
| 4377 | +other public grants | |
| 4378 | +``` | |
| 4379 | + | |
| 4380 | +Then create: | |
| 4381 | + | |
| 4382 | +```text | |
| 4383 | +funding by cancer | |
| 4384 | +funding per annual death | |
| 4385 | +funding per incident case | |
| 4386 | +``` | |
| 4387 | + | |
| 4388 | +Potential: | |
| 4389 | + | |
| 4390 | +**Funding Gap Index** | |
| 4391 | + | |
| 4392 | +But methodology must be transparent. | |
| 4393 | + | |
| 4394 | +--- | |
| 4395 | + | |
| 4396 | +# 120. NIH REPORTER CONNECTOR | |
| 4397 | + | |
| 4398 | +Potential high-priority future connector. | |
| 4399 | + | |
| 4400 | +Map grant: | |
| 4401 | + | |
| 4402 | +```text | |
| 4403 | +project | |
| 4404 | +principal investigator | |
| 4405 | +institution | |
| 4406 | +funding amount | |
| 4407 | +year | |
| 4408 | +cancer | |
| 4409 | +gene | |
| 4410 | +topic | |
| 4411 | +publication | |
| 4412 | +``` | |
| 4413 | + | |
| 4414 | +--- | |
| 4415 | + | |
| 4416 | +# 121. PATENTS | |
| 4417 | + | |
| 4418 | +Potential future module. | |
| 4419 | + | |
| 4420 | +Sources: | |
| 4421 | + | |
| 4422 | +```text | |
| 4423 | +USPTO | |
| 4424 | +EPO | |
| 4425 | +Google Patents metadata where appropriate | |
| 4426 | +``` | |
| 4427 | + | |
| 4428 | +Use to map therapeutic innovation. | |
| 4429 | + | |
| 4430 | +Not required for MVP. | |
| 4431 | + | |
| 4432 | +--- | |
| 4433 | + | |
| 4434 | +# 122. COMPANY PIPELINE | |
| 4435 | + | |
| 4436 | +Potential future module: | |
| 4437 | + | |
| 4438 | +```text | |
| 4439 | +biotech | |
| 4440 | +pharma | |
| 4441 | +drug candidate | |
| 4442 | +target | |
| 4443 | +phase | |
| 4444 | +indication | |
| 4445 | +``` | |
| 4446 | + | |
| 4447 | +Sources must be verified. | |
| 4448 | + | |
| 4449 | +Public company claims should not override trial registries/regulatory sources. | |
| 4450 | + | |
| 4451 | +--- | |
| 4452 | + | |
| 4453 | +# 123. DRUG DEVELOPMENT PIPELINE | |
| 4454 | + | |
| 4455 | +Statuses: | |
| 4456 | + | |
| 4457 | +```text | |
| 4458 | +preclinical | |
| 4459 | +Phase I | |
| 4460 | +Phase I/II | |
| 4461 | +Phase II | |
| 4462 | +Phase II/III | |
| 4463 | +Phase III | |
| 4464 | +submitted | |
| 4465 | +approved | |
| 4466 | +discontinued | |
| 4467 | +withdrawn | |
| 4468 | +``` | |
| 4469 | + | |
| 4470 | +Status may be disease-specific. | |
| 4471 | + | |
| 4472 | +--- | |
| 4473 | + | |
| 4474 | +# 124. FAILURE DATABASE | |
| 4475 | + | |
| 4476 | +Extremely valuable. | |
| 4477 | + | |
| 4478 | +Track oncology programs that fail or stop. | |
| 4479 | + | |
| 4480 | +Sources: | |
| 4481 | + | |
| 4482 | +```text | |
| 4483 | +ClinicalTrials.gov status | |
| 4484 | +regulatory documents | |
| 4485 | +company releases | |
| 4486 | +publications | |
| 4487 | +``` | |
| 4488 | + | |
| 4489 | +Create: | |
| 4490 | + | |
| 4491 | +```text | |
| 4492 | +Drug → Cancer → Development outcome | |
| 4493 | +``` | |
| 4494 | + | |
| 4495 | +Avoid inferring failure solely from stale trial status. | |
| 4496 | + | |
| 4497 | +--- | |
| 4498 | + | |
| 4499 | +# 125. RESISTANCE DATABASE | |
| 4500 | + | |
| 4501 | +Track mechanisms: | |
| 4502 | + | |
| 4503 | +```text | |
| 4504 | +primary resistance | |
| 4505 | +acquired resistance | |
| 4506 | +``` | |
| 4507 | + | |
| 4508 | +Relations: | |
| 4509 | + | |
| 4510 | +```text | |
| 4511 | +Variant → confers resistance → Drug | |
| 4512 | +Pathway → resistance mechanism → Drug | |
| 4513 | +``` | |
| 4514 | + | |
| 4515 | +Evidence-backed only. | |
| 4516 | + | |
| 4517 | +--- | |
| 4518 | + | |
| 4519 | +# 126. METASTASIS DATABASE | |
| 4520 | + | |
| 4521 | +Map: | |
| 4522 | + | |
| 4523 | +```text | |
| 4524 | +primary cancer | |
| 4525 | +→ common metastatic locations | |
| 4526 | +``` | |
| 4527 | + | |
| 4528 | +Store frequency only with cohort context. | |
| 4529 | + | |
| 4530 | +Do not generalize from small cohorts. | |
| 4531 | + | |
| 4532 | +--- | |
| 4533 | + | |
| 4534 | +# 127. MULTI-OMICS | |
| 4535 | + | |
| 4536 | +Future coverage: | |
| 4537 | + | |
| 4538 | +```text | |
| 4539 | +genome | |
| 4540 | +transcriptome | |
| 4541 | +epigenome | |
| 4542 | +proteome | |
| 4543 | +metabolome | |
| 4544 | +single-cell | |
| 4545 | +spatial | |
| 4546 | +``` | |
| 4547 | + | |
| 4548 | +GDC and other public research repositories can seed this layer. | |
| 4549 | + | |
| 4550 | +--- | |
| 4551 | + | |
| 4552 | +# 128. SINGLE-CELL CANCER DATA | |
| 4553 | + | |
| 4554 | +Future connector candidates: | |
| 4555 | + | |
| 4556 | +```text | |
| 4557 | +CELLxGENE | |
| 4558 | +Human Tumor Atlas Network resources | |
| 4559 | +public scRNA-seq studies | |
| 4560 | +``` | |
| 4561 | + | |
| 4562 | +Must support: | |
| 4563 | + | |
| 4564 | +```text | |
| 4565 | +study | |
| 4566 | +sample | |
| 4567 | +cell type | |
| 4568 | +cancer | |
| 4569 | +gene expression | |
| 4570 | +``` | |
| 4571 | + | |
| 4572 | +Large matrices should not live in PostgreSQL. | |
| 4573 | + | |
| 4574 | +--- | |
| 4575 | + | |
| 4576 | +# 129. HUMAN TUMOR ATLAS | |
| 4577 | + | |
| 4578 | +Potential high-value research connector where datasets and terms permit. | |
| 4579 | + | |
| 4580 | +--- | |
| 4581 | + | |
| 4582 | +# 130. PROTEOMICS | |
| 4583 | + | |
| 4584 | +CPTAC-related data should connect: | |
| 4585 | + | |
| 4586 | +```text | |
| 4587 | +cancer | |
| 4588 | +protein | |
| 4589 | +phosphoprotein | |
| 4590 | +genomic alteration | |
| 4591 | +clinical outcome | |
| 4592 | +``` | |
| 4593 | + | |
| 4594 | +--- | |
| 4595 | + | |
| 4596 | +# 131. MICROBIOME / CANCER | |
| 4597 | + | |
| 4598 | +Future experimental research category. | |
| 4599 | + | |
| 4600 | +Clearly label exploratory evidence. | |
| 4601 | + | |
| 4602 | +--- | |
| 4603 | + | |
| 4604 | +# 132. ENVIRONMENTAL EXPOSURES | |
| 4605 | + | |
| 4606 | +Possible integration: | |
| 4607 | + | |
| 4608 | +```text | |
| 4609 | +IARC carcinogen classifications | |
| 4610 | +occupational exposure datasets | |
| 4611 | +air pollution data | |
| 4612 | +``` | |
| 4613 | + | |
| 4614 | +Do not infer personal cancer risk. | |
| 4615 | + | |
| 4616 | +--- | |
| 4617 | + | |
| 4618 | +# 133. CARCINOGEN ENTITY | |
| 4619 | + | |
| 4620 | +Create: | |
| 4621 | + | |
| 4622 | +```text | |
| 4623 | +Carcinogen | |
| 4624 | +``` | |
| 4625 | + | |
| 4626 | +Relations: | |
| 4627 | + | |
| 4628 | +```text | |
| 4629 | +Carcinogen → evidence of association → Cancer | |
| 4630 | +``` | |
| 4631 | + | |
| 4632 | +Store classification authority. | |
| 4633 | + | |
| 4634 | +--- | |
| 4635 | + | |
| 4636 | +# 134. INFECTIOUS ONCOLOGY | |
| 4637 | + | |
| 4638 | +Entities: | |
| 4639 | + | |
| 4640 | +```text | |
| 4641 | +HPV | |
| 4642 | +HBV | |
| 4643 | +HCV | |
| 4644 | +EBV | |
| 4645 | +H. pylori | |
| 4646 | +HHV-8 | |
| 4647 | +etc. | |
| 4648 | +``` | |
| 4649 | + | |
| 4650 | +Map to cancer evidence. | |
| 4651 | + | |
| 4652 | +--- | |
| 4653 | + | |
| 4654 | +# 135. CANCER PREVALENCE FORECASTS | |
| 4655 | + | |
| 4656 | +Can later model forecasts. | |
| 4657 | + | |
| 4658 | +But clearly label: | |
| 4659 | + | |
| 4660 | +```text | |
| 4661 | +OBSERVED | |
| 4662 | +ESTIMATED | |
| 4663 | +PROJECTED | |
| 4664 | +``` | |
| 4665 | + | |
| 4666 | +Never make projections visually indistinguishable from observed registry data. | |
| 4667 | + | |
| 4668 | +--- | |
| 4669 | + | |
| 4670 | +# 136. FORECAST ENGINE | |
| 4671 | + | |
| 4672 | +Potential: | |
| 4673 | + | |
| 4674 | +```text | |
| 4675 | +incidence forecast | |
| 4676 | +mortality forecast | |
| 4677 | +trial activity forecast | |
| 4678 | +research momentum | |
| 4679 | +``` | |
| 4680 | + | |
| 4681 | +Version each model. | |
| 4682 | + | |
| 4683 | +Display uncertainty intervals. | |
| 4684 | + | |
| 4685 | +--- | |
| 4686 | + | |
| 4687 | +# 137. DATA SNAPSHOTS | |
| 4688 | + | |
| 4689 | +Monthly immutable snapshots: | |
| 4690 | + | |
| 4691 | +```text | |
| 4692 | +CancerIndex 2026-09 | |
| 4693 | +CancerIndex 2026-10 | |
| 4694 | +``` | |
| 4695 | + | |
| 4696 | +Allows reproducibility. | |
| 4697 | + | |
| 4698 | +--- | |
| 4699 | + | |
| 4700 | +# 138. DATA RELEASES | |
| 4701 | + | |
| 4702 | +Publish: | |
| 4703 | + | |
| 4704 | +```text | |
| 4705 | +CancerIndex Data Release 1 | |
| 4706 | +``` | |
| 4707 | + | |
| 4708 | +With: | |
| 4709 | + | |
| 4710 | +```text | |
| 4711 | +new sources | |
| 4712 | +updated sources | |
| 4713 | +entity changes | |
| 4714 | +ranking methodology changes | |
| 4715 | +known limitations | |
| 4716 | +``` | |
| 4717 | + | |
| 4718 | +--- | |
| 4719 | + | |
| 4720 | +# 139. API VERSIONING | |
| 4721 | + | |
| 4722 | +Never break existing clients casually. | |
| 4723 | + | |
| 4724 | +Use: | |
| 4725 | + | |
| 4726 | +```text | |
| 4727 | +/v1 | |
| 4728 | +/v2 | |
| 4729 | +``` | |
| 4730 | + | |
| 4731 | +Data release version separate from API version. | |
| 4732 | + | |
| 4733 | +--- | |
| 4734 | + | |
| 4735 | +# 140. ADMIN CONTROL CENTER | |
| 4736 | + | |
| 4737 | +Route: | |
| 4738 | + | |
| 4739 | +```text | |
| 4740 | +/admin | |
| 4741 | +``` | |
| 4742 | + | |
| 4743 | +Sections: | |
| 4744 | + | |
| 4745 | +```text | |
| 4746 | +Overview | |
| 4747 | +Connectors | |
| 4748 | +Ingestion | |
| 4749 | +Entities | |
| 4750 | +Reconciliation | |
| 4751 | +Rankings | |
| 4752 | +Evidence | |
| 4753 | +Sources | |
| 4754 | +Licensing | |
| 4755 | +Users | |
| 4756 | +AI | |
| 4757 | +Jobs | |
| 4758 | +Search | |
| 4759 | +System | |
| 4760 | +``` | |
| 4761 | + | |
| 4762 | +--- | |
| 4763 | + | |
| 4764 | +# 141. CONNECTOR ADMIN | |
| 4765 | + | |
| 4766 | +For every connector: | |
| 4767 | + | |
| 4768 | +```text | |
| 4769 | +Run now | |
| 4770 | +Pause | |
| 4771 | +Resume | |
| 4772 | +Backfill | |
| 4773 | +Incremental sync | |
| 4774 | +Dry run | |
| 4775 | +View raw records | |
| 4776 | +View parser | |
| 4777 | +View errors | |
| 4778 | +View schema changes | |
| 4779 | +``` | |
| 4780 | + | |
| 4781 | +--- | |
| 4782 | + | |
| 4783 | +# 142. LICENSE REGISTRY | |
| 4784 | + | |
| 4785 | +Create internal table: | |
| 4786 | + | |
| 4787 | +```text | |
| 4788 | +source_license | |
| 4789 | +``` | |
| 4790 | + | |
| 4791 | +Fields: | |
| 4792 | + | |
| 4793 | +```text | |
| 4794 | +source | |
| 4795 | +license | |
| 4796 | +commercial use | |
| 4797 | +redistribution | |
| 4798 | +derivative works | |
| 4799 | +attribution requirements | |
| 4800 | +API terms | |
| 4801 | +review date | |
| 4802 | +notes | |
| 4803 | +approved for production | |
| 4804 | +``` | |
| 4805 | + | |
| 4806 | +No new connector becomes public until licensing status is reviewed. | |
| 4807 | + | |
| 4808 | +--- | |
| 4809 | + | |
| 4810 | +# 143. SOURCE PRIORITY | |
| 4811 | + | |
| 4812 | +When sources conflict, do not blindly implement a global precedence. | |
| 4813 | + | |
| 4814 | +Precedence depends on field. | |
| 4815 | + | |
| 4816 | +Examples: | |
| 4817 | + | |
| 4818 | +```text | |
| 4819 | +Gene official symbol → HGNC | |
| 4820 | +Clinical trial registration → ClinicalTrials.gov | |
| 4821 | +US regulatory status → FDA | |
| 4822 | +global burden estimate → selected IARC dataset | |
| 4823 | +US registry survival → SEER | |
| 4824 | +variant clinical curation → retain multiple curated sources | |
| 4825 | +``` | |
| 4826 | + | |
| 4827 | +--- | |
| 4828 | + | |
| 4829 | +# 144. ENTITY LINEAGE | |
| 4830 | + | |
| 4831 | +Every canonical field may need: | |
| 4832 | + | |
| 4833 | +```text | |
| 4834 | +derivedFromSourceRecordIds | |
| 4835 | +``` | |
| 4836 | + | |
| 4837 | +Example: | |
| 4838 | + | |
| 4839 | +```text | |
| 4840 | +canonical name: | |
| 4841 | +"Lung Adenocarcinoma" | |
| 4842 | + | |
| 4843 | +supported by: | |
| 4844 | +NCIt | |
| 4845 | +SEER | |
| 4846 | +OncoTree | |
| 4847 | +GDC | |
| 4848 | +``` | |
| 4849 | + | |
| 4850 | +--- | |
| 4851 | + | |
| 4852 | +# 145. CACHING | |
| 4853 | + | |
| 4854 | +Cache expensive: | |
| 4855 | + | |
| 4856 | +```text | |
| 4857 | +rankings | |
| 4858 | +global aggregates | |
| 4859 | +country dashboards | |
| 4860 | +AI answers | |
| 4861 | +knowledge graph layouts | |
| 4862 | +``` | |
| 4863 | + | |
| 4864 | +Invalidation should be event-driven when possible. | |
| 4865 | + | |
| 4866 | +--- | |
| 4867 | + | |
| 4868 | +# 146. PERFORMANCE | |
| 4869 | + | |
| 4870 | +Targets: | |
| 4871 | + | |
| 4872 | +```text | |
| 4873 | +homepage < 2 sec meaningful render | |
| 4874 | +search suggestions < 200 ms cached target | |
| 4875 | +common API reads < 300 ms target | |
| 4876 | +ranking query < 500 ms target | |
| 4877 | +``` | |
| 4878 | + | |
| 4879 | +Do not block page rendering on AI generation. | |
| 4880 | + | |
| 4881 | +--- | |
| 4882 | + | |
| 4883 | +# 147. SEO | |
| 4884 | + | |
| 4885 | +CancerIndex has enormous programmatic SEO potential. | |
| 4886 | + | |
| 4887 | +Pages: | |
| 4888 | + | |
| 4889 | +```text | |
| 4890 | +/cancer/{cancer} | |
| 4891 | +/cancer/{cancer}/survival | |
| 4892 | +/cancer/{cancer}/statistics | |
| 4893 | +/cancer/{cancer}/genes | |
| 4894 | +/cancer/{cancer}/trials | |
| 4895 | + | |
| 4896 | +/gene/{gene} | |
| 4897 | +/drug/{drug} | |
| 4898 | +/variant/{variant} | |
| 4899 | +/country/{country} | |
| 4900 | +``` | |
| 4901 | + | |
| 4902 | +Every generated page must contain substantive sourced information. | |
| 4903 | + | |
| 4904 | +No thin spam pages. | |
| 4905 | + | |
| 4906 | +--- | |
| 4907 | + | |
| 4908 | +# 148. STRUCTURED DATA | |
| 4909 | + | |
| 4910 | +Use relevant Schema.org structured metadata where appropriate: | |
| 4911 | + | |
| 4912 | +```text | |
| 4913 | +MedicalCondition | |
| 4914 | +Drug | |
| 4915 | +Dataset | |
| 4916 | +ScholarlyArticle | |
| 4917 | +Organization | |
| 4918 | +``` | |
| 4919 | + | |
| 4920 | +Verify current specifications before implementation. | |
| 4921 | + | |
| 4922 | +--- | |
| 4923 | + | |
| 4924 | +# 149. ACCESSIBILITY | |
| 4925 | + | |
| 4926 | +WCAG-minded implementation. | |
| 4927 | + | |
| 4928 | +Requirements: | |
| 4929 | + | |
| 4930 | +```text | |
| 4931 | +keyboard navigation | |
| 4932 | +screen reader labels | |
| 4933 | +contrast | |
| 4934 | +chart text alternatives | |
| 4935 | +color-independent state | |
| 4936 | +reduced motion | |
| 4937 | +``` | |
| 4938 | + | |
| 4939 | +--- | |
| 4940 | + | |
| 4941 | +# 150. INTERNATIONALIZATION | |
| 4942 | + | |
| 4943 | +English first. | |
| 4944 | + | |
| 4945 | +Architecture must support: | |
| 4946 | + | |
| 4947 | +```text | |
| 4948 | +French | |
| 4949 | +Spanish | |
| 4950 | +German | |
| 4951 | +Portuguese | |
| 4952 | +Japanese | |
| 4953 | +etc. | |
| 4954 | +``` | |
| 4955 | + | |
| 4956 | +Canonical scientific entity remains language-independent. | |
| 4957 | + | |
| 4958 | +Translations are attributes. | |
| 4959 | + | |
| 4960 | +--- | |
| 4961 | + | |
| 4962 | +# 151. LOCALIZATION | |
| 4963 | + | |
| 4964 | +Important distinction: | |
| 4965 | + | |
| 4966 | +```text | |
| 4967 | +language != geography | |
| 4968 | +``` | |
| 4969 | + | |
| 4970 | +French Canadian user can view Canadian data. | |
| 4971 | + | |
| 4972 | +French user can view France data. | |
| 4973 | + | |
| 4974 | +--- | |
| 4975 | + | |
| 4976 | +# 152. TESTING REQUIREMENTS | |
| 4977 | + | |
| 4978 | +Claude must create: | |
| 4979 | + | |
| 4980 | +```text | |
| 4981 | +unit tests | |
| 4982 | +integration tests | |
| 4983 | +connector fixture tests | |
| 4984 | +schema tests | |
| 4985 | +ranking tests | |
| 4986 | +reconciliation tests | |
| 4987 | +API contract tests | |
| 4988 | +UI tests | |
| 4989 | +end-to-end tests | |
| 4990 | +``` | |
| 4991 | + | |
| 4992 | +--- | |
| 4993 | + | |
| 4994 | +# 153. CONNECTOR FIXTURES | |
| 4995 | + | |
| 4996 | +Never run all connector tests against production APIs. | |
| 4997 | + | |
| 4998 | +Store sanitized fixtures. | |
| 4999 | + | |
| 5000 | +Test: | |
| 5001 | + | |
| 5002 | +```text | |
| 5003 | +normal response | |
| 5004 | +empty response | |
| 5005 | +pagination | |
| 5006 | +rate limit | |
| 5007 | +server error | |
| 5008 | +schema change | |
| 5009 | +malformed record | |
| 5010 | +duplicate record | |
| 5011 | +``` | |
| 5012 | + | |
| 5013 | +--- | |
| 5014 | + | |
| 5015 | +# 154. SCIENTIFIC REGRESSION TESTS | |
| 5016 | + | |
| 5017 | +Create invariant tests. | |
| 5018 | + | |
| 5019 | +Examples: | |
| 5020 | + | |
| 5021 | +```text | |
| 5022 | +survival >= 0 | |
| 5023 | +survival <= 1 | |
| 5024 | + | |
| 5025 | +incidence >= 0 | |
| 5026 | +mortality >= 0 | |
| 5027 | + | |
| 5028 | +lowerCI <= estimate | |
| 5029 | +estimate <= upperCI | |
| 5030 | +``` | |
| 5031 | + | |
| 5032 | +--- | |
| 5033 | + | |
| 5034 | +# 155. RANKING TESTS | |
| 5035 | + | |
| 5036 | +Given fixed fixture inputs, ranking output must be deterministic. | |
| 5037 | + | |
| 5038 | +Snapshot: | |
| 5039 | + | |
| 5040 | +```text | |
| 5041 | +ranking methodology version | |
| 5042 | +input snapshot | |
| 5043 | +output | |
| 5044 | +``` | |
| 5045 | + | |
| 5046 | +--- | |
| 5047 | + | |
| 5048 | +# 156. RECONCILIATION TESTS | |
| 5049 | + | |
| 5050 | +Known aliases: | |
| 5051 | + | |
| 5052 | +```text | |
| 5053 | +NSCLC | |
| 5054 | +non-small cell lung cancer | |
| 5055 | +``` | |
| 5056 | + | |
| 5057 | +should behave correctly. | |
| 5058 | + | |
| 5059 | +Known distinct diseases must never merge accidentally. | |
| 5060 | + | |
| 5061 | +Build a large gold-standard mapping fixture. | |
| 5062 | + | |
| 5063 | +--- | |
| 5064 | + | |
| 5065 | +# 157. AI EVALUATION SUITE | |
| 5066 | + | |
| 5067 | +Create fixed questions: | |
| 5068 | + | |
| 5069 | +```text | |
| 5070 | +What is PDAC? | |
| 5071 | +Compare LUAD and SCLC. | |
| 5072 | +What cancers are associated with BRAF V600E? | |
| 5073 | +What recruiting trials exist for X? | |
| 5074 | +``` | |
| 5075 | + | |
| 5076 | +Evaluate: | |
| 5077 | + | |
| 5078 | +```text | |
| 5079 | +citation correctness | |
| 5080 | +entity correctness | |
| 5081 | +numerical correctness | |
| 5082 | +unsupported statements | |
| 5083 | +source freshness | |
| 5084 | +``` | |
| 5085 | + | |
| 5086 | +--- | |
| 5087 | + | |
| 5088 | +# 158. HALLUCINATION DEFENSE | |
| 5089 | + | |
| 5090 | +AI must say: | |
| 5091 | + | |
| 5092 | +```text | |
| 5093 | +CancerIndex does not currently have sufficient sourced data to answer this. | |
| 5094 | +``` | |
| 5095 | + | |
| 5096 | +instead of guessing. | |
| 5097 | + | |
| 5098 | +--- | |
| 5099 | + | |
| 5100 | +# 159. NO SILENT FALLBACK TO MODEL KNOWLEDGE | |
| 5101 | + | |
| 5102 | +If CancerIndex retrieval finds no evidence: | |
| 5103 | + | |
| 5104 | +do not silently answer using model memory. | |
| 5105 | + | |
| 5106 | +Model knowledge may only be used as clearly labeled supplementary context if product policy explicitly allows it. | |
| 5107 | + | |
| 5108 | +Default: | |
| 5109 | + | |
| 5110 | +**database-grounded answers only.** | |
| 5111 | + | |
| 5112 | +--- | |
| 5113 | + | |
| 5114 | +# 160. SECURITY | |
| 5115 | + | |
| 5116 | +Protect: | |
| 5117 | + | |
| 5118 | +```text | |
| 5119 | +API keys | |
| 5120 | +database credentials | |
| 5121 | +connector credentials | |
| 5122 | +LLM keys | |
| 5123 | +admin routes | |
| 5124 | +worker endpoints | |
| 5125 | +``` | |
| 5126 | + | |
| 5127 | +Use environment variables/secrets. | |
| 5128 | + | |
| 5129 | +Never commit secrets. | |
| 5130 | + | |
| 5131 | +--- | |
| 5132 | + | |
| 5133 | +# 161. USER PRIVACY | |
| 5134 | + | |
| 5135 | +CancerIndex does not require personal health data to be useful. | |
| 5136 | + | |
| 5137 | +Avoid collecting: | |
| 5138 | + | |
| 5139 | +```text | |
| 5140 | +diagnosis | |
| 5141 | +genetic results | |
| 5142 | +treatment history | |
| 5143 | +medical documents | |
| 5144 | +``` | |
| 5145 | + | |
| 5146 | +unless a future clearly separated healthcare feature has proper privacy architecture. | |
| 5147 | + | |
| 5148 | +--- | |
| 5149 | + | |
| 5150 | +# 162. ANALYTICS PRIVACY | |
| 5151 | + | |
| 5152 | +Do not log sensitive search queries unnecessarily. | |
| 5153 | + | |
| 5154 | +Provide privacy-preserving analytics. | |
| 5155 | + | |
| 5156 | +--- | |
| 5157 | + | |
| 5158 | +# 163. AUTHORIZATION | |
| 5159 | + | |
| 5160 | +Roles: | |
| 5161 | + | |
| 5162 | +```text | |
| 5163 | +USER | |
| 5164 | +RESEARCHER | |
| 5165 | +CURATOR | |
| 5166 | +ADMIN | |
| 5167 | +SUPERADMIN | |
| 5168 | +``` | |
| 5169 | + | |
| 5170 | +Curators can edit scientific metadata. | |
| 5171 | + | |
| 5172 | +Every curator action is logged. | |
| 5173 | + | |
| 5174 | +--- | |
| 5175 | + | |
| 5176 | +# 164. CURATION PLATFORM | |
| 5177 | + | |
| 5178 | +Allow expert curators to: | |
| 5179 | + | |
| 5180 | +```text | |
| 5181 | +merge entities | |
| 5182 | +split entities | |
| 5183 | +add aliases | |
| 5184 | +correct mappings | |
| 5185 | +flag evidence | |
| 5186 | +resolve conflicts | |
| 5187 | +add citations | |
| 5188 | +approve AI extractions | |
| 5189 | +``` | |
| 5190 | + | |
| 5191 | +--- | |
| 5192 | + | |
| 5193 | +# 165. AI CURATION QUEUE | |
| 5194 | + | |
| 5195 | +LLM pipeline may discover candidate: | |
| 5196 | + | |
| 5197 | +```text | |
| 5198 | +publication → gene | |
| 5199 | +publication → cancer | |
| 5200 | +publication → drug | |
| 5201 | +``` | |
| 5202 | + | |
| 5203 | +Confidence: | |
| 5204 | + | |
| 5205 | +```text | |
| 5206 | +>0.98 auto-accept only for low-risk deterministic mappings | |
| 5207 | +0.80–0.98 review | |
| 5208 | +<0.80 reject/manual | |
| 5209 | +``` | |
| 5210 | + | |
| 5211 | +Thresholds must be evaluated empirically. | |
| 5212 | + | |
| 5213 | +Do not use these exact numbers blindly. | |
| 5214 | + | |
| 5215 | +--- | |
| 5216 | + | |
| 5217 | +# 166. EXTRACTION ENGINE | |
| 5218 | + | |
| 5219 | +For publications: | |
| 5220 | + | |
| 5221 | +```text | |
| 5222 | +abstract | |
| 5223 | +↓ | |
| 5224 | +NER | |
| 5225 | +↓ | |
| 5226 | +ontology mapping | |
| 5227 | +↓ | |
| 5228 | +relationship extraction | |
| 5229 | +↓ | |
| 5230 | +confidence | |
| 5231 | +↓ | |
| 5232 | +validation | |
| 5233 | +↓ | |
| 5234 | +graph edge | |
| 5235 | +``` | |
| 5236 | + | |
| 5237 | +Prefer deterministic identifiers when present. | |
| 5238 | + | |
| 5239 | +--- | |
| 5240 | + | |
| 5241 | +# 167. PDF INGESTION | |
| 5242 | + | |
| 5243 | +Some sources publish PDFs. | |
| 5244 | + | |
| 5245 | +Pipeline: | |
| 5246 | + | |
| 5247 | +```text | |
| 5248 | ||
| 5249 | +↓ | |
| 5250 | +native text extraction | |
| 5251 | +↓ | |
| 5252 | +layout understanding | |
| 5253 | +↓ | |
| 5254 | +table extraction | |
| 5255 | +↓ | |
| 5256 | +OCR only when necessary | |
| 5257 | +↓ | |
| 5258 | +structured JSON | |
| 5259 | +↓ | |
| 5260 | +validation | |
| 5261 | +``` | |
| 5262 | + | |
| 5263 | +Store page-level citations. | |
| 5264 | + | |
| 5265 | +--- | |
| 5266 | + | |
| 5267 | +# 168. TABLE EXTRACTION | |
| 5268 | + | |
| 5269 | +AI-extracted numeric tables must pass validations. | |
| 5270 | + | |
| 5271 | +Do not accept: | |
| 5272 | + | |
| 5273 | +```text | |
| 5274 | +OCR number → production statistic | |
| 5275 | +``` | |
| 5276 | + | |
| 5277 | +without confidence checks. | |
| 5278 | + | |
| 5279 | +--- | |
| 5280 | + | |
| 5281 | +# 169. DATA DIFFS | |
| 5282 | + | |
| 5283 | +On source refresh: | |
| 5284 | + | |
| 5285 | +```text | |
| 5286 | +previous snapshot | |
| 5287 | +vs | |
| 5288 | +current snapshot | |
| 5289 | +``` | |
| 5290 | + | |
| 5291 | +Generate: | |
| 5292 | + | |
| 5293 | +```text | |
| 5294 | +new records | |
| 5295 | +removed records | |
| 5296 | +changed values | |
| 5297 | +new enums | |
| 5298 | +``` | |
| 5299 | + | |
| 5300 | +Store diff. | |
| 5301 | + | |
| 5302 | +--- | |
| 5303 | + | |
| 5304 | +# 170. ALERTS | |
| 5305 | + | |
| 5306 | +Internal alerts: | |
| 5307 | + | |
| 5308 | +```text | |
| 5309 | +connector failure | |
| 5310 | +stale source | |
| 5311 | +ranking anomaly | |
| 5312 | +mass entity deletion | |
| 5313 | +schema drift | |
| 5314 | +unexpected record drop | |
| 5315 | +license review due | |
| 5316 | +``` | |
| 5317 | + | |
| 5318 | +--- | |
| 5319 | + | |
| 5320 | +# 171. ANOMALY DETECTION | |
| 5321 | + | |
| 5322 | +Example: | |
| 5323 | + | |
| 5324 | +```text | |
| 5325 | +GDC records yesterday: 2,430,000 | |
| 5326 | +today: 214 | |
| 5327 | +``` | |
| 5328 | + | |
| 5329 | +Do NOT publish a destructive update. | |
| 5330 | + | |
| 5331 | +Pause ingest and alert. | |
| 5332 | + | |
| 5333 | +--- | |
| 5334 | + | |
| 5335 | +# 172. BACKUPS | |
| 5336 | + | |
| 5337 | +Automated: | |
| 5338 | + | |
| 5339 | +```text | |
| 5340 | +PostgreSQL backups | |
| 5341 | +object storage versioning | |
| 5342 | +search reindex ability | |
| 5343 | +configuration backups | |
| 5344 | +``` | |
| 5345 | + | |
| 5346 | +Test restore process. | |
| 5347 | + | |
| 5348 | +--- | |
| 5349 | + | |
| 5350 | +# 173. INFRASTRUCTURE / CLUSTER DEPLOYMENT | |
| 5351 | + | |
| 5352 | +CancerIndex should be containerized. | |
| 5353 | + | |
| 5354 | +Use: | |
| 5355 | + | |
| 5356 | +```text | |
| 5357 | +Docker | |
| 5358 | +``` | |
| 5359 | + | |
| 5360 | +Services should be independently deployable. | |
| 5361 | + | |
| 5362 | +Suggested: | |
| 5363 | + | |
| 5364 | +```text | |
| 5365 | +web | |
| 5366 | +api | |
| 5367 | +worker-ingest | |
| 5368 | +worker-ai | |
| 5369 | +worker-ranking | |
| 5370 | +postgres | |
| 5371 | +redis | |
| 5372 | +clickhouse | |
| 5373 | +opensearch | |
| 5374 | +minio | |
| 5375 | +``` | |
| 5376 | + | |
| 5377 | +If deploying to an existing cluster, keep configuration portable. | |
| 5378 | + | |
| 5379 | +--- | |
| 5380 | + | |
| 5381 | +# 174. DOMAIN | |
| 5382 | + | |
| 5383 | +Production: | |
| 5384 | + | |
| 5385 | +```text | |
| 5386 | +www.cancerindex.io | |
| 5387 | +cancerindex.io | |
| 5388 | +api.cancerindex.io | |
| 5389 | +``` | |
| 5390 | + | |
| 5391 | +Optional: | |
| 5392 | + | |
| 5393 | +```text | |
| 5394 | +status.cancerindex.io | |
| 5395 | +docs.cancerindex.io | |
| 5396 | +``` | |
| 5397 | + | |
| 5398 | +--- | |
| 5399 | + | |
| 5400 | +# 175. OBSERVABILITY | |
| 5401 | + | |
| 5402 | +Use: | |
| 5403 | + | |
| 5404 | +```text | |
| 5405 | +structured logs | |
| 5406 | +metrics | |
| 5407 | +distributed traces | |
| 5408 | +error monitoring | |
| 5409 | +job monitoring | |
| 5410 | +``` | |
| 5411 | + | |
| 5412 | +Every request gets correlation ID. | |
| 5413 | + | |
| 5414 | +Every ingest gets run ID. | |
| 5415 | + | |
| 5416 | +--- | |
| 5417 | + | |
| 5418 | +# 176. INGEST RUN ID | |
| 5419 | + | |
| 5420 | +Example: | |
| 5421 | + | |
| 5422 | +```text | |
| 5423 | +ING-CLINICALTRIALS-20260908-000019 | |
| 5424 | +``` | |
| 5425 | + | |
| 5426 | +Every created/updated record can reference ingest run. | |
| 5427 | + | |
| 5428 | +--- | |
| 5429 | + | |
| 5430 | +# 177. CANCERINDEX SCORE VERSIONING | |
| 5431 | + | |
| 5432 | +Example: | |
| 5433 | + | |
| 5434 | +```text | |
| 5435 | +CI-IMPACT-v1.0 | |
| 5436 | +CI-RESEARCH-GAP-v1.0 | |
| 5437 | +CI-TRIAL-GAP-v1.0 | |
| 5438 | +CI-PROGRESS-v1.0 | |
| 5439 | +CI-MOMENTUM-v1.0 | |
| 5440 | +``` | |
| 5441 | + | |
| 5442 | +Never silently change formula. | |
| 5443 | + | |
| 5444 | +--- | |
| 5445 | + | |
| 5446 | +# 178. METHODOLOGY PAGE | |
| 5447 | + | |
| 5448 | +Route: | |
| 5449 | + | |
| 5450 | +```text | |
| 5451 | +/methodology | |
| 5452 | +``` | |
| 5453 | + | |
| 5454 | +Explain: | |
| 5455 | + | |
| 5456 | +```text | |
| 5457 | +sources | |
| 5458 | +normalization | |
| 5459 | +ranking | |
| 5460 | +age standardization | |
| 5461 | +survival | |
| 5462 | +research metrics | |
| 5463 | +trial metrics | |
| 5464 | +composite indexes | |
| 5465 | +uncertainty | |
| 5466 | +limitations | |
| 5467 | +``` | |
| 5468 | + | |
| 5469 | +The methodology page should be exceptionally detailed. | |
| 5470 | + | |
| 5471 | +--- | |
| 5472 | + | |
| 5473 | +# 179. PUBLIC REPRODUCIBILITY | |
| 5474 | + | |
| 5475 | +For each ranking: | |
| 5476 | + | |
| 5477 | +button: | |
| 5478 | + | |
| 5479 | +```text | |
| 5480 | +Methodology | |
| 5481 | +``` | |
| 5482 | + | |
| 5483 | +Display formula. | |
| 5484 | + | |
| 5485 | +Potential later: | |
| 5486 | + | |
| 5487 | +```text | |
| 5488 | +Download input dataset | |
| 5489 | +Download ranking dataset | |
| 5490 | +``` | |
| 5491 | + | |
| 5492 | +where licensing permits. | |
| 5493 | + | |
| 5494 | +--- | |
| 5495 | + | |
| 5496 | +# 180. DATA SOURCE BADGES | |
| 5497 | + | |
| 5498 | +On values: | |
| 5499 | + | |
| 5500 | +```text | |
| 5501 | +IARC | |
| 5502 | +SEER | |
| 5503 | +GDC | |
| 5504 | +FDA | |
| 5505 | +ClinicalTrials.gov | |
| 5506 | +CIViC | |
| 5507 | +``` | |
| 5508 | + | |
| 5509 | +Hover → metadata. | |
| 5510 | + | |
| 5511 | +--- | |
| 5512 | + | |
| 5513 | +# 181. CITATION UX | |
| 5514 | + | |
| 5515 | +Citation: | |
| 5516 | + | |
| 5517 | +```text | |
| 5518 | +[1] | |
| 5519 | +``` | |
| 5520 | + | |
| 5521 | +click opens side panel rather than sending user away immediately. | |
| 5522 | + | |
| 5523 | +Panel: | |
| 5524 | + | |
| 5525 | +```text | |
| 5526 | +Source | |
| 5527 | +Original title | |
| 5528 | +Dataset | |
| 5529 | +Record | |
| 5530 | +Date | |
| 5531 | +Method | |
| 5532 | +Open source | |
| 5533 | +``` | |
| 5534 | + | |
| 5535 | +--- | |
| 5536 | + | |
| 5537 | +# 182. CONFIDENCE UX | |
| 5538 | + | |
| 5539 | +Examples: | |
| 5540 | + | |
| 5541 | +```text | |
| 5542 | +High confidence | |
| 5543 | +Moderate confidence | |
| 5544 | +Limited evidence | |
| 5545 | +Sparse data | |
| 5546 | +``` | |
| 5547 | + | |
| 5548 | +Do not hide uncertainty. | |
| 5549 | + | |
| 5550 | +--- | |
| 5551 | + | |
| 5552 | +# 183. "WHY THIS RANK?" | |
| 5553 | + | |
| 5554 | +Every CancerIndex rank gets: | |
| 5555 | + | |
| 5556 | +```text | |
| 5557 | +Why #4? | |
| 5558 | +``` | |
| 5559 | + | |
| 5560 | +Click: | |
| 5561 | + | |
| 5562 | +```text | |
| 5563 | +Mortality burden +23.3 | |
| 5564 | +Lethality +18.9 | |
| 5565 | +Treatment gap +14.1 | |
| 5566 | +Research gap +9.7 | |
| 5567 | +Trend +8.2 | |
| 5568 | +... | |
| 5569 | +``` | |
| 5570 | + | |
| 5571 | +--- | |
| 5572 | + | |
| 5573 | +# 184. HISTORICAL RANKS | |
| 5574 | + | |
| 5575 | +Store ranking snapshots. | |
| 5576 | + | |
| 5577 | +Graph: | |
| 5578 | + | |
| 5579 | +```text | |
| 5580 | +2015 #12 | |
| 5581 | +2018 #11 | |
| 5582 | +2021 #9 | |
| 5583 | +2024 #8 | |
| 5584 | +``` | |
| 5585 | + | |
| 5586 | +Important: methodology consistency must be maintained or explicitly annotated. | |
| 5587 | + | |
| 5588 | +--- | |
| 5589 | + | |
| 5590 | +# 185. USER-CUSTOM RANKINGS | |
| 5591 | + | |
| 5592 | +Advanced feature. | |
| 5593 | + | |
| 5594 | +Allow users to set weights: | |
| 5595 | + | |
| 5596 | +```text | |
| 5597 | +Mortality 40% | |
| 5598 | +Incidence 20% | |
| 5599 | +Survival 20% | |
| 5600 | +Research gap 20% | |
| 5601 | +``` | |
| 5602 | + | |
| 5603 | +Generate: | |
| 5604 | + | |
| 5605 | +```text | |
| 5606 | +Custom Cancer Index | |
| 5607 | +``` | |
| 5608 | + | |
| 5609 | +Do not overwrite official CancerIndex ranking. | |
| 5610 | + | |
| 5611 | +--- | |
| 5612 | + | |
| 5613 | +# 186. DATA EXPLORER | |
| 5614 | + | |
| 5615 | +Advanced SQL-like analytics UI without exposing raw SQL. | |
| 5616 | + | |
| 5617 | +Dimensions: | |
| 5618 | + | |
| 5619 | +```text | |
| 5620 | +cancer | |
| 5621 | +country | |
| 5622 | +year | |
| 5623 | +sex | |
| 5624 | +age | |
| 5625 | +``` | |
| 5626 | + | |
| 5627 | +Measures: | |
| 5628 | + | |
| 5629 | +```text | |
| 5630 | +cases | |
| 5631 | +deaths | |
| 5632 | +ASIR | |
| 5633 | +ASMR | |
| 5634 | +survival | |
| 5635 | +trials | |
| 5636 | +publications | |
| 5637 | +``` | |
| 5638 | + | |
| 5639 | +--- | |
| 5640 | + | |
| 5641 | +# 187. CHART BUILDER | |
| 5642 | + | |
| 5643 | +Users choose: | |
| 5644 | + | |
| 5645 | +```text | |
| 5646 | +X = year | |
| 5647 | +Y = mortality | |
| 5648 | +Group = cancer | |
| 5649 | +Country = Canada | |
| 5650 | +``` | |
| 5651 | + | |
| 5652 | +Generate shareable chart. | |
| 5653 | + | |
| 5654 | +--- | |
| 5655 | + | |
| 5656 | +# 188. EMBEDDABLE CHARTS | |
| 5657 | + | |
| 5658 | +Future: | |
| 5659 | + | |
| 5660 | +```text | |
| 5661 | +embed.cancerindex.io/chart/{id} | |
| 5662 | +``` | |
| 5663 | + | |
| 5664 | +Attribution required. | |
| 5665 | + | |
| 5666 | +--- | |
| 5667 | + | |
| 5668 | +# 189. SHAREABLE RESEARCH CARDS | |
| 5669 | + | |
| 5670 | +Generate beautiful cards: | |
| 5671 | + | |
| 5672 | +```text | |
| 5673 | +Pancreatic Cancer | |
| 5674 | +#3 Lethality | |
| 5675 | +#7 Global Mortality | |
| 5676 | +5-year survival ... | |
| 5677 | +``` | |
| 5678 | + | |
| 5679 | +Always include date/source. | |
| 5680 | + | |
| 5681 | +--- | |
| 5682 | + | |
| 5683 | +# 190. PUBLIC DATA API KEYS | |
| 5684 | + | |
| 5685 | +API account: | |
| 5686 | + | |
| 5687 | +```text | |
| 5688 | +free | |
| 5689 | +research | |
| 5690 | +pro | |
| 5691 | +institutional | |
| 5692 | +``` | |
| 5693 | + | |
| 5694 | +Do not monetize third-party data contrary to source licenses. | |
| 5695 | + | |
| 5696 | +Value can come from CancerIndex aggregation, normalization and infrastructure where permitted. | |
| 5697 | + | |
| 5698 | +--- | |
| 5699 | + | |
| 5700 | +# 191. RATE LIMITING | |
| 5701 | + | |
| 5702 | +API: | |
| 5703 | + | |
| 5704 | +```text | |
| 5705 | +anonymous | |
| 5706 | +authenticated | |
| 5707 | +paid/institutional | |
| 5708 | +``` | |
| 5709 | + | |
| 5710 | +Return standard rate-limit headers. | |
| 5711 | + | |
| 5712 | +--- | |
| 5713 | + | |
| 5714 | +# 192. DEVELOPER PORTAL | |
| 5715 | + | |
| 5716 | +Route: | |
| 5717 | + | |
| 5718 | +```text | |
| 5719 | +/developers | |
| 5720 | +``` | |
| 5721 | + | |
| 5722 | +Include: | |
| 5723 | + | |
| 5724 | +```text | |
| 5725 | +API docs | |
| 5726 | +OpenAPI | |
| 5727 | +authentication | |
| 5728 | +examples | |
| 5729 | +schema | |
| 5730 | +changelog | |
| 5731 | +status | |
| 5732 | +``` | |
| 5733 | + | |
| 5734 | +--- | |
| 5735 | + | |
| 5736 | +# 193. DATA DOWNLOAD CENTER | |
| 5737 | + | |
| 5738 | +Route: | |
| 5739 | + | |
| 5740 | +```text | |
| 5741 | +/data | |
| 5742 | +``` | |
| 5743 | + | |
| 5744 | +List datasets CancerIndex is legally allowed to redistribute. | |
| 5745 | + | |
| 5746 | +--- | |
| 5747 | + | |
| 5748 | +# 194. SOURCE LICENSE AUTOMATION | |
| 5749 | + | |
| 5750 | +Crawler can periodically detect source terms changes. | |
| 5751 | + | |
| 5752 | +But: | |
| 5753 | + | |
| 5754 | +AI cannot make final legal determination. | |
| 5755 | + | |
| 5756 | +Flag for human review. | |
| 5757 | + | |
| 5758 | +--- | |
| 5759 | + | |
| 5760 | +# 195. RELEASE BOT | |
| 5761 | + | |
| 5762 | +Weekly report: | |
| 5763 | + | |
| 5764 | +```text | |
| 5765 | +CancerIndex Weekly Data Report | |
| 5766 | + | |
| 5767 | ++43 cancers/subtypes | |
| 5768 | ++12,328 publications | |
| 5769 | ++184 trials | |
| 5770 | ++2 FDA approvals | |
| 5771 | ++91,224 variant relations | |
| 5772 | + | |
| 5773 | +3 connector warnings | |
| 5774 | +``` | |
| 5775 | + | |
| 5776 | +--- | |
| 5777 | + | |
| 5778 | +# 196. FRONT PAGE DAILY UPDATE | |
| 5779 | + | |
| 5780 | +Show: | |
| 5781 | + | |
| 5782 | +```text | |
| 5783 | +Updated X minutes ago | |
| 5784 | +``` | |
| 5785 | + | |
| 5786 | +only for sources actually refreshed that recently. | |
| 5787 | + | |
| 5788 | +Do not imply global dataset freshness because ClinicalTrials updated today. | |
| 5789 | + | |
| 5790 | +--- | |
| 5791 | + | |
| 5792 | +# 197. CANCERINDEX DAILY | |
| 5793 | + | |
| 5794 | +Potential editorial product: | |
| 5795 | + | |
| 5796 | +**CancerIndex Daily** | |
| 5797 | + | |
| 5798 | +Automatically identify: | |
| 5799 | + | |
| 5800 | +```text | |
| 5801 | +important approvals | |
| 5802 | +practice-changing trials | |
| 5803 | +major publications | |
| 5804 | +new trial openings | |
| 5805 | +large dataset releases | |
| 5806 | +``` | |
| 5807 | + | |
| 5808 | +AI summarizes with citations. | |
| 5809 | + | |
| 5810 | +--- | |
| 5811 | + | |
| 5812 | +# 198. TREND DETECTOR | |
| 5813 | + | |
| 5814 | +Calculate abnormal increases in: | |
| 5815 | + | |
| 5816 | +```text | |
| 5817 | +publication volume | |
| 5818 | +trial creation | |
| 5819 | +drug development | |
| 5820 | +gene mentions | |
| 5821 | +``` | |
| 5822 | + | |
| 5823 | +Potential: | |
| 5824 | + | |
| 5825 | +```text | |
| 5826 | +"KRAS G12D research activity +74% YoY" | |
| 5827 | +``` | |
| 5828 | + | |
| 5829 | +Only publish after methodology validation. | |
| 5830 | + | |
| 5831 | +--- | |
| 5832 | + | |
| 5833 | +# 199. TOPIC GRAPH | |
| 5834 | + | |
| 5835 | +Search: | |
| 5836 | + | |
| 5837 | +```text | |
| 5838 | +ADC | |
| 5839 | +``` | |
| 5840 | + | |
| 5841 | +Graph: | |
| 5842 | + | |
| 5843 | +```text | |
| 5844 | +ADC | |
| 5845 | +→ HER2 | |
| 5846 | +→ TROP2 | |
| 5847 | +→ HER3 | |
| 5848 | +→ cancers | |
| 5849 | +→ drugs | |
| 5850 | +→ trials | |
| 5851 | +→ publications | |
| 5852 | +``` | |
| 5853 | + | |
| 5854 | +--- | |
| 5855 | + | |
| 5856 | +# 200. RELATIONSHIP TEMPORALITY | |
| 5857 | + | |
| 5858 | +Relationships evolve. | |
| 5859 | + | |
| 5860 | +Store: | |
| 5861 | + | |
| 5862 | +```text | |
| 5863 | +first evidence | |
| 5864 | +most recent evidence | |
| 5865 | +current status | |
| 5866 | +``` | |
| 5867 | + | |
| 5868 | +A therapy-cancer relationship may change from: | |
| 5869 | + | |
| 5870 | +```text | |
| 5871 | +experimental | |
| 5872 | +→ Phase III | |
| 5873 | +→ approved | |
| 5874 | +``` | |
| 5875 | + | |
| 5876 | +--- | |
| 5877 | + | |
| 5878 | +# 201. REAL-WORLD EVIDENCE | |
| 5879 | + | |
| 5880 | +Future module. | |
| 5881 | + | |
| 5882 | +Possible sources: | |
| 5883 | + | |
| 5884 | +```text | |
| 5885 | +public registries | |
| 5886 | +regulatory RWE reports | |
| 5887 | +published cohorts | |
| 5888 | +``` | |
| 5889 | + | |
| 5890 | +Do not attempt to ingest private medical records casually. | |
| 5891 | + | |
| 5892 | +--- | |
| 5893 | + | |
| 5894 | +# 202. PATIENT-REPORTED OUTCOMES | |
| 5895 | + | |
| 5896 | +When published: | |
| 5897 | + | |
| 5898 | +```text | |
| 5899 | +quality of life | |
| 5900 | +symptom burden | |
| 5901 | +functional outcomes | |
| 5902 | +``` | |
| 5903 | + | |
| 5904 | +Store separately from survival. | |
| 5905 | + | |
| 5906 | +--- | |
| 5907 | + | |
| 5908 | +# 203. ENDPOINT ENTITY | |
| 5909 | + | |
| 5910 | +Clinical endpoints should become structured concepts: | |
| 5911 | + | |
| 5912 | +```text | |
| 5913 | +OS | |
| 5914 | +PFS | |
| 5915 | +DFS | |
| 5916 | +EFS | |
| 5917 | +ORR | |
| 5918 | +DOR | |
| 5919 | +pCR | |
| 5920 | +MRD | |
| 5921 | +QoL | |
| 5922 | +``` | |
| 5923 | + | |
| 5924 | +Map trial results. | |
| 5925 | + | |
| 5926 | +--- | |
| 5927 | + | |
| 5928 | +# 204. TRIAL RESULTS EXTRACTION | |
| 5929 | + | |
| 5930 | +When results are available: | |
| 5931 | + | |
| 5932 | +capture structured registry results first. | |
| 5933 | + | |
| 5934 | +Publication-derived results must cite paper. | |
| 5935 | + | |
| 5936 | +Store: | |
| 5937 | + | |
| 5938 | +```text | |
| 5939 | +endpoint | |
| 5940 | +population | |
| 5941 | +arm | |
| 5942 | +estimate | |
| 5943 | +CI | |
| 5944 | +p-value | |
| 5945 | +follow-up | |
| 5946 | +``` | |
| 5947 | + | |
| 5948 | +--- | |
| 5949 | + | |
| 5950 | +# 205. TREATMENT EFFECT MODEL | |
| 5951 | + | |
| 5952 | +Do not store: | |
| 5953 | + | |
| 5954 | +```text | |
| 5955 | +Drug X improves survival by 40% | |
| 5956 | +``` | |
| 5957 | + | |
| 5958 | +Store: | |
| 5959 | + | |
| 5960 | +```text | |
| 5961 | +endpoint | |
| 5962 | +effect measure | |
| 5963 | +HR/RR/OR | |
| 5964 | +estimate | |
| 5965 | +CI | |
| 5966 | +population | |
| 5967 | +comparator | |
| 5968 | +trial | |
| 5969 | +follow-up | |
| 5970 | +``` | |
| 5971 | + | |
| 5972 | +--- | |
| 5973 | + | |
| 5974 | +# 206. CROSS-CANCER ANALYSIS | |
| 5975 | + | |
| 5976 | +Enable questions: | |
| 5977 | + | |
| 5978 | +```text | |
| 5979 | +Which cancers share KRAS mutations? | |
| 5980 | + | |
| 5981 | +Which cancers have HER2 amplification? | |
| 5982 | + | |
| 5983 | +Which cancers respond to tissue-agnostic therapies? | |
| 5984 | + | |
| 5985 | +Which cancers share immune biomarkers? | |
| 5986 | +``` | |
| 5987 | + | |
| 5988 | +--- | |
| 5989 | + | |
| 5990 | +# 207. TUMOR-AGNOSTIC INDICATIONS | |
| 5991 | + | |
| 5992 | +Support cancer-agnostic drug approvals. | |
| 5993 | + | |
| 5994 | +Drug indication entity may reference: | |
| 5995 | + | |
| 5996 | +```text | |
| 5997 | +biomarker | |
| 5998 | +without single cancer restriction | |
| 5999 | +``` | |
| 6000 | + | |
| 6001 | +Do not force every approval to one cancer ID. | |
| 6002 | + | |
| 6003 | +--- | |
| 6004 | + | |
| 6005 | +# 208. CANCER OF UNKNOWN PRIMARY | |
| 6006 | + | |
| 6007 | +Include CUP properly. | |
| 6008 | + | |
| 6009 | +Do not force primary anatomical site where unknown. | |
| 6010 | + | |
| 6011 | +--- | |
| 6012 | + | |
| 6013 | +# 209. BENIGN / BORDERLINE TUMORS | |
| 6014 | + | |
| 6015 | +CancerIndex may index clinically relevant nonmalignant/borderline tumors if useful for taxonomy. | |
| 6016 | + | |
| 6017 | +They must be clearly marked: | |
| 6018 | + | |
| 6019 | +```text | |
| 6020 | +malignant = false | |
| 6021 | +``` | |
| 6022 | + | |
| 6023 | +Never count them in cancer rankings unless methodology explicitly includes them. | |
| 6024 | + | |
| 6025 | +--- | |
| 6026 | + | |
| 6027 | +# 210. SKIN CANCER COUNTING | |
| 6028 | + | |
| 6029 | +Be careful with: | |
| 6030 | + | |
| 6031 | +```text | |
| 6032 | +non-melanoma skin cancers | |
| 6033 | +``` | |
| 6034 | + | |
| 6035 | +Some global datasets treat them differently. | |
| 6036 | + | |
| 6037 | +Ranking engine must preserve inclusion/exclusion rules. | |
| 6038 | + | |
| 6039 | +--- | |
| 6040 | + | |
| 6041 | +# 211. HEMATOLOGIC MALIGNANCIES | |
| 6042 | + | |
| 6043 | +Do not model solely by anatomical organ. | |
| 6044 | + | |
| 6045 | +Dedicated structure for: | |
| 6046 | + | |
| 6047 | +```text | |
| 6048 | +leukemia | |
| 6049 | +lymphoma | |
| 6050 | +myeloma | |
| 6051 | +myelodysplastic neoplasms | |
| 6052 | +myeloproliferative neoplasms | |
| 6053 | +``` | |
| 6054 | + | |
| 6055 | +--- | |
| 6056 | + | |
| 6057 | +# 212. SARCOMAS | |
| 6058 | + | |
| 6059 | +Build fine-grained taxonomy. | |
| 6060 | + | |
| 6061 | +Examples categories: | |
| 6062 | + | |
| 6063 | +```text | |
| 6064 | +soft tissue | |
| 6065 | +bone | |
| 6066 | +GIST | |
| 6067 | +leiomyosarcoma | |
| 6068 | +liposarcoma | |
| 6069 | +angiosarcoma | |
| 6070 | +synovial sarcoma | |
| 6071 | +Ewing sarcoma | |
| 6072 | +osteosarcoma | |
| 6073 | +``` | |
| 6074 | + | |
| 6075 | +Do not group all rare sarcomas when subtype data exists. | |
| 6076 | + | |
| 6077 | +--- | |
| 6078 | + | |
| 6079 | +# 213. BRAIN/CNS TUMORS | |
| 6080 | + | |
| 6081 | +Molecular classification is essential. | |
| 6082 | + | |
| 6083 | +Model modern molecular subtypes. | |
| 6084 | + | |
| 6085 | +Taxonomies change over time. | |
| 6086 | + | |
| 6087 | +Store classification version. | |
| 6088 | + | |
| 6089 | +--- | |
| 6090 | + | |
| 6091 | +# 214. BREAST CANCER | |
| 6092 | + | |
| 6093 | +Support: | |
| 6094 | + | |
| 6095 | +```text | |
| 6096 | +histology | |
| 6097 | +ER | |
| 6098 | +PR | |
| 6099 | +HER2 | |
| 6100 | +HER2-low where applicable | |
| 6101 | +triple negative | |
| 6102 | +molecular subtypes | |
| 6103 | +germline context | |
| 6104 | +``` | |
| 6105 | + | |
| 6106 | +Do not collapse all breast cancers. | |
| 6107 | + | |
| 6108 | +--- | |
| 6109 | + | |
| 6110 | +# 215. LUNG CANCER | |
| 6111 | + | |
| 6112 | +Support: | |
| 6113 | + | |
| 6114 | +```text | |
| 6115 | +SCLC | |
| 6116 | +NSCLC | |
| 6117 | +adenocarcinoma | |
| 6118 | +squamous | |
| 6119 | +large cell | |
| 6120 | +molecular alterations | |
| 6121 | +``` | |
| 6122 | + | |
| 6123 | +--- | |
| 6124 | + | |
| 6125 | +# 216. COLORECTAL CANCER | |
| 6126 | + | |
| 6127 | +Support: | |
| 6128 | + | |
| 6129 | +```text | |
| 6130 | +colon | |
| 6131 | +rectal | |
| 6132 | +left/right sided context where evidence requires | |
| 6133 | +MSI | |
| 6134 | +RAS | |
| 6135 | +BRAF | |
| 6136 | +HER2 | |
| 6137 | +``` | |
| 6138 | + | |
| 6139 | +--- | |
| 6140 | + | |
| 6141 | +# 217. PRECISION TAXONOMY | |
| 6142 | + | |
| 6143 | +CancerIndex needs overlapping labels. | |
| 6144 | + | |
| 6145 | +One patient cohort may conceptually be: | |
| 6146 | + | |
| 6147 | +```text | |
| 6148 | +lung | |
| 6149 | +adenocarcinoma | |
| 6150 | +metastatic | |
| 6151 | +EGFR-mutated | |
| 6152 | +exon 19 deletion | |
| 6153 | +``` | |
| 6154 | + | |
| 6155 | +Do not create a unique canonical cancer entity for every arbitrary combination. | |
| 6156 | + | |
| 6157 | +Use attributes/biomarker cohort definitions appropriately. | |
| 6158 | + | |
| 6159 | +--- | |
| 6160 | + | |
| 6161 | +# 218. COHORT ENTITY | |
| 6162 | + | |
| 6163 | +Create: | |
| 6164 | + | |
| 6165 | +```ts | |
| 6166 | +CohortDefinition | |
| 6167 | +``` | |
| 6168 | + | |
| 6169 | +Example: | |
| 6170 | + | |
| 6171 | +```text | |
| 6172 | +Metastatic EGFR exon 19 deletion lung adenocarcinoma | |
| 6173 | +``` | |
| 6174 | + | |
| 6175 | +This is not necessarily a globally recognized cancer taxonomy node. | |
| 6176 | + | |
| 6177 | +--- | |
| 6178 | + | |
| 6179 | +# 219. ONTOLOGY VERSIONING | |
| 6180 | + | |
| 6181 | +Taxonomies evolve. | |
| 6182 | + | |
| 6183 | +Store: | |
| 6184 | + | |
| 6185 | +```text | |
| 6186 | +ontology | |
| 6187 | +version | |
| 6188 | +concept | |
| 6189 | +valid_from | |
| 6190 | +valid_to | |
| 6191 | +``` | |
| 6192 | + | |
| 6193 | +Never lose historical mappings. | |
| 6194 | + | |
| 6195 | +--- | |
| 6196 | + | |
| 6197 | +# 220. CROSSWALK TABLES | |
| 6198 | + | |
| 6199 | +Build: | |
| 6200 | + | |
| 6201 | +```text | |
| 6202 | +NCIt ↔ ICD-O | |
| 6203 | +NCIt ↔ ICD-10 | |
| 6204 | +NCIt ↔ OncoTree | |
| 6205 | +NCIt ↔ Disease Ontology | |
| 6206 | +NCIt ↔ MONDO | |
| 6207 | +SEER ↔ canonical CancerIndex | |
| 6208 | +``` | |
| 6209 | + | |
| 6210 | +Mappings may be: | |
| 6211 | + | |
| 6212 | +```text | |
| 6213 | +exact | |
| 6214 | +broader | |
| 6215 | +narrower | |
| 6216 | +related | |
| 6217 | +ambiguous | |
| 6218 | +``` | |
| 6219 | + | |
| 6220 | +--- | |
| 6221 | + | |
| 6222 | +# 221. MATCH CONFIDENCE | |
| 6223 | + | |
| 6224 | +Entity mapping: | |
| 6225 | + | |
| 6226 | +```text | |
| 6227 | +EXACT_IDENTIFIER | |
| 6228 | +CURATED_EXACT | |
| 6229 | +ONTOLOGY_EXACT | |
| 6230 | +CURATED_BROADER | |
| 6231 | +CURATED_NARROWER | |
| 6232 | +ALIAS | |
| 6233 | +PROBABILISTIC | |
| 6234 | +UNRESOLVED | |
| 6235 | +``` | |
| 6236 | + | |
| 6237 | +--- | |
| 6238 | + | |
| 6239 | +# 222. UNRESOLVED ENTITY QUEUE | |
| 6240 | + | |
| 6241 | +Never discard unknown disease labels. | |
| 6242 | + | |
| 6243 | +Store: | |
| 6244 | + | |
| 6245 | +```text | |
| 6246 | +source text | |
| 6247 | +source ID | |
| 6248 | +context | |
| 6249 | +count | |
| 6250 | +``` | |
| 6251 | + | |
| 6252 | +Admin can map later. | |
| 6253 | + | |
| 6254 | +--- | |
| 6255 | + | |
| 6256 | +# 223. DATA DISCOVERY AGENT | |
| 6257 | + | |
| 6258 | +Build an AI-assisted internal agent that searches for: | |
| 6259 | + | |
| 6260 | +```text | |
| 6261 | +new official APIs | |
| 6262 | +new dataset releases | |
| 6263 | +schema changes | |
| 6264 | +new registries | |
| 6265 | +new cancer ontologies | |
| 6266 | +``` | |
| 6267 | + | |
| 6268 | +It produces proposals. | |
| 6269 | + | |
| 6270 | +It cannot automatically onboard sources into production without compliance review. | |
| 6271 | + | |
| 6272 | +--- | |
| 6273 | + | |
| 6274 | +# 224. CONNECTOR DOCUMENTATION REQUIREMENT | |
| 6275 | + | |
| 6276 | +Before Claude implements ANY connector: | |
| 6277 | + | |
| 6278 | +1. locate current official documentation; | |
| 6279 | +2. verify API/bulk mechanism; | |
| 6280 | +3. verify authentication; | |
| 6281 | +4. inspect pagination; | |
| 6282 | +5. inspect rate limits; | |
| 6283 | +6. inspect license/terms; | |
| 6284 | +7. inspect update schedule; | |
| 6285 | +8. inspect identifiers; | |
| 6286 | +9. save source schema; | |
| 6287 | +10. create tests. | |
| 6288 | + | |
| 6289 | +Do not implement an API from memory. | |
| 6290 | + | |
| 6291 | +--- | |
| 6292 | + | |
| 6293 | +# 225. CURRENT-DOC REQUIREMENT | |
| 6294 | + | |
| 6295 | +Because CancerIndex depends on external systems: | |
| 6296 | + | |
| 6297 | +**Claude MUST always verify current documentation before coding an integration.** | |
| 6298 | + | |
| 6299 | +Do not trust: | |
| 6300 | + | |
| 6301 | +```text | |
| 6302 | +old blog posts | |
| 6303 | +random GitHub examples | |
| 6304 | +Stack Overflow | |
| 6305 | +cached knowledge | |
| 6306 | +``` | |
| 6307 | + | |
| 6308 | +Prefer: | |
| 6309 | + | |
| 6310 | +```text | |
| 6311 | +official documentation | |
| 6312 | +official repositories | |
| 6313 | +official OpenAPI specs | |
| 6314 | +official release notes | |
| 6315 | +``` | |
| 6316 | + | |
| 6317 | +--- | |
| 6318 | + | |
| 6319 | +# 226. CONNECTOR SOURCE TEST | |
| 6320 | + | |
| 6321 | +Before production: | |
| 6322 | + | |
| 6323 | +```text | |
| 6324 | +curl/API smoke test | |
| 6325 | +↓ | |
| 6326 | +small fixture | |
| 6327 | +↓ | |
| 6328 | +parser | |
| 6329 | +↓ | |
| 6330 | +normalization | |
| 6331 | +↓ | |
| 6332 | +reconciliation | |
| 6333 | +↓ | |
| 6334 | +integration test | |
| 6335 | +↓ | |
| 6336 | +full sync | |
| 6337 | +``` | |
| 6338 | + | |
| 6339 | +--- | |
| 6340 | + | |
| 6341 | +# 227. HUGE IMPORT SAFETY | |
| 6342 | + | |
| 6343 | +Never begin a million-record import before proving the pipeline on: | |
| 6344 | + | |
| 6345 | +```text | |
| 6346 | +10 | |
| 6347 | +100 | |
| 6348 | +1,000 | |
| 6349 | +``` | |
| 6350 | + | |
| 6351 | +records. | |
| 6352 | + | |
| 6353 | +--- | |
| 6354 | + | |
| 6355 | +# 228. BULK-FIRST STRATEGY | |
| 6356 | + | |
| 6357 | +For massive datasets: | |
| 6358 | + | |
| 6359 | +prefer bulk downloads over millions of API calls when terms and official access support it. | |
| 6360 | + | |
| 6361 | +--- | |
| 6362 | + | |
| 6363 | +# 229. RATE LIMIT RESPECT | |
| 6364 | + | |
| 6365 | +Implement: | |
| 6366 | + | |
| 6367 | +```text | |
| 6368 | +token bucket | |
| 6369 | +exponential backoff | |
| 6370 | +Retry-After | |
| 6371 | +jitter | |
| 6372 | +max concurrency | |
| 6373 | +``` | |
| 6374 | + | |
| 6375 | +Source-specific. | |
| 6376 | + | |
| 6377 | +--- | |
| 6378 | + | |
| 6379 | +# 230. CHECKSUMS | |
| 6380 | + | |
| 6381 | +Bulk file: | |
| 6382 | + | |
| 6383 | +```text | |
| 6384 | +SHA-256 | |
| 6385 | +``` | |
| 6386 | + | |
| 6387 | +Store: | |
| 6388 | + | |
| 6389 | +```text | |
| 6390 | +source URL | |
| 6391 | +timestamp | |
| 6392 | +checksum | |
| 6393 | +size | |
| 6394 | +``` | |
| 6395 | + | |
| 6396 | +--- | |
| 6397 | + | |
| 6398 | +# 231. ETL LANGUAGE | |
| 6399 | + | |
| 6400 | +Use Python heavily for scientific ETL. | |
| 6401 | + | |
| 6402 | +TypeScript can orchestrate web/application systems. | |
| 6403 | + | |
| 6404 | +Do not force complex bioinformatics normalization into TypeScript if mature Python packages are appropriate. | |
| 6405 | + | |
| 6406 | +--- | |
| 6407 | + | |
| 6408 | +# 232. DATAFRAMES | |
| 6409 | + | |
| 6410 | +For large ETL: | |
| 6411 | + | |
| 6412 | +consider: | |
| 6413 | + | |
| 6414 | +```text | |
| 6415 | +Polars | |
| 6416 | +PyArrow | |
| 6417 | +DuckDB | |
| 6418 | +``` | |
| 6419 | + | |
| 6420 | +instead of blindly using pandas for everything. | |
| 6421 | + | |
| 6422 | +--- | |
| 6423 | + | |
| 6424 | +# 233. PARQUET | |
| 6425 | + | |
| 6426 | +Use Parquet for large analytical snapshots. | |
| 6427 | + | |
| 6428 | +Partition by sensible dimensions. | |
| 6429 | + | |
| 6430 | +Example: | |
| 6431 | + | |
| 6432 | +```text | |
| 6433 | +source | |
| 6434 | +year | |
| 6435 | +entity type | |
| 6436 | +``` | |
| 6437 | + | |
| 6438 | +--- | |
| 6439 | + | |
| 6440 | +# 234. BIOINFORMATICS LIBRARIES | |
| 6441 | + | |
| 6442 | +Before choosing packages: | |
| 6443 | + | |
| 6444 | +verify active maintenance/current documentation. | |
| 6445 | + | |
| 6446 | +Potential functionality: | |
| 6447 | + | |
| 6448 | +```text | |
| 6449 | +HGVS normalization | |
| 6450 | +VCF parsing | |
| 6451 | +genomic liftover | |
| 6452 | +sequence handling | |
| 6453 | +``` | |
| 6454 | + | |
| 6455 | +Never implement complex genomics standards from scratch unless necessary. | |
| 6456 | + | |
| 6457 | +--- | |
| 6458 | + | |
| 6459 | +# 235. GENOME BUILD | |
| 6460 | + | |
| 6461 | +Canonical support: | |
| 6462 | + | |
| 6463 | +```text | |
| 6464 | +GRCh37 | |
| 6465 | +GRCh38 | |
| 6466 | +``` | |
| 6467 | + | |
| 6468 | +Where available. | |
| 6469 | + | |
| 6470 | +Never silently convert coordinates. | |
| 6471 | + | |
| 6472 | +Store original + normalized. | |
| 6473 | + | |
| 6474 | +--- | |
| 6475 | + | |
| 6476 | +# 236. LIFTOVER | |
| 6477 | + | |
| 6478 | +If performing liftover: | |
| 6479 | + | |
| 6480 | +```text | |
| 6481 | +original assembly | |
| 6482 | +original coordinate | |
| 6483 | +target assembly | |
| 6484 | +converted coordinate | |
| 6485 | +tool/version | |
| 6486 | +status | |
| 6487 | +``` | |
| 6488 | + | |
| 6489 | +--- | |
| 6490 | + | |
| 6491 | +# 237. VARIANT NORMALIZATION | |
| 6492 | + | |
| 6493 | +Store: | |
| 6494 | + | |
| 6495 | +```text | |
| 6496 | +genomic HGVS | |
| 6497 | +coding HGVS | |
| 6498 | +protein HGVS | |
| 6499 | +gene | |
| 6500 | +transcript | |
| 6501 | +assembly | |
| 6502 | +dbSNP | |
| 6503 | +ClinVar ID | |
| 6504 | +CIViC ID | |
| 6505 | +``` | |
| 6506 | + | |
| 6507 | +Not every variant will have all identifiers. | |
| 6508 | + | |
| 6509 | +--- | |
| 6510 | + | |
| 6511 | +# 238. FUSIONS | |
| 6512 | + | |
| 6513 | +Dedicated structure: | |
| 6514 | + | |
| 6515 | +```text | |
| 6516 | +5' gene | |
| 6517 | +3' gene | |
| 6518 | +breakpoint | |
| 6519 | +fusion name | |
| 6520 | +orientation | |
| 6521 | +``` | |
| 6522 | + | |
| 6523 | +Do not model only as free text. | |
| 6524 | + | |
| 6525 | +--- | |
| 6526 | + | |
| 6527 | +# 239. COPY NUMBER | |
| 6528 | + | |
| 6529 | +Model: | |
| 6530 | + | |
| 6531 | +```text | |
| 6532 | +amplification | |
| 6533 | +gain | |
| 6534 | +loss | |
| 6535 | +deep deletion | |
| 6536 | +``` | |
| 6537 | + | |
| 6538 | +Keep source-specific thresholds. | |
| 6539 | + | |
| 6540 | +--- | |
| 6541 | + | |
| 6542 | +# 240. EXPRESSION | |
| 6543 | + | |
| 6544 | +Keep units/platform. | |
| 6545 | + | |
| 6546 | +Never compare raw expression values from incompatible platforms directly. | |
| 6547 | + | |
| 6548 | +--- | |
| 6549 | + | |
| 6550 | +# 241. BIOMARKER THRESHOLDS | |
| 6551 | + | |
| 6552 | +Example PD-L1. | |
| 6553 | + | |
| 6554 | +Store: | |
| 6555 | + | |
| 6556 | +```text | |
| 6557 | +assay | |
| 6558 | +clone | |
| 6559 | +scoring system | |
| 6560 | +threshold | |
| 6561 | +cancer | |
| 6562 | +indication | |
| 6563 | +``` | |
| 6564 | + | |
| 6565 | +Do not reduce to positive/negative globally. | |
| 6566 | + | |
| 6567 | +--- | |
| 6568 | + | |
| 6569 | +# 242. TMB | |
| 6570 | + | |
| 6571 | +Store: | |
| 6572 | + | |
| 6573 | +```text | |
| 6574 | +assay | |
| 6575 | +unit | |
| 6576 | +threshold | |
| 6577 | +panel | |
| 6578 | +cancer | |
| 6579 | +``` | |
| 6580 | + | |
| 6581 | +--- | |
| 6582 | + | |
| 6583 | +# 243. MSI | |
| 6584 | + | |
| 6585 | +Map: | |
| 6586 | + | |
| 6587 | +```text | |
| 6588 | +MSI-H | |
| 6589 | +MSS | |
| 6590 | +MSI-L | |
| 6591 | +dMMR | |
| 6592 | +pMMR | |
| 6593 | +``` | |
| 6594 | + | |
| 6595 | +but preserve differences. | |
| 6596 | + | |
| 6597 | +--- | |
| 6598 | + | |
| 6599 | +# 244. EVIDENCE CROSS-CANCER CONTEXT | |
| 6600 | + | |
| 6601 | +A variant may be: | |
| 6602 | + | |
| 6603 | +```text | |
| 6604 | +predictive in cancer A | |
| 6605 | +prognostic in cancer B | |
| 6606 | +unknown in cancer C | |
| 6607 | +``` | |
| 6608 | + | |
| 6609 | +Relationship context is mandatory. | |
| 6610 | + | |
| 6611 | +--- | |
| 6612 | + | |
| 6613 | +# 245. RANKING DATA ELIGIBILITY | |
| 6614 | + | |
| 6615 | +For a cancer to enter a ranking: | |
| 6616 | + | |
| 6617 | +define explicit inclusion rules. | |
| 6618 | + | |
| 6619 | +Example survival ranking: | |
| 6620 | + | |
| 6621 | +```text | |
| 6622 | +minimum cohort size | |
| 6623 | +accepted survival type | |
| 6624 | +accepted diagnosis period | |
| 6625 | +geography | |
| 6626 | +minimum source quality | |
| 6627 | +``` | |
| 6628 | + | |
| 6629 | +Do not rank sparse estimates unfairly. | |
| 6630 | + | |
| 6631 | +--- | |
| 6632 | + | |
| 6633 | +# 246. PARENT VS SUBTYPE RANKING | |
| 6634 | + | |
| 6635 | +Avoid double-counting. | |
| 6636 | + | |
| 6637 | +If global incidence gives: | |
| 6638 | + | |
| 6639 | +```text | |
| 6640 | +Lung Cancer = 2.4M | |
| 6641 | +``` | |
| 6642 | + | |
| 6643 | +and subtype estimates separately: | |
| 6644 | + | |
| 6645 | +```text | |
| 6646 | +LUAD | |
| 6647 | +SCC | |
| 6648 | +``` | |
| 6649 | + | |
| 6650 | +do not sum all three. | |
| 6651 | + | |
| 6652 | +Rank scope must define entity level. | |
| 6653 | + | |
| 6654 | +Allow: | |
| 6655 | + | |
| 6656 | +```text | |
| 6657 | +Top-level cancer ranking | |
| 6658 | +Histology ranking | |
| 6659 | +Subtype ranking | |
| 6660 | +Rare entity ranking | |
| 6661 | +``` | |
| 6662 | + | |
| 6663 | +--- | |
| 6664 | + | |
| 6665 | +# 247. GLOBAL MASTER RANKING | |
| 6666 | + | |
| 6667 | +Default broad global ranking should use mutually exclusive or carefully defined top-level cancer categories. | |
| 6668 | + | |
| 6669 | +Fine-grained ranking is separate. | |
| 6670 | + | |
| 6671 | +--- | |
| 6672 | + | |
| 6673 | +# 248. CANCERINDEX COVERAGE COUNT | |
| 6674 | + | |
| 6675 | +Homepage may say: | |
| 6676 | + | |
| 6677 | +```text | |
| 6678 | +4,812 cancer entities indexed | |
| 6679 | +``` | |
| 6680 | + | |
| 6681 | +only if entity model genuinely contains them. | |
| 6682 | + | |
| 6683 | +Do not market every alias as a separate cancer. | |
| 6684 | + | |
| 6685 | +--- | |
| 6686 | + | |
| 6687 | +# 249. DUPLICATE CONTROL | |
| 6688 | + | |
| 6689 | +Alias count ≠ cancer count. | |
| 6690 | + | |
| 6691 | +Subtype count ≠ top-level cancer count. | |
| 6692 | + | |
| 6693 | +Be explicit. | |
| 6694 | + | |
| 6695 | +--- | |
| 6696 | + | |
| 6697 | +# 250. METRIC CATALOG | |
| 6698 | + | |
| 6699 | +Create first-class: | |
| 6700 | + | |
| 6701 | +```text | |
| 6702 | +MetricDefinition | |
| 6703 | +``` | |
| 6704 | + | |
| 6705 | +Fields: | |
| 6706 | + | |
| 6707 | +```text | |
| 6708 | +id | |
| 6709 | +name | |
| 6710 | +description | |
| 6711 | +formula | |
| 6712 | +unit | |
| 6713 | +higherIsWorse | |
| 6714 | +aggregation | |
| 6715 | +validDimensions | |
| 6716 | +sources | |
| 6717 | +methodologyVersion | |
| 6718 | +``` | |
| 6719 | + | |
| 6720 | +--- | |
| 6721 | + | |
| 6722 | +# 251. FORMULA ENGINE | |
| 6723 | + | |
| 6724 | +Derived metrics should not live as random code functions. | |
| 6725 | + | |
| 6726 | +Create versioned formulas. | |
| 6727 | + | |
| 6728 | +Example: | |
| 6729 | + | |
| 6730 | +```yaml | |
| 6731 | +id: CI-METRIC-MIR | |
| 6732 | +name: Mortality-to-Incidence Ratio | |
| 6733 | +formula: mortality_count / incidence_count | |
| 6734 | +version: 1.0 | |
| 6735 | +``` | |
| 6736 | + | |
| 6737 | +--- | |
| 6738 | + | |
| 6739 | +# 252. DATA LINEAGE GRAPH | |
| 6740 | + | |
| 6741 | +Every ranked score should be traceable: | |
| 6742 | + | |
| 6743 | +```text | |
| 6744 | +CancerIndex score | |
| 6745 | +↓ | |
| 6746 | +component | |
| 6747 | +↓ | |
| 6748 | +normalized metric | |
| 6749 | +↓ | |
| 6750 | +source observation | |
| 6751 | +↓ | |
| 6752 | +raw source record | |
| 6753 | +``` | |
| 6754 | + | |
| 6755 | +--- | |
| 6756 | + | |
| 6757 | +# 253. ADMIN "TRACE VALUE" | |
| 6758 | + | |
| 6759 | +Admin button: | |
| 6760 | + | |
| 6761 | +```text | |
| 6762 | +TRACE | |
| 6763 | +``` | |
| 6764 | + | |
| 6765 | +For any number. | |
| 6766 | + | |
| 6767 | +Shows full lineage. | |
| 6768 | + | |
| 6769 | +This will save massive debugging time. | |
| 6770 | + | |
| 6771 | +--- | |
| 6772 | + | |
| 6773 | +# 254. CANCERINDEX LABS | |
| 6774 | + | |
| 6775 | +Experimental section: | |
| 6776 | + | |
| 6777 | +```text | |
| 6778 | +/labs | |
| 6779 | +``` | |
| 6780 | + | |
| 6781 | +For: | |
| 6782 | + | |
| 6783 | +```text | |
| 6784 | +forecasting | |
| 6785 | +experimental indexes | |
| 6786 | +novel network analysis | |
| 6787 | +AI research tools | |
| 6788 | +``` | |
| 6789 | + | |
| 6790 | +Clearly separate experimental metrics from main product. | |
| 6791 | + | |
| 6792 | +--- | |
| 6793 | + | |
| 6794 | +# 255. NETWORK CENTRALITY | |
| 6795 | + | |
| 6796 | +Interesting research feature: | |
| 6797 | + | |
| 6798 | +rank genes by: | |
| 6799 | + | |
| 6800 | +```text | |
| 6801 | +number of cancers | |
| 6802 | +number of actionable variants | |
| 6803 | +number of approved drugs | |
| 6804 | +number of trials | |
| 6805 | +network centrality | |
| 6806 | +``` | |
| 6807 | + | |
| 6808 | +Do not imply biological importance solely from graph centrality. | |
| 6809 | + | |
| 6810 | +--- | |
| 6811 | + | |
| 6812 | +# 256. DRUG TARGET LANDSCAPE | |
| 6813 | + | |
| 6814 | +Visual: | |
| 6815 | + | |
| 6816 | +```text | |
| 6817 | +targets × cancers | |
| 6818 | +``` | |
| 6819 | + | |
| 6820 | +Heatmap: | |
| 6821 | + | |
| 6822 | +```text | |
| 6823 | +approved | |
| 6824 | +clinical | |
| 6825 | +preclinical | |
| 6826 | +``` | |
| 6827 | + | |
| 6828 | +--- | |
| 6829 | + | |
| 6830 | +# 257. ONCOLOGY PIPELINE MAP | |
| 6831 | + | |
| 6832 | +Interactive: | |
| 6833 | + | |
| 6834 | +```text | |
| 6835 | +Cancer | |
| 6836 | +→ Target | |
| 6837 | +→ Drug | |
| 6838 | +→ Phase | |
| 6839 | +→ Company | |
| 6840 | +``` | |
| 6841 | + | |
| 6842 | +--- | |
| 6843 | + | |
| 6844 | +# 258. BIOMARKER LANDSCAPE | |
| 6845 | + | |
| 6846 | +Interactive matrix: | |
| 6847 | + | |
| 6848 | +```text | |
| 6849 | +Cancer × Biomarker | |
| 6850 | +``` | |
| 6851 | + | |
| 6852 | +Color: | |
| 6853 | + | |
| 6854 | +```text | |
| 6855 | +frequency | |
| 6856 | +clinical actionability | |
| 6857 | +``` | |
| 6858 | + | |
| 6859 | +Different toggles. | |
| 6860 | + | |
| 6861 | +--- | |
| 6862 | + | |
| 6863 | +# 259. CANCER GENOMIC LANDSCAPE | |
| 6864 | + | |
| 6865 | +Cancer page: | |
| 6866 | + | |
| 6867 | +```text | |
| 6868 | +Top mutated genes | |
| 6869 | +CNAs | |
| 6870 | +fusions | |
| 6871 | +pathways | |
| 6872 | +``` | |
| 6873 | + | |
| 6874 | +Allow study selection. | |
| 6875 | + | |
| 6876 | +Do not merge frequencies from incompatible cohorts without method. | |
| 6877 | + | |
| 6878 | +--- | |
| 6879 | + | |
| 6880 | +# 260. COHORT SELECTOR | |
| 6881 | + | |
| 6882 | +Example: | |
| 6883 | + | |
| 6884 | +```text | |
| 6885 | +TCGA | |
| 6886 | +MSK cohort | |
| 6887 | +CPTAC | |
| 6888 | +study X | |
| 6889 | +``` | |
| 6890 | + | |
| 6891 | +User can switch data source. | |
| 6892 | + | |
| 6893 | +--- | |
| 6894 | + | |
| 6895 | +# 261. FREQUENCY DENOMINATORS | |
| 6896 | + | |
| 6897 | +Every genomic frequency must include denominator. | |
| 6898 | + | |
| 6899 | +Example: | |
| 6900 | + | |
| 6901 | +```text | |
| 6902 | +KRAS mutation: 31.4% | |
| 6903 | +214 / 681 profiled samples | |
| 6904 | +``` | |
| 6905 | + | |
| 6906 | +Never show 31.4% without cohort context. | |
| 6907 | + | |
| 6908 | +--- | |
| 6909 | + | |
| 6910 | +# 262. MISSINGNESS | |
| 6911 | + | |
| 6912 | +Genomic studies frequently have different profiling coverage. | |
| 6913 | + | |
| 6914 | +Store: | |
| 6915 | + | |
| 6916 | +```text | |
| 6917 | +tested | |
| 6918 | +not tested | |
| 6919 | +unknown | |
| 6920 | +``` | |
| 6921 | + | |
| 6922 | +Do not assume missing = wild type. | |
| 6923 | + | |
| 6924 | +--- | |
| 6925 | + | |
| 6926 | +# 263. SURVIVAL CURVES | |
| 6927 | + | |
| 6928 | +Where permissible/raw aggregate data allow: | |
| 6929 | + | |
| 6930 | +Kaplan-Meier visualization. | |
| 6931 | + | |
| 6932 | +Display: | |
| 6933 | + | |
| 6934 | +```text | |
| 6935 | +n at risk | |
| 6936 | +CI | |
| 6937 | +censoring | |
| 6938 | +cohort | |
| 6939 | +endpoint | |
| 6940 | +``` | |
| 6941 | + | |
| 6942 | +Do not fabricate curves from summary survival percentages. | |
| 6943 | + | |
| 6944 | +--- | |
| 6945 | + | |
| 6946 | +# 264. INCIDENCE TREND CHART | |
| 6947 | + | |
| 6948 | +Use: | |
| 6949 | + | |
| 6950 | +```text | |
| 6951 | +annual estimates | |
| 6952 | +ASIR | |
| 6953 | +confidence intervals when available | |
| 6954 | +``` | |
| 6955 | + | |
| 6956 | +--- | |
| 6957 | + | |
| 6958 | +# 265. GLOBAL BURDEN BUBBLE CHART | |
| 6959 | + | |
| 6960 | +Axes: | |
| 6961 | + | |
| 6962 | +```text | |
| 6963 | +X = incidence | |
| 6964 | +Y = mortality/incidence | |
| 6965 | +bubble = deaths | |
| 6966 | +``` | |
| 6967 | + | |
| 6968 | +Great discovery visualization. | |
| 6969 | + | |
| 6970 | +--- | |
| 6971 | + | |
| 6972 | +# 266. RESEARCH GAP QUADRANT | |
| 6973 | + | |
| 6974 | +Axes: | |
| 6975 | + | |
| 6976 | +```text | |
| 6977 | +X = disease burden | |
| 6978 | +Y = research activity | |
| 6979 | +``` | |
| 6980 | + | |
Diff truncated — file too large.