spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { ClaimBadge, type ClaimKind } from '@/components/ui/badge';4import { SourceBadge } from '@/components/ui/source-badge';5import { Freshness } from '@/components/ui/freshness';6import { EmptyState } from '@/components/ui/empty-state';7import { latestFiguresFor, nearestRegistryAncestor, type LatestFigure } from '@/lib/queries/epidemiology';8import { rankingsForCancers, pickLatestScopes, bestRankFor } from '@/lib/queries/rankings';9import { loadProvenance, toInfo } from '@/lib/queries/provenance';10import { fmtInt, fmtValue, unitLabel, scopeLabel, toDate } from '@/lib/format';11import type { CancerBundle } from './load';1213/**14 * "Key figures" strip for the cancer overview (SPEC §43, §309). Every tile shows value + unit, the period,15 * a source badge and — when a current ranking snapshot covers the entity — "rank #n of m (scope)".16 * Registry figures come from the entity's own observations or, for entities below the top level, from the17 * nearest top-level ancestor with an explicit note. Nothing is estimated or extrapolated here.18 */1920interface Tile {21 key: string;22 label: string;23 value: ReactNode;24 unit?: string | null;25 period: string;26 source: ReactNode;27 claim: ClaimKind;28 rank?: { rank: number; eligible: number; scope: string; href: string } | null;29 note?: string | null;30 href?: string;31}3233const RANK_METRIC_FOR: Record<string, string> = { mortality_count: 'mortality_count', incidence_count: 'incidence_count', as_mortality_rate: 'as_mortality_rate' };3435export async function KeyFigures({ b }: { b: CancerBundle }) {36 const { cancer: c, counters } = b;37 const registry = c.top_level ? { id: c.id, slug: c.slug, canonical_name: c.canonical_name, depth: 0 } : await nearestRegistryAncestor(c.id);38 const rankIds = registry && registry.id !== c.id ? [c.id, registry.id] : [c.id];39 const [figures, ranksAll] = await Promise.all([registry ? latestFiguresFor([registry.id], 'USA', 'all') : Promise.resolve([] as LatestFigure[]), rankingsForCancers(rankIds)]);40 const ranks = pickLatestScopes(ranksAll);41 const prov = await loadProvenance(figures.map((f) => f.provenance_id));42 const byMetric = new Map(figures.map((f) => [f.metric, f]));43 const level: 'top' | 'all' = c.top_level ? 'top' : 'all';4445 const rankOf = (metric: string, forId: string, geo?: string) => {46 const r = bestRankFor(47 ranks.filter((x) => x.cancer_id === forId),48 metric,49 { geo, level: forId === c.id ? level : 'top' },50 );51 return r ? { rank: r.rank, eligible: r.eligible_entities, scope: scopeLabel(r.scope_key), href: `/rankings/${r.metric_slug}?scope=${encodeURIComponent(r.scope_key)}` } : null;52 };5354 const tiles: Tile[] = [];55 const epiNote = registry && registry.depth > 0 ? `figures shown for ${registry.canonical_name} (registry level)` : null;56 const epi: Array<[string, string]> = [57 ['mortality_count', 'US deaths'],58 ['incidence_count', 'US new cases'],59 ['as_mortality_rate', 'US age-standardized mortality'],60 ];61 for (const [metric, label] of epi) {62 const f = byMetric.get(metric);63 if (!f) continue;64 const p = toInfo(prov.get(f.provenance_id), 'normalized') ?? { sourceSlug: f.source_slug, sourceName: f.source_name };65 tiles.push({66 key: metric,67 label,68 value: fmtValue(f.value, f.unit),69 unit: f.unit,70 period: `${f.geography_name} · ${f.year_end && f.year_end !== f.year ? `${f.year}–${f.year_end}` : f.year} · ${f.sex === 'all' ? 'both sexes' : f.sex} · all ages${f.standard_population ? ` · ${f.standard_population.replace(/\s*\(.*\)$/, '')}` : ''}${f.estimate_type !== 'observed' ? ` · ${f.estimate_type}` : ''}`,71 source: <SourceBadge p={p} />,72 claim: 'observed',73 rank: registry ? rankOf(RANK_METRIC_FOR[metric] ?? metric, registry.id, f.iso3 ?? undefined) : null,74 note: epiNote,75 href: registry && registry.depth > 0 ? `/cancer/${registry.slug}/statistics` : `/cancer/${c.slug}/statistics`,76 });77 }78 const registryTilesMissing = tiles.length === 0;7980 if (counters) {81 const src = (slug: string, name: string) => <SourceBadge p={{ sourceSlug: slug, sourceName: name, layer: 'derived', note: 'Counter computed by CancerIndex from ingested records; aggregates the entity and its descendants.' }} />;82 tiles.push(83 { key: 'active_trials', label: 'Active trials', value: fmtInt(counters.active_trial_count), unit: 'count', period: 'current registry status · entity + descendants', source: src('clinicaltrials', 'ClinicalTrials.gov'), claim: 'computed', rank: rankOf('active_trials', c.id), href: `/cancer/${c.slug}/trials` },84 { key: 'recruiting_trials', label: 'Recruiting trials', value: fmtInt(counters.recruiting_trial_count), unit: 'count', period: 'status RECRUITING · entity + descendants', source: src('clinicaltrials', 'ClinicalTrials.gov'), claim: 'computed', rank: rankOf('recruiting_trials', c.id), href: `/cancer/${c.slug}/trials?status=RECRUITING` },85 { key: 'evidence', label: 'Curated evidence items', value: fmtInt(counters.evidence_count), unit: 'count', period: 'accepted CIViC items · entity + descendants', source: src('civic', 'CIViC'), claim: 'computed', rank: rankOf('curated_evidence_items', c.id), href: `/cancer/${c.slug}/evidence` },86 { key: 'pubs12m', label: 'Publications, last 12 months', value: fmtInt(counters.publication_count_12m), unit: 'count', period: 'PubMed records · stored query · 12-month window', source: src('pubmed', 'PubMed'), claim: 'computed', rank: rankOf('publications_12m', c.id), href: `/cancer/${c.slug}/research` },87 { key: 'cohorts', label: 'Genomic cohorts', value: fmtInt(counters.cohort_count), unit: 'count', period: 'open GDC projects · entity + descendants', source: src('gdc', 'NCI GDC'), claim: 'computed', rank: rankOf('genomic_cohorts', c.id), href: `/cancer/${c.slug}/genomics` },88 );89 }9091 const freshest = [...figures.map((f) => toDate(f.updated_at)), counters ? toDate(counters.updated_at) : null].filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;9293 return (94 <section id="key-figures" aria-labelledby="key-figures-title" className="ci-rule pt-5">95 <div className="mb-3 flex flex-wrap items-end justify-between gap-2">96 <div>97 <p className="ci-kicker mb-1">Key figures</p>98 <h2 id="key-figures-title" className="text-xl sm:text-2xl">99 At a glance100 </h2>101 </div>102 <p className="text-[12px] text-ink-3">Each figure carries its unit, period and source; ranks link to "Why this rank?".</p>103 </div>104 {tiles.length === 0 ? (105 <EmptyState compact title="No key figure yet">106 No registry observation, trial, evidence, literature or cohort counter is attached to this entity. Counters are refreshed after each connector run.107 </EmptyState>108 ) : (109 <>110 {registryTilesMissing ? (111 <p className="mb-3 border-l-2 border-rule-strong pl-3 text-[12.5px] text-ink-3">112 {registry113 ? `No US registry observation for ${registry.depth > 0 ? `${registry.canonical_name} (nearest registry-level ancestor)` : 'this top-level site'} yet.`114 : 'No registry-level ancestor: burden figures are published for the mutually exclusive top-level site groups only, and this entity does not descend from one.'}{' '}115 Global figures (IARC / GLOBOCAN) stay under license review and SEER awaits credentials.116 </p>117 ) : null}118 <ul className="grid grid-cols-1 gap-px bg-rule sm:grid-cols-2 lg:grid-cols-4">119 {tiles.map((t) => (120 <li key={t.key} className="flex min-w-0 flex-col bg-paper px-3 py-2.5">121 <span className="text-[11.5px] font-medium uppercase tracking-wide text-ink-3">{t.label}</span>122 <span className="mt-0.5 flex flex-wrap items-baseline gap-x-1.5">123 <span className="ci-num font-display text-2xl text-ink">{t.value}</span>124 {t.unit ? <span className="text-[11.5px] text-ink-3">{unitLabel(t.unit)}</span> : null}125 </span>126 <span className="mt-0.5 text-[11.5px] leading-4 text-ink-3">{t.period}</span>127 <span className="mt-1 flex flex-wrap items-center gap-1.5">128 {t.source}129 <ClaimBadge kind={t.claim} />130 </span>131 {t.rank ? (132 <Link href={t.rank.href} className="ci-link mt-1 text-[12px]">133 rank #{t.rank.rank} of {fmtInt(t.rank.eligible)} <span className="text-ink-3">({t.rank.scope})</span>134 </Link>135 ) : (136 <span className="mt-1 text-[11.5px] text-ink-4">not in a current ranking snapshot</span>137 )}138 {t.note ? (139 <span className="mt-1 text-[11.5px] italic text-warn">140 {t.note}141 {registry && registry.depth > 0 ? (142 <>143 {' '}144 —{' '}145 <Link className="ci-link" href={`/cancer/${registry.slug}`}>146 open {registry.canonical_name}147 </Link>148 </>149 ) : null}150 </span>151 ) : null}152 </li>153 ))}154 </ul>155 <Freshness dataUpdatedAt={freshest} extra={`registry figures: ${figures.length ? `${figures[0]!.source_slug} · latest year available per metric` : 'none'} · counters aggregate over descendants`} />156 </>157 )}158 </section>159 );160}161162export const COMPLETENESS_DIMENSIONS: Array<{ key: string; label: string; hint: string }> = [163 { key: 'epidemiology', label: 'Epidemiology', hint: 'At least one incidence/mortality observation attached to this entity' },164 { key: 'survival', label: 'Survival', hint: 'At least one survival observation' },165 { key: 'trials', label: 'Trials', hint: 'At least one ClinicalTrials.gov study mapped to the entity or a descendant' },166 { key: 'literature', label: 'Literature', hint: 'At least one PubMed record linked' },167 { key: 'genomics', label: 'Genomics', hint: 'At least one open genomic cohort (GDC project)' },168 { key: 'evidence', label: 'Evidence', hint: 'At least one accepted CIViC evidence item' },169 { key: 'therapies', label: 'Therapies', hint: 'At least one drug with a regulatory approval for the entity or a descendant' },170];171172/** Data completeness row: the 7 dimensions stored in entity_counters.completeness as filled/empty squares with labels. */173export function CompletenessRow({ completeness, computedAt }: { completeness: Record<string, number> | null | undefined; computedAt?: Date | string | null }) {174 const has = completeness && Object.keys(completeness).length > 0;175 const filled = COMPLETENESS_DIMENSIONS.filter((d) => has && Number(completeness![d.key] ?? 0) > 0).length;176 return (177 <div className="mt-3 border-t border-rule pt-2" aria-label={`Data completeness: ${filled} of ${COMPLETENESS_DIMENSIONS.length} dimensions`}>178 <p className="flex flex-wrap items-baseline justify-between gap-2 text-[12px] text-ink-3">179 <span className="ci-kicker">Data completeness</span>180 <span>181 <span className="ci-num text-ink">{filled}</span> / {COMPLETENESS_DIMENSIONS.length} dimensions{has ? '' : ' · counters not computed yet'}182 </span>183 </p>184 <ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1.5 text-[12px]">185 {COMPLETENESS_DIMENSIONS.map((d) => {186 const on = has && Number(completeness![d.key] ?? 0) > 0;187 return (188 <li key={d.key} className="inline-flex items-center gap-1.5" title={d.hint}>189 <span aria-hidden className={`inline-block h-[10px] w-[10px] border ${on ? 'border-accent bg-accent' : 'border-rule-strong bg-transparent'}`} />190 <span className={on ? 'text-ink' : 'text-ink-3'}>191 {d.label}192 <span className="sr-only">{on ? ' (data present)' : ' (no data yet)'}</span>193 </span>194 </li>195 );196 })}197 </ul>198 {computedAt ? <p className="mt-1 text-[11px] text-ink-4">Computed from entity_counters.completeness · refreshed after each connector run</p> : null}199 </div>200 );201}202