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%
8.8 KB · 215 lines typescript
Raw Blame History
1import 'server-only';2import { run, sql, safe } from '@/lib/db';34export 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}2122// 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`.24export 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_count29        FROM metric_definitions m ORDER BY m.category, m.name`),30    [] as MetricDef[],31  );32  return rows;33}3435export 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}3940export 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}5758export 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}6162export 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}7172export 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}8687export 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.inputs92        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}9697/** Every current ranking row for one cancer — the "Why this rank?" panel (§183). */98export 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}117export 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_ids122        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_slug123        WHERE r.cancer_id = ${cancerId} ORDER BY m.category, m.name, s.scope_key`),124    [] as CancerRanking[],125  );126}127128/** Home preview: the best available current top-level snapshot for a list of preferred metrics. */129export 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}140141/**142 * Current ranking rows for several cancers at once (compare page, overview hero). Same shape as143 * rankingsForCancer; callers keep the latest year per (metric, scope-without-year) with pickLatestScopes.144 */145export async function rankingsForCancers(cancerIds: string[]): Promise<Array<CancerRanking & { cancer_id: string }>> {146  if (cancerIds.length === 0) return [];147  return safe(148    () =>149      run<CancerRanking & { cancer_id: string }>(sql`150        SELECT r.id, r.cancer_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_ids151        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_slug152        WHERE r.cancer_id IN (${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}) ORDER BY m.category, m.name, s.scope_key`),153    [],154  );155}156157/** Keep, per (cancer, metric, scope without year), only the latest year. Ties keep the first row. */158export function pickLatestScopes<T extends { metric_slug: string; scope_key: string; cancer_id?: string }>(rows: T[]): T[] {159  const yearOf = (k: string) => Number(/year=(\d{4})/.exec(k)?.[1] ?? 0);160  const out = new Map<string, T>();161  for (const r of rows) {162    const key = `${r.cancer_id ?? ''}|${r.metric_slug}|${r.scope_key.replace(/year=[^|]*/, '')}`;163    const cur = out.get(key);164    if (!cur || yearOf(r.scope_key) > yearOf(cur.scope_key)) out.set(key, r);165  }166  return [...out.values()];167}168169/**170 * Pick the most relevant current rank for one metric: prefer the requested geography, then the requested171 * entity level (top for top-level entities, all otherwise), then the latest year.172 */173export function bestRankFor<T extends { metric_slug: string; scope_key: string }>(rows: T[], metricSlug: string, opts: { geo?: string; level?: 'top' | 'all' } = {}): T | null {174  const cands = rows.filter((r) => r.metric_slug === metricSlug);175  if (cands.length === 0) return null;176  const score = (k: string) => {177    let s = 0;178    if (opts.geo && k.includes(`geo=${opts.geo}|`)) s += 100;179    if (opts.level && k.endsWith(`level=${opts.level}`)) s += 10;180    s += Number(/year=(\d{4})/.exec(k)?.[1] ?? 0) / 10_000;181    return s;182  };183  return [...cands].sort((a, b) => score(b.scope_key) - score(a.scope_key))[0] ?? null;184}185186/** Latest current snapshot of a metric for a geography code (e.g. USA), top level. */187export async function latestSnapshotForGeo(slug: string, geo: string, sex = 'all'): Promise<Snapshot | null> {188  const rows = await safe(189    () => run<Snapshot>(sql`SELECT * FROM ranking_snapshots WHERE metric_slug = ${slug} AND is_current AND geography = ${geo} AND sex = ${sex} AND entity_level = 'top' ORDER BY year DESC NULLS LAST, generated_at DESC LIMIT 1`),190    [] as Snapshot[],191  );192  return rows[0] ?? null;193}194195/** Home module: the gap indexes (trial gap, research gap) for a geography, top rows with lineage. */196export async function gapRankings(geo: string, limit = 8): Promise<Array<{ metric: MetricDef; snapshot: Snapshot; rows: RankingRow[] }>> {197  const out: Array<{ metric: MetricDef; snapshot: Snapshot; rows: RankingRow[] }> = [];198  for (const slug of ['trial_gap', 'research_gap']) {199    const snapshot = await latestSnapshotForGeo(slug, geo);200    if (!snapshot) continue;201    const metric = await getMetric(slug);202    if (!metric) continue;203    const rows = await rankingRows(snapshot.id, limit);204    if (rows.length) out.push({ metric, snapshot, rows });205  }206  return out;207}208209export async function listSnapshots(limit = 200): Promise<Array<Snapshot & { metric_name: string; row_count: number }>> {210  return safe(211    () => 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}`),212    [],213  );214}215