SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%

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

Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent f2c2adc

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=&lt;ADMIN_TOKEN&gt;</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=&lt;slug&gt;&amp;scope=&lt;scope_key&gt;</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 &amp; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 +Google
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 +email
3856 +password
3857 +email verification
3858 +password reset
3859 +session management
3860 +```
3861 +
3862 +Optional:
3863 +
3864 +```text
3865 +Google
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 +PDF
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.