import type { Metadata } from 'next'; import Link from 'next/link'; import type { ReactNode } from 'react'; import { PageHeader, Section, Note } from '@/components/ui/section'; import { EmptyState } from '@/components/ui/empty-state'; import { Freshness } from '@/components/ui/freshness'; import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; import { SourceBadge } from '@/components/ui/source-badge'; import { CompareBars, type CompareBarDatum } from '@/components/charts/compare-bars'; import { loadCompare, parseCompareIds, COMPARE_MIN, COMPARE_MAX, type CompareEntity } from '@/lib/queries/compare'; import { loadProvenance, toInfo } from '@/lib/queries/provenance'; import { bestRankFor } from '@/lib/queries/rankings'; import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; import { str, type SP } from '@/lib/search-params'; import { fmtInt, fmtValue, humanize, scopeLabel, toDate, unitLabel } from '@/lib/format'; import { ComparePicker } from './picker'; export const dynamic = 'force-dynamic'; export async function generateMetadata({ searchParams }: { searchParams: Promise }): Promise { const ids = parseCompareIds(str(await searchParams, 'ids')); const ents = ids.length ? await loadCompare(ids) : []; const names = ents.map((e) => e.cancer.canonical_name); return { title: names.length >= 2 ? `Compare: ${names.join(' vs ')}` : 'Compare cancers', description: names.length >= 2 ? `Side-by-side facts for ${names.join(', ')}: taxonomy, US registry figures, trials, literature, curated evidence, cohorts and current ranks — every value with its source and period.` : 'Compare two to four cancers side by side: taxonomy, US registry figures, clinical research, literature, molecular evidence and current ranks, with a source on every value.', alternates: { canonical: ids.length ? `/compare?ids=${ids.join(',')}` : '/compare' }, robots: ids.length ? { index: false, follow: true } : undefined, }; } const EPI_ROWS: Array<[string, string]> = [ ['mortality_count', 'US annual deaths'], ['incidence_count', 'US annual new cases'], ['as_mortality_rate', 'US age-standardized mortality'], ['as_incidence_rate', 'US age-standardized incidence'], ]; const RANK_METRICS = ['mortality_count', 'incidence_count', 'as_mortality_rate', 'as_incidence_rate', 'mortality_incidence_ratio', 'trial_gap', 'research_gap', 'active_trials', 'recruiting_trials', 'phase3_trials', 'publications_5y', 'publications_12m', 'publication_growth', 'curated_evidence_items', 'associated_genes', 'genomic_cohorts']; /** * /compare?ids=a,b,c (§100): side-by-side table of facts that exist in the database. Registry figures below the * top level fall back to the nearest top-level ancestor with an explicit label (methodology#compare). */ export default async function ComparePage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const ids = parseCompareIds(str(sp, 'ids')); const ents = ids.length ? await loadCompare(ids) : []; const missing = ids.filter((s) => !ents.some((e) => e.cancer.slug === s)); const enough = ents.length >= COMPARE_MIN; const provIds = ents.flatMap((e) => [...e.figures.values()].map((f) => f.provenance_id)); const prov = await loadProvenance(provIds); const registryNote = (e: CompareEntity) => (e.registry && e.registry.depth > 0 ? `registry level: ${e.registry.canonical_name}` : null); const freshest = ents .flatMap((e) => [...[...e.figures.values()].map((f) => toDate(f.updated_at)), e.counters ? toDate(e.counters.updated_at) : null, ...e.ranks.map((r) => toDate(r.generated_at))]) .filter((d): d is Date => !!d) .sort((a, b) => b.getTime() - a.getTime())[0]; return (
({ slug: e.cancer.slug, name: e.cancer.canonical_name }))} max={COMPARE_MAX} min={COMPARE_MIN} /> {missing.length ? (

Unknown or merged slug{missing.length > 1 ? 's' : ''}: {missing.join(', ')} — ignored.

) : null} {!enough ? (
Use the picker above or open /compare?ids=slug-1,slug-2 with cancer slugs from their pages.
) : (
{e.cancer.id}) }, { k: 'Entity type', cells: ents.map((e) => humanize(e.cancer.entity_type)) }, { k: 'Hierarchy depth', cells: ents.map((e) => {e.cancer.depth}) }, { k: 'Top-level registry site', cells: ents.map((e) => (e.cancer.top_level ? 'Yes' : e.registry ? No — nearest: {e.registry.canonical_name} ({e.registry.depth} level{e.registry.depth === 1 ? '' : 's'} up) : No top-level ancestor)) }, { k: 'Parents', cells: ents.map((e) => (e.parents.length ?
    {e.parents.map((p) =>
  • {p.canonical_name} {p.hierarchy_type}
  • )}
: root)) }, { k: 'NCIt / OncoTree', cells: ents.map((e) => {[e.cancer.primary_ncit_code, e.cancer.primary_oncotree_code].filter(Boolean).join(' · ') || '—'}) }, { k: 'Badges', cells: ents.map((e) => {e.cancer.rare_cancer === true ? Rare : null}{e.cancer.pediatric_relevant ? Pediatric : null}{e.cancer.hematologic ? Hematologic : null}{e.cancer.solid_tumor && !e.cancer.hematologic ? Solid tumor : null}{!e.cancer.malignant ? Non-malignant : null}{e.cancer.top_level ? Top-level : null}{e.cancer.rare_cancer == null ? rarity unknown : null}) }, ]} caption={<>Taxonomy from NCIt / OncoTree (). Badges are rule-derived from stored attributes.} /> registryNote(e))} rows={EPI_ROWS.map(([metric, label]) => ({ k: label, cells: ents.map((e) => { const f = e.figures.get(metric); if (!f) return e.registry ? {e.registry.depth > 0 ? 'no registry observation at this level; none for the ancestor either' : 'no observation'} : no registry observation at this level (no top-level ancestor); const p = toInfo(prov.get(f.provenance_id), 'normalized') ?? { sourceSlug: f.source_slug, sourceName: f.source_name }; return ( {fmtValue(f.value, f.unit)} {unitLabel(f.unit)} {f.estimate_type !== 'observed' ? {f.estimate_type} : null} {f.year_end && f.year_end !== f.year ? `${f.year}–${f.year_end}` : f.year} · both sexes · all ages{f.standard_population ? ` · ${f.standard_population.replace(/\s*\(.*\)$/, '')}` : ''} {e.registry && e.registry.depth > 0 ? figures for {e.registry.canonical_name} : null} ); }), }))} caption={<>Observations as published (site definitions on each entity's Statistics tab). No global figures: IARC / GLOBOCAN is under license review and SEER awaits credentials. For entities below the top level the nearest top-level ancestor's registry figures are shown and labelled; they describe the whole site group, not the subtype.} /> ({ k: label as string, cells: ents.map((e) => { const c = e.counters; if (!c) return counters not computed; const v = c[field as keyof typeof c] as number; return ( {fmtInt(v)} {field === 'drug_count' ? / {fmtInt(c.approved_drug_count)} : null} ); }), }))} caption={<>Counts aggregate over descendants across hierarchy types, so a broad family counts more than one of its subtypes; publication counts are query-based per entity. Counters refreshed {freshest ? {freshest.toISOString().slice(0, 10)} : 'unknown'}.} /> ents.some((e) => e.ranks.some((r) => r.metric_slug === m))).map((m) => ({ k: ents.flatMap((e) => e.ranks).find((r) => r.metric_slug === m)?.metric_name ?? humanize(m), cells: ents.map((e) => { const own = bestRankFor( e.ranks.filter((r) => r.cancer_id === e.cancer.id), m, { geo: 'USA', level: e.cancer.top_level ? 'top' : 'all' }, ); const viaAncestor = !own && e.registry && e.registry.depth > 0 ? bestRankFor(e.ranks.filter((r) => r.cancer_id === e.registry!.id), m, { geo: 'USA', level: 'top' }) : null; const r = own ?? viaAncestor; if (!r) return not ranked; return ( #{r.rank} of {fmtInt(r.eligible_entities)} {fmtValue(r.value, r.unit)} {unitLabel(r.unit)} · {scopeLabel(r.scope_key)} {viaAncestor ? rank of {e.registry!.canonical_name} : null} ); }), }))} caption={<>Ranks come from current snapshots (metric × scope × formula version); "of n" is the number of eligible entities in that scope. Top-level entities are ranked among the 36 mutually exclusive site groups, others among all malignant entities — the two are not comparable. Versioning.} emptyText="No current ranking snapshot covers these entities." />
e.figures.get('mortality_count'))} caption={<>Registry observation · latest year · } /> e.figures.get('as_mortality_rate'))} caption="Per 100,000 · standard population declared by the source" /> ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.active_trial_count : null, note: 'ClinicalTrials.gov · entity + descendants', href: `/cancer/${e.cancer.slug}/trials` }))} caption={<>Counter · } /> ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.publication_count_5y : null, note: 'PubMed · stored query per entity', href: `/cancer/${e.cancer.slug}/research` }))} caption={<>Counter · } />
Comparisons juxtapose facts of different natures (registry counts, registrations, literature volume). They are not a judgment of severity or priority, and population figures never describe an individual.
)}
); } function barsFor(ents: CompareEntity[], pick: (e: CompareEntity) => { value: number; year: number; estimate_type: string } | undefined): CompareBarDatum[] { return ents.map((e) => { const f = pick(e); const anc = e.registry && e.registry.depth > 0 ? e.registry.canonical_name : null; return { label: e.cancer.canonical_name, value: f ? Number(f.value) : null, note: f ? `${f.year} · ${f.estimate_type}${anc ? ` · registry level: ${anc}` : ''}` : 'no registry observation', href: `/cancer/${e.cancer.slug}/statistics`, muted: !!anc }; }); } function CompareTable({ ents, title, id, rows, caption, headNote, emptyText }: { ents: CompareEntity[]; title: string; id: string; rows: Array<{ k: string; cells: ReactNode[] }>; caption?: ReactNode; headNote?: Array; emptyText?: string }) { const nonEmpty = rows; return (
{nonEmpty.length === 0 ? ( {emptyText ?? 'Nothing to compare in this block.'} ) : (
{ents.map((e, i) => ( ))} {nonEmpty.map((r) => ( {r.cells.map((c, i) => ( ))} ))}
Fact {e.cancer.canonical_name} {headNote?.[i] ? {headNote[i]} : null}
{r.k} {c}
)} {caption ?

{caption}

: null}
); }