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%
4.1 KB · 80 lines typescript
Raw Blame History
1import 'server-only';2import { run, sql, safe } from '@/lib/db';3import type { CancerCore, Counters } from '@/lib/queries/cancers';4import { latestFiguresFor, nearestRegistryAncestor, type LatestFigure, type RegistryAncestor } from '@/lib/queries/epidemiology';5import { rankingsForCancers, pickLatestScopes, type CancerRanking } from '@/lib/queries/rankings';67/**8 * Compare engine (SPEC §100): side-by-side facts for 2–4 cancers. Only facts that exist in the database are9 * shown; registry figures for entities below the top level fall back to the nearest top-level ancestor and10 * are labelled as such (see /methodology#compare).11 */1213export const COMPARE_MIN = 2;14export const COMPARE_MAX = 4;1516export interface CompareEntity {17  cancer: CancerCore;18  parents: Array<{ slug: string; canonical_name: string; hierarchy_type: string }>;19  counters: Counters | null;20  registry: RegistryAncestor | null; // entity whose observations are shown (self when top-level)21  figures: Map<string, LatestFigure>; // metric → latest observation (sex=all, USA)22  ranks: Array<CancerRanking & { cancer_id: string }>; // latest year per scope23}2425export function parseCompareIds(raw: string | undefined): string[] {26  if (!raw) return [];27  const seen = new Set<string>();28  const out: string[] = [];29  for (const part of raw.split(',')) {30    const s = part.trim().toLowerCase();31    if (!/^[a-z0-9][a-z0-9-]{0,120}$/.test(s) || seen.has(s)) continue;32    seen.add(s);33    out.push(s);34    if (out.length >= COMPARE_MAX) break;35  }36  return out;37}3839export async function resolveCancers(slugs: string[]): Promise<CancerCore[]> {40  if (slugs.length === 0) return [];41  const rows = await safe(() => run<CancerCore>(sql`SELECT * FROM cancers WHERE slug IN (${sql.join(slugs.map((s) => sql`${s}`), sql`, `)}) AND status <> 'merged'`), [] as CancerCore[]);42  const bySlug = new Map(rows.map((r) => [r.slug, r]));43  return slugs.map((s) => bySlug.get(s)).filter((r): r is CancerCore => !!r);44}4546export async function loadCompare(slugs: string[]): Promise<CompareEntity[]> {47  const cancers = await resolveCancers(slugs);48  if (cancers.length === 0) return [];49  const ids = cancers.map((c) => c.id);50  const [parents, counters, ancestors] = await Promise.all([51    safe(52      () =>53        run<{ child_id: string; slug: string; canonical_name: string; hierarchy_type: string }>(sql`54          SELECT h.child_id, p.slug, p.canonical_name, h.hierarchy_type FROM cancer_hierarchy h JOIN cancers p ON p.id = h.parent_id55          WHERE h.child_id IN (${sql.join(ids.map((i) => sql`${i}`), sql`, `)}) ORDER BY h.hierarchy_type, p.canonical_name`),56      [],57    ),58    safe(() => run<Counters & { entity_id: string }>(sql`SELECT * FROM entity_counters WHERE entity_type = 'cancer' AND entity_id IN (${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`), []),59    Promise.all(cancers.map((c) => (c.top_level ? Promise.resolve<RegistryAncestor | null>({ id: c.id, slug: c.slug, canonical_name: c.canonical_name, depth: 0 }) : nearestRegistryAncestor(c.id)))),60  ]);61  const registryIds = [...new Set(ancestors.filter((a): a is RegistryAncestor => !!a).map((a) => a.id))];62  const rankIds = [...new Set([...ids, ...registryIds])];63  const [figures, ranks] = await Promise.all([latestFiguresFor(registryIds, 'USA', 'all'), rankingsForCancers(rankIds)]);64  const latestRanks = pickLatestScopes(ranks);65  const countersById = new Map(counters.map((c) => [c.entity_id, c]));66  return cancers.map((c, i) => {67    const registry = ancestors[i] ?? null;68    const figs = new Map<string, LatestFigure>();69    if (registry) for (const f of figures) if (f.cancer_id === registry.id) figs.set(f.metric, f);70    return {71      cancer: c,72      parents: parents.filter((p) => p.child_id === c.id).slice(0, 4),73      counters: countersById.get(c.id) ?? null,74      registry,75      figures: figs,76      ranks: latestRanks.filter((r) => r.cancer_id === c.id || (registry && registry.depth > 0 && r.cancer_id === registry.id && /^(mortality_count|incidence_count|as_mortality_rate|as_incidence_rate|mortality_incidence_ratio|trial_gap|research_gap)$/.test(r.metric_slug))),77    };78  });79}80