SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
12.5 KB · 284 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import type { ReactNode } from 'react';4import { t, tOpt } from '@/i18n';5import { isNotBuilt } from '@/lib/api';6import { apiExplore } from '@/lib/api-explore';7import { formatDate, grouped } from '@/lib/format';8import { routes } from '@/lib/site';9import type { MethodologyResponse } from '@/lib/types-explore';10import { StatusBadge } from '@/components/data/freshness-badge';11import { PageHeader } from '@/components/explore/page-header';12import { Toc } from '@/components/methodology/toc';1314export const revalidate = 3600;1516export const metadata: Metadata = {17  title: t('method.title'),18  description: t('method.sub'),19  alternates: { canonical: routes.methodology() },20};2122const SECTIONS = ['principles', 'countries', 'indicators', 'sources', 'validation', 'freshness', 'rankings', 'changes', 'similarity', 'insights', 'dna', 'limitations', 'versioning', 'licence'] as const;2324function Prose({ id, title, children }: { id: string; title: string; children: ReactNode }) {25  return (26    <section id={id} className="scroll-mt-28 border-t border-rule py-8 first:border-t-0 md:py-10" aria-labelledby={`${id}-h`}>27      <h2 id={`${id}-h`} className="display text-2xl text-ink md:text-3xl">28        <a href={`#${id}`} className="link-quiet">29          {title}30        </a>31      </h2>32      <div className="mt-3 max-w-prose space-y-3 text-base leading-relaxed text-ink-2 [&_p]:text-pretty">{children}</div>33    </section>34  );35}3637function Kv({ rows }: { rows: Array<[string, ReactNode]> }) {38  return (39    <dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-6 gap-y-1 text-sm">40      {rows.map(([k, v]) => (41        <div key={k} className="contents">42          <dt className="text-ink-2">{k}</dt>43          <dd className="tnum text-right text-ink">{v}</dd>44        </div>45      ))}46    </dl>47  );48}4950export default async function MethodologyPage() {51  let m: MethodologyResponse | null = null;52  try {53    m = await apiExplore.methodology();54  } catch (e) {55    if (!isNotBuilt(e)) throw e;56  }57  const toc = SECTIONS.map((id) => ({ id, label: t(`method.${id}` as 'method.principles') }));58  const stale = m?.validation.stale_after_days ?? { A: 800, Q: 200, M: 75 };5960  return (61    <>62      <PageHeader title={t('method.title')} lede={t('method.sub')} meta={m?.meta.built_at ? `${t('site.footer.refreshed', { date: formatDate(m.meta.built_at) })}${m.meta.run_id ? ` · ${t('site.footer.build', { run: m.meta.run_id })}` : ''}` : undefined} />63      <div className="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12">64        <div className="lg:self-start">65          <Toc items={toc} />66        </div>67        <article className="min-w-0">68          <Prose id="principles" title={t('method.principles')}>69            <ol className="list-decimal space-y-2 pl-5">70              {(m?.principles ?? []).map((p) => (71                <li key={p}>{p}</li>72              ))}73            </ol>74          </Prose>7576          <Prose id="countries" title={t('method.countries')}>77            <p>{t('method.countries.text')}</p>78            <p className="text-sm">79              <Link href={routes.regions()} className="text-accent hover:underline">80                {t('regions.title')} →81              </Link>82            </p>83          </Prose>8485          <Prose id="indicators" title={t('method.indicators')}>86            <p>{t('method.indicators.text', { n: grouped(m?.indicators.n ?? 260) })}</p>87            {m ? (88              <div className="grid gap-6 pt-2 sm:grid-cols-2">89                <div>90                  <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.indicators.formats')}</h3>91                  <Kv rows={m.indicators.formats.map((f) => [f.format, grouped(f.n)] as [string, ReactNode])} />92                </div>93                <div>94                  <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.indicators.units')}</h3>95                  <Kv rows={m.indicators.units.slice(0, 10).map((u) => [u.unit, grouped(u.n)] as [string, ReactNode])} />96                  <h3 className="mb-1.5 mt-5 text-sm font-semibold text-ink">{t('method.indicators.frequencies')}</h3>97                  <Kv rows={Object.entries(m.indicators.frequencies).map(([k, v]) => [tOpt(`indicator.frequency.${k}`, k), grouped(v)] as [string, ReactNode])} />98                  <h3 className="mb-1.5 mt-5 text-sm font-semibold text-ink">{t('method.indicators.aggregations')}</h3>99                  <Kv rows={Object.entries(m.indicators.aggregations).map(([k, v]) => [k, grouped(v)] as [string, ReactNode])} />100                </div>101              </div>102            ) : null}103            <p className="text-sm">104              <Link href={routes.indicators()} className="text-accent hover:underline">105                {t('indicators.title')} →106              </Link>107            </p>108          </Prose>109110          <Prose id="sources" title={t('method.sources')}>111            <p>{m?.sources.priority_rule}</p>112            <p>{t('method.sources.text2')}</p>113            {m ? (114              <div className="grid gap-6 pt-2 sm:grid-cols-2">115                <div>116                  <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.sources.mapped')}</h3>117                  <Kv118                    rows={Object.entries(m.sources.series_mapped_per_connector)119                      .sort((a, b) => b[1] - a[1])120                      .map(([k, v]) => [k, grouped(v)] as [string, ReactNode])}121                  />122                </div>123                <div>124                  <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.sources.urls')}</h3>125                  <ul className="space-y-1 text-xs">126                    {Object.entries(m.sources.urls).map(([k, v]) => (127                      <li key={k} className="grid grid-cols-[5rem_minmax(0,1fr)] gap-2">128                        <span className="text-ink-2">{k}</span>129                        <code className="break-all font-mono text-ink">{v}</code>130                      </li>131                    ))}132                  </ul>133                </div>134              </div>135            ) : null}136            <p className="text-sm">137              <Link href={routes.sources()} className="text-accent hover:underline">138                {t('sources.title')} →139              </Link>140            </p>141          </Prose>142143          <Prose id="validation" title={t('method.validation')}>144            <p>{t('method.validation.text')}</p>145            {m ? (146              <table className="w-full border-collapse text-sm">147                <thead>148                  <tr className="border-b border-rule text-left text-xs text-ink-3">149                    <th scope="col" className="py-1.5 pr-3 font-medium">{t('method.rule.code')}</th>150                    <th scope="col" className="py-1.5 pr-3 font-medium">{t('method.rule.severity')}</th>151                    <th scope="col" className="py-1.5 font-medium">{t('method.rule.effect')}</th>152                  </tr>153                </thead>154                <tbody className="divide-y divide-rule">155                  {m.validation.rules.map((r) => (156                    <tr key={r.code}>157                      <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{r.code}</td>158                      <td className="py-2 pr-3 align-top text-xs text-ink-2">{r.severity}</td>159                      <td className="py-2 align-top text-ink-2">{r.text}</td>160                    </tr>161                  ))}162                </tbody>163              </table>164            ) : null}165            <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.validation.statuses')}</h3>166            <ul className="space-y-2">167              {(['verified', 'imported', 'warning', 'stale', 'quarantined'] as const).map((s) => (168                <li key={s} className="flex items-start gap-3">169                  <span className="mt-0.5 shrink-0">170                    <StatusBadge status={s} />171                  </span>172                  <span className="text-sm">{t(`method.validation.status.${s}` as 'method.validation.status.verified')}</span>173                </li>174              ))}175            </ul>176            <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.validation.stale')}</h3>177            <p className="text-sm">{t('method.validation.staleText', { a: stale.A ?? 800, q: stale.Q ?? 200, m: stale.M ?? 75 })}</p>178          </Prose>179180          <Prose id="freshness" title={t('method.freshness')}>181            <p>{t('method.freshness.text')}</p>182            {m ? (183              <>184                <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.periods')}</h3>185                <Kv rows={Object.entries(m.periods).map(([k, v]) => [k, <code key={k} className="font-mono text-xs">{v}</code>] as [string, ReactNode])} />186              </>187            ) : null}188          </Prose>189190          <Prose id="rankings" title={t('method.rankings')}>191            <p>{t('method.rankings.text')}</p>192            {m ? <p className="text-sm text-ink-3">{m.derived.rankings}</p> : null}193            <p className="text-sm">194              <Link href={routes.rankings()} className="text-accent hover:underline">195                {t('nav.rankings')} →196              </Link>197            </p>198          </Prose>199200          <Prose id="changes" title={t('method.changes')}>201            <p>{t('method.changes.text')}</p>202            {m ? <p className="text-sm text-ink-3">{m.derived.changes}</p> : null}203            <p className="text-sm">204              <Link href={routes.changes()} className="text-accent hover:underline">205                {t('changes.title')} →206              </Link>207            </p>208          </Prose>209210          <Prose id="similarity" title={t('method.similarity')}>211            <p>{t('method.similarity.text')}</p>212            {m ? (213              <>214                <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.similarity.modes')}</h3>215                <dl className="space-y-1.5 text-sm">216                  {Object.entries(m.derived.similarity.modes).map(([k, v]) => (217                    <div key={k} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2">218                      <dt className="font-medium text-ink">{tOpt(`country.similar.mode.${k}`, k)}</dt>219                      <dd className="text-ink-2">{v}</dd>220                    </div>221                  ))}222                </dl>223                <p className="text-sm text-ink-3">{m.derived.similarity.method}</p>224              </>225            ) : null}226          </Prose>227228          <Prose id="insights" title={t('method.insights')}>229            <p>{t('method.insights.text')}</p>230            {m ? <p className="text-sm text-ink-3">{m.derived.insights}</p> : null}231          </Prose>232233          <Prose id="dna" title={t('method.dna')}>234            <p>{t('method.dna.text')}</p>235            {m ? (236              <ul className="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">237                {m.derived.country_dna.dimensions.map((d) => (238                  <li key={d.id} className="grid grid-cols-[8rem_minmax(0,1fr)] gap-2">239                    <span className="font-medium text-ink">{d.label}</span>240                    <span className="min-w-0">241                      {d.indicators.map((s, i) => (242                        <span key={s}>243                          {i > 0 ? ', ' : ''}244                          <Link href={routes.indicator(s)} className="text-accent hover:underline">245                            {s}246                          </Link>247                        </span>248                      ))}249                    </span>250                  </li>251                ))}252              </ul>253            ) : null}254            {m ? <p className="text-sm text-ink-3">{m.derived.world_aggregates}</p> : null}255          </Prose>256257          <Prose id="limitations" title={t('method.limitations')}>258            <p>{t('method.limitations.text')}</p>259          </Prose>260261          <Prose id="versioning" title={t('method.versioning')}>262            <p>{t('method.versioning.text')}</p>263          </Prose>264265          <Prose id="licence" title={t('method.licence')}>266            <p>{t('method.licence.text')}</p>267            {m ? (268              <>269                <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.formatting')}</h3>270                <Kv rows={Object.entries(m.formatting).map(([k, v]) => [k, v] as [string, ReactNode])} />271              </>272            ) : null}273            <p className="text-sm">274              <Link href={routes.data()} className="text-accent hover:underline">275                {t('data.title')} →276              </Link>277            </p>278          </Prose>279        </article>280      </div>281    </>282  );283}284