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%
9.3 KB · 164 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { Badge } from '@/components/ui/badge';7import { SourceBadge } from '@/components/ui/source-badge';8import { listGeographiesWithObservations, topCancersFor, allSitesObservations, WHO_REGION_LABEL } from '@/lib/queries/geography';9import { listSources } from '@/lib/queries/sources';10import { fmtInt, fmtValue, humanize, toDate, unitLabel } from '@/lib/format';1112export const metadata: Metadata = {13  title: 'Countries',14  description: 'Geographies with cancer epidemiology observations in CancerIndex: years covered, sources, and the latest-year figures per country.',15  alternates: { canonical: '/countries' },16};17export const revalidate = 3600;1819/**20 * /countries (§47): every geography that carries at least one epidemiology observation, with its year span,21 * the sources behind it and either the all-sites totals (when the source publishes one) or the top cancer by22 * deaths / new cases for the latest year. Geographies without data are not listed — nothing is invented.23 */24export default async function CountriesPage() {25  const [geos, sources] = await Promise.all([listGeographiesWithObservations(), listSources()]);26  const epiSources = sources.filter((s) => ['cdc-uscs', 'cdc-wonder', 'seer', 'seer-explorer', 'iarc-globocan'].includes(s.slug));2728  const details = await Promise.all(29    geos.map(async (g) => {30      const [allSites, deaths, cases] = await Promise.all([allSitesObservations(g.id, g.max_year, 'all'), topCancersFor(g, 'mortality_count', g.max_year, 'all', 1), topCancersFor(g, 'incidence_count', g.max_year, 'all', 1)]);31      return { g, allSites, deaths, cases };32    }),33  );34  const freshest = geos.map((g) => toDate(g.last_updated)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;3536  return (37    <div>38      <PageHeader kicker="Geographies" title="Countries with epidemiology data" lede="Population statistics are attached per geography, year and sex, exactly as published by a registry or statistical agency. This page lists only geographies for which at least one observation has been ingested.">39        <p className="mt-3 flex flex-wrap items-center gap-1.5 text-[12.5px] text-ink-3">40          Epidemiology sources:41          {epiSources.map((s) => (42            <span key={s.slug} className="inline-flex items-center gap-1">43              <SourceBadge p={{ sourceSlug: s.slug, sourceName: s.name, license: s.license, layer: 'raw' }} compact />44              <Badge tone={s.status === 'active' ? 'ok' : s.status === 'review' || s.status === 'awaiting_credentials' ? 'warn' : 'neutral'}>{humanize(s.status)}</Badge>45            </span>46          ))}47        </p>48      </PageHeader>4950      <Section id="list" kicker="Coverage" title={`${fmtInt(geos.length)} ${geos.length === 1 ? 'geography' : 'geographies'} with observations`} description="Years covered, distinct cancer entities with at least one observation, and the latest-year figures. Totals across all cancer sites are shown only when the source itself publishes an all-sites row; sites are never summed by CancerIndex.">51        {geos.length === 0 ? (52          <EmptyState title="No geography has observations yet">53            Country pages appear as soon as a licensed registry connector ingests observations. IARC / GLOBOCAN stays under license review and SEER awaits credentials, so global coverage is not available yet.54          </EmptyState>55        ) : (56          <>57            <div className="ci-table-wrap">58              <table className="ci-table">59                <thead>60                  <tr>61                    <th>Geography</th>62                    <th>ISO3</th>63                    <th>WHO region</th>64                    <th className="num">Years</th>65                    <th className="num">Cancers</th>66                    <th className="num">Observations</th>67                    <th>Sources</th>68                    <th>Latest year</th>69                    <th>Highest annual deaths</th>70                    <th>Highest annual new cases</th>71                  </tr>72                </thead>73                <tbody>74                  {details.map(({ g, allSites, deaths, cases }) => {75                    const total = (metric: string) => allSites.find((a) => a.metric === metric);76                    const td = total('mortality_count');77                    const tc = total('incidence_count');78                    const d0 = deaths.rows[0];79                    const c0 = cases.rows[0];80                    return (81                      <tr key={g.id}>82                        <td>83                          <Link className="ci-link font-medium" href={`/country/${g.slug}`}>84                            {g.name}85                          </Link>86                          <span className="ml-1.5 text-[11.5px] text-ink-3">{humanize(g.kind)}</span>87                        </td>88                        <td className="ci-mono">{g.iso3 ?? '—'}</td>89                        <td className="text-[12.5px] text-ink-2">{g.who_region ? WHO_REGION_LABEL[g.who_region] ?? g.who_region : '—'}</td>90                        <td className="num">91                          {g.min_year}–{g.max_year}92                        </td>93                        <td className="num">{fmtInt(g.n_cancers)}</td>94                        <td className="num">{fmtInt(g.n_obs)}</td>95                        <td>96                          <span className="inline-flex flex-wrap gap-1">97                            {(g.sources ?? []).map((s) => (98                              <SourceBadge key={s.slug} p={{ sourceSlug: s.slug, sourceName: s.name }} compact />99                            ))}100                          </span>101                        </td>102                        <td className="ci-num">{g.max_year}</td>103                        <td className="text-[12.5px]">104                          {td ? (105                            <span>106                              All sites: <span className="ci-num font-medium">{fmtValue(td.value, td.unit)}</span> {unitLabel(td.unit)} ({td.year})107                            </span>108                          ) : d0 ? (109                            <span>110                              <Link className="ci-link" href={`/cancer/${d0.slug}`}>111                                {d0.canonical_name}112                              </Link>{' '}113                              <span className="ci-num font-medium">{fmtValue(d0.value, d0.unit)}</span> <span className="text-ink-3">({deaths.year})</span>114                            </span>115                          ) : (116                            <span className="text-ink-4">—</span>117                          )}118                        </td>119                        <td className="text-[12.5px]">120                          {tc ? (121                            <span>122                              All sites: <span className="ci-num font-medium">{fmtValue(tc.value, tc.unit)}</span> {unitLabel(tc.unit)} ({tc.year})123                            </span>124                          ) : c0 ? (125                            <span>126                              <Link className="ci-link" href={`/cancer/${c0.slug}`}>127                                {c0.canonical_name}128                              </Link>{' '}129                              <span className="ci-num font-medium">{fmtValue(c0.value, c0.unit)}</span> <span className="text-ink-3">({cases.year})</span>130                            </span>131                          ) : (132                            <span className="text-ink-4">—</span>133                          )}134                        </td>135                      </tr>136                    );137                  })}138                </tbody>139              </table>140            </div>141            <p className="mt-2 text-[12px] text-ink-3">"Highest annual deaths / new cases" is the top single site group for the latest year of that metric, both sexes, all ages — not a national total. Where a source publishes an all-sites aggregate it is shown instead and labelled "All sites".</p>142            <Freshness dataUpdatedAt={freshest} extra={`${fmtInt(geos.reduce((n, g) => n + g.n_obs, 0))} observations in total`} />143          </>144        )}145      </Section>146147      <Section id="status" kicker="Coverage status" title="Why only these geographies?" level={3}>148        <Note>149          CancerIndex publishes population statistics only from sources whose license has been reviewed (CLAUDE.md §10.4). U.S. Cancer Statistics (CDC, NPCR/SEER incidence and NVSS mortality) is approved and ingested. The IARC Global Cancer Observatory (GLOBOCAN), which would provide estimates for 185 countries, is under license review; the SEER API awaits credentials. Until then no country outside the United States has observations, and no global ranking of countries is shown. Estimates are never substituted for registry counts.150        </Note>151        <p className="mt-2 text-[13px]">152          <Link className="ci-link" href="/sources">153            Source registry and license status →154          </Link>{' '}155          ·{' '}156          <Link className="ci-link" href="/methodology#country-scopes">157            Methodology: country scopes →158          </Link>159        </p>160      </Section>161    </div>162  );163}164