spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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