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%
5.6 KB · 118 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Section } from '@/components/ui/section';3import { EmptyState } from '@/components/ui/empty-state';4import { Freshness } from '@/components/ui/freshness';5import { Badge, ClaimBadge } from '@/components/ui/badge';6import { SourceBadge } from '@/components/ui/source-badge';7import { getGeographyBySlug, topCancersFor, yearsFor, type TopCancersResult } from '@/lib/queries/geography';8import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';9import { fmtInt, fmtValue, unitLabel, toDate } from '@/lib/format';1011/**12 * Home module "Cancer burden in the United States (latest year)" (§105, §328): top 5 by deaths and top 5 by13 * age-standardized incidence, straight from observations, with year and source on every row.14 */15export async function BurdenModule({ slug = 'united-states' }: { slug?: string }) {16  const geo = await getGeographyBySlug(slug);17  const years = geo ? await yearsFor(geo.id) : [];18  const latest = years[0];19  const [deaths, asir] = geo && latest ? await Promise.all([topCancersFor(geo, 'mortality_count', latest, 'all', 5), topCancersFor(geo, 'as_incidence_rate', latest, 'all', 5)]) : [null, null];20  const has = !!(deaths?.rows.length || asir?.rows.length);21  const freshest = [...(deaths?.rows ?? []), ...(asir?.rows ?? [])].map((r) => toDate(r.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;2223  return (24    <Section25      id="us-burden"26      kicker="Cancer burden"27      title={`Cancer burden in the United States${latest ? ` (${latest})` : ''}`}28      description="Registry observations as published by the source, both sexes, all ages, per site group. No all-sites total is shown unless the source publishes one."29      actions={30        <Link href={`/country/${slug}`} className="ci-link">31          Country page →32        </Link>33      }34    >35      {!has ? (36        <EmptyState title="No US observation yet">37          Burden figures appear once a licensed registry connector has ingested observations for the United States. IARC / GLOBOCAN remains under license review; SEER awaits credentials.38          <div className="mt-1">39            <Link href="/countries" className="ci-link">40              Countries with data41            </Link>42          </div>43        </EmptyState>44      ) : (45        <>46          <div className="grid gap-6 md:grid-cols-2">47            {[deaths, asir].map((res) => (res ? <TopFive key={res.metric} res={res} sex="all" slug={slug} /> : null))}48          </div>49          <Freshness dataUpdatedAt={freshest} extra={`source: ${deaths?.rows[0]?.source_slug ?? asir?.rows[0]?.source_slug ?? 'registry'} · per-site rows, not summed`} />50        </>51      )}52    </Section>53  );54}5556function TopFive({ res, sex, slug }: { res: TopCancersResult; sex: string; slug: string }) {57  const first = res.rows[0];58  if (!first) {59    return (60      <div>61        <p className="ci-kicker mb-1">{EPI_METRIC_LABEL[res.metric] ?? res.metric}</p>62        <EmptyState compact>No {EPI_METRIC_LABEL[res.metric]?.toLowerCase() ?? res.metric} observation for {res.requestedYear}.</EmptyState>63      </div>64    );65  }66  const allObserved = res.rows.every((r) => r.estimate_type === 'observed');67  return (68    <div className="min-w-0">69      <p className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2">70        <span className="ci-kicker">Top 5 · {EPI_METRIC_LABEL[res.metric] ?? res.metric}</span>71        <span className="text-[11.5px] text-ink-3">72          {res.year}73          {res.year !== res.requestedYear ? ` (latest available; ${res.requestedYear} not yet published)` : ''} · {unitLabel(first.unit)}74        </span>75      </p>76      <div className="ci-table-wrap">77        <table className="ci-table">78          <thead>79            <tr>80              <th className="num">#</th>81              <th>Cancer</th>82              <th className="num">{unitLabel(first.unit)}</th>83              <th>Type</th>84            </tr>85          </thead>86          <tbody>87            {res.rows.map((r, i) => (88              <tr key={r.cancer_id}>89                <td className="num">{r.rank ?? i + 1}</td>90                <td>91                  <Link className="ci-link" href={`/cancer/${r.slug}`}>92                    {r.canonical_name}93                  </Link>94                </td>95                <td className="num font-medium">{fmtValue(r.value, r.unit)}</td>96                <td>97                  <Badge tone={r.estimate_type === 'observed' ? 'ok' : 'warn'}>{r.estimate_type}</Badge>98                </td>99              </tr>100            ))}101          </tbody>102        </table>103      </div>104      {/* div, not p: the SourceBadge popover contains a <dl>, which the HTML parser would use to close a <p> (hydration mismatch). */}105      <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">106        <SourceBadge p={{ sourceSlug: first.source_slug, sourceName: first.source_name, dataset: first.site_definition ? 'USCS site groups' : null, layer: 'normalized' }} />107        <ClaimBadge kind="observed" />108        {first.standard_population ? <span>standard: {first.standard_population}</span> : null}109        {res.rows.some((r) => r.rank == null) ? <span>rank = position in this table when no snapshot covers the year</span> : <span>rank from snapshot {first.rank_scope_key ? <span className="ci-mono">{first.rank_scope_key}</span> : null}</span>}110        {!allObserved ? <span className="italic text-warn">includes estimated values</span> : null}111        <Link className="ci-link" href={`/country/${slug}?sex=${sex}&year=${res.year}#${res.metric}`}>112          full table ({fmtInt(res.rows.length)} shown) →113        </Link>114      </div>115    </div>116  );117}118