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%
7.1 KB · 121 lines tsx
Raw Blame History
1import { Section, Note } from '@/components/ui/section';2import { EmptyState } from '@/components/ui/empty-state';3import { Freshness } from '@/components/ui/freshness';4import { Badge, ClaimBadge } from '@/components/ui/badge';5import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';6import { LineChart, type Series } from '@/components/charts/line-chart';7import { epidemiologyFor, EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';8import { loadProvenance, toInfo } from '@/lib/queries/provenance';9import { fmtValue, humanize, unitLabel } from '@/lib/format';10import type { CancerBundle } from '../load';1112const ROWS_PER_TABLE = 12;1314export async function StatisticsTab({ b }: { b: CancerBundle }) {15  const obs = await epidemiologyFor(b.cancer.id);16  if (obs.length === 0) {17    return (18      <Section id="statistics" kicker="Epidemiology" title="Incidence, mortality and prevalence">19        <EmptyState20          title="Data not yet available"21          knows={[22            { label: 'Taxonomy and codes', href: `/cancer/${b.cancer.slug}` },23            ...(b.counters?.trial_count ? [{ label: `${b.counters.trial_count} trials`, href: `/cancer/${b.cancer.slug}/trials` }] : []),24            { label: 'Source registry and license status', href: '/sources' },25          ]}26        >27          No epidemiology observation is attached to this entity. Population statistics come from registries (SEER, CDC WONDER) and the IARC Global Cancer Observatory; IARC data stays under license review and SEER awaits credentials, so no burden figure is displayed. Statistics are attached per geography, year and sex — never invented or extrapolated.28        </EmptyState>29      </Section>30    );31  }3233  const prov = await loadProvenance(obs.map((o) => o.provenance_id));34  // Group: geography → metric → (sex, age) → series by year35  type Key = string;36  const groups = new Map<Key, { geography: string; metric: string; unit: string; rows: typeof obs }>();37  for (const o of obs) {38    const k = `${o.geography_id}|${o.metric}`;39    if (!groups.has(k)) groups.set(k, { geography: o.geography_name, metric: o.metric, unit: o.unit, rows: [] });40    groups.get(k)!.rows.push(o);41  }42  const latest = obs.reduce<Date | string | null>((m, o) => (m == null || String(o.updated_at) > String(m) ? o.updated_at : m), null);4344  return (45    <div className="space-y-8">46      <Note tone="warn">Population statistics describe groups defined by geography, period and sex. They do not predict any individual outcome. Values labelled "estimated" or "projected" are model outputs from the source, not registry counts.</Note>47      {[...groups.values()].map((g) => {48        const seriesMap = new Map<string, Series>();49        for (const r of g.rows) {50          const name = `${humanize(r.sex)}${r.age_group !== 'all' ? ` · ${r.age_group}` : ''}${r.estimate_type !== 'observed' ? ` (${r.estimate_type})` : ''} · ${r.source_slug}`;51          if (!seriesMap.has(name)) seriesMap.set(name, { name, points: [], dashed: r.estimate_type !== 'observed' });52          seriesMap.get(name)!.points.push({ x: r.year, y: r.value, lo: r.lower_ci, hi: r.upper_ci });53        }54        const series = [...seriesMap.values()];55        const multiYear = series.some((s) => s.points.length > 1);56        const sources = new Set(g.rows.map((r) => r.source_slug));57        const firstProv = toInfo(prov.get(g.rows[0]!.provenance_id)) ?? { sourceSlug: g.rows[0]!.source_slug, sourceName: g.rows[0]!.source_name };58        const shown = [...g.rows].sort((a, c) => c.year - a.year || a.sex.localeCompare(c.sex)).slice(0, ROWS_PER_TABLE);59        return (60          <Section key={`${g.geography}-${g.metric}`} id={`${g.metric}-${g.geography}`} kicker={g.geography} title={EPI_METRIC_LABEL[g.metric] ?? humanize(g.metric)} description={`Unit: ${unitLabel(g.unit)}${g.rows[0]?.standard_population ? ` · standard population: ${g.rows[0].standard_population}` : ''}`}>61            {multiYear ? <LineChart series={series} unit={g.unit} ariaLabel={`${EPI_METRIC_LABEL[g.metric] ?? g.metric} in ${g.geography} by year`} /> : null}62            <TableProvenance className={multiYear ? 'mt-4' : ''} p={firstProv} claim={<ClaimBadge kind="observed" />}>63              {g.rows.length} observation{g.rows.length === 1 ? '' : 's'}64              {sources.size > 1 ? ` from ${sources.size} sources` : ''} · observations differ by dataset and year: hover a row badge for its dataset and version.65            </TableProvenance>66            <div className="ci-table-wrap">67              <table className="ci-table">68                <thead>69                  <tr>70                    <th scope="col">Year</th>71                    <th scope="col">Sex</th>72                    <th scope="col">Age group</th>73                    <th scope="col" className="num">74                      Value ({unitLabel(g.unit)})75                    </th>76                    <th scope="col" className="num">77                      95% CI78                    </th>79                    <th scope="col">Type</th>80                    <th scope="col">Site definition</th>81                    <th scope="col">Source</th>82                  </tr>83                </thead>84                <tbody>85                  {shown.map((r) => (86                    <tr key={r.id}>87                      <td className="ci-num">{r.year_end && r.year_end !== r.year ? `${r.year}–${r.year_end}` : r.year}</td>88                      <td>{humanize(r.sex)}</td>89                      <td>{r.age_group === 'all' ? 'All ages' : r.age_group}</td>90                      <td className="num font-medium">{fmtValue(r.value, r.unit)}</td>91                      <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtValue(r.lower_ci, r.unit)}–${fmtValue(r.upper_ci, r.unit)}` : '—'}</td>92                      <td>93                        <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge>94                      </td>95                      <td className="max-w-[240px] text-[12px] text-ink-3">{r.site_definition ?? '—'}</td>96                      <td>97                        <SourceBadge compact p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} />98                      </td>99                    </tr>100                  ))}101                </tbody>102              </table>103            </div>104            {g.rows.length > ROWS_PER_TABLE ? (105              <p className="mt-1 text-[12px] text-ink-3">106                Showing the {ROWS_PER_TABLE} most recent rows of {g.rows.length}; every year is plotted above and available via{' '}107                <a className="ci-link" href={`/api/v1/cancers/${b.cancer.id}/statistics`}>108                  the API109                </a>110                .111              </p>112            ) : null}113            <Freshness dataUpdatedAt={g.rows.reduce<Date | string | null>((m, r) => (m == null || String(r.updated_at) > String(m) ? r.updated_at : m), null)} sourceVersion={prov.get(g.rows[0]!.provenance_id)?.dataset_version ?? null} />114          </Section>115        );116      })}117      <Freshness dataUpdatedAt={latest} extra={`${obs.length} observations`} />118    </div>119  );120}121