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%
12.1 KB · 202 lines tsx
Raw Blame History
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