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%
31.4 KB

# CountryAtlas — Architecture (contract for all contributors)

www.countryatlas.co — Understand the world, one country at a time. The country is the primary unit of navigation. Every visible number is traceable to its source.

This document is the binding contract between the data platform (Python), the API (FastAPI) and the web app (Next.js). Change it first, then the code.

# 1. Deployment topology (MacLustr cluster)

text
Internet ─ DNS A www.countryatlas.co → 51.161.112.61 (BHS64, OVH Beauharnois)
        └─ Caddy (TLS auto) ─ WireGuard wg1 ─→ M2M32 (Mac Studio M2, 12c/32GB)  10.67.0.x
                                                 ├─ countryatlas-web        Next.js 16   :8290  (public upstream)
                                                 ├─ countryatlas-api        FastAPI      :8291  (loopback only; reached via Next rewrites /api/v1/*)
                                                 └─ countryatlas-scheduler  Python loop  (daily refresh, writes snapshots)
                                                 ~/countryatlas-data/  (raw/, staging/, build/, atlas.duckdb, exports/, logs/)
  • Orchestrated by mld from the gateway M1M32 (manifest deploy/countryatlas.mld.json, PM2 processes).
  • No database port is ever public. The API listens on 127.0.0.1:8291 only.
  • The web app listens on 0.0.0.0:8290 (reached through the WireGuard tunnel only; the Mac has no public IP).
  • Apex countryatlas.co → 301 to www (Caddy redirect on BHS64; requires an A record for the apex).

# 2. Storage: DuckDB snapshots (no Postgres/ClickHouse/Redis for the MVP)

Volumes: ~220 countries × ~250 indicators × ≤65 years ≈ 3–4 M observations. DuckDB handles this in milliseconds, exports Parquet/CSV natively and needs zero operations. The design keeps the door open to Postgres/ClickHouse later (same logical schema).

Snapshot-swap rule (critical):

  1. The pipeline builds a brand-new file ~/countryatlas-data/build/atlas-<run_id>.duckdb.
  2. Derived tables are computed inside it (latest, rankings, changes, events, similarity, insights, coverage).
  3. Integrity checks pass → os.replace() it onto ~/countryatlas-data/atlas.duckdb (atomic rename) and copy to snapshots/atlas-<run_id>.duckdb (keep last 7).
  4. The API opens the DB read-only; on every request it compares the file's st_ino/st_mtime with the open one and reopens when they differ. Readers never block the writer and vice-versa.

Raw source payloads are kept forever (gzip JSON/CSV) under raw/<connector>/<dataset>/<YYYY-MM-DD>/…. Nothing is ever fetched without also being stored raw.

# 2.1 Logical schema (DuckDB)

sql
countries(id TEXT PK /* = iso3 */, iso2, iso3, iso_numeric, slug, short_name, official_name, capital,
          continent, region_wb /* WB region id */, region_wb_name, subregion, income_group /* HIC/UMC/LMC/LIC */,
          currency_code, currency_name, area_km2 DOUBLE, latitude, longitude, flag_emoji,
          un_member BOOLEAN, status TEXT /* country|territory|historical */, independent BOOLEAN,
          landlocked BOOLEAN, borders TEXT[] , languages TEXT[], demonym, kind TEXT /* country|aggregate */)
groups(id TEXT PK /* world|oecd|g7|g20|eu|brics|nac|ecs|hic|... */, slug, name, kind /* world|region|income|org|custom */,
       description, wb_code /* WLD, OED, EUU, HIC ... when the source has an aggregate */)
group_members(group_id, country_id)
sources(id TEXT PK /* worldbank|imf|oecd|eurostat|who|fred|owid|bis|ilo */, name, organization, url, licence,
        attribution, api_base, last_success_at, notes)
indicators(id TEXT PK /* = slug */, slug, name, short_name, description, topic, subtopic, unit, unit_short,
           frequency /* A|Q|M */, precision INT, aggregation /* sum|mean|weighted_mean|none */,
           higher_is_better BOOLEAN NULL, ranking_eligible BOOLEAN, per_capita_of TEXT NULL,
           format TEXT /* number|percent|currency|index|years|per_1000|... */, scale TEXT /* raw|thousands|millions|billions */,
           source_priority TEXT[] , methodology TEXT, tags TEXT[], featured BOOLEAN)
indicator_sources(indicator_id, source_id, dataset, series_code, params JSON, priority INT, transform TEXT NULL, notes)
observations(country_id, indicator_id, period DATE /* first day of period */, year INT, frequency,
             value DOUBLE, unit, source_id, source_dataset, source_series_code,
             is_estimate BOOL, is_forecast BOOL, revision INT, retrieved_at TIMESTAMP, source_updated_at TIMESTAMP,
             status TEXT /* verified|imported|warning|stale|quarantined */, metadata JSON)
             -- PK (country_id, indicator_id, period, frequency). Exactly ONE source per WHOLE SERIES (country, indicator,
             -- frequency): the highest-priority source having non-forecast data for that country, unless a lower-priority
             -- source is > 3 years fresher (metadata.merge_reason = "priority" | "fresher"). Sources are never spliced
             -- inside a series. Every other source's complete series is kept in observations_alt.
observations_alt(same columns)      -- complete alternative series (other sources), for provenance / alternative views
observation_revisions(country_id, indicator_id, period, frequency, old_value, new_value, old_source_id, new_source_id,
             changed_at TIMESTAMP, run_id)   -- never silently overwrite: carry forward from the previous snapshot
latest(country_id, indicator_id, period, year, value, prev_period, prev_value, change_abs, change_pct,
       rank_world INT, n_world INT, rank_region INT, n_region INT, rank_income INT, n_income INT,
       source_id, is_forecast, is_estimate, status)       -- latest NON-forecast observation per country×indicator
rankings(indicator_id, year, country_id, value, rank INT, n INT, pct_rank DOUBLE)   -- countries only (kind='country'), non-forecast
changes(id, country_id, indicator_id, kind /* yoy_drop|yoy_jump|record_high|record_low|n_year_high|n_year_low|sign_flip|accelerating|decelerating */,
        period, year, value, ref_value, delta, delta_pct, window_years INT, severity DOUBLE /* 0-1 */, headline TEXT, detail JSON, detected_at)
        -- "What changed" = recent (latest period) ; events = whole history
events(id, country_id, indicator_id, kind, period, year, value, ref_value, delta, delta_pct, severity, headline, detail JSON)
similarity(country_id, mode /* overall|economic|demographic|energy|social */, peer_id, score DOUBLE /* 0-100 */, rank INT,
           contributions JSON /* {indicator: {z_a, z_b, weight, contribution}} */)
insights(id, country_id, template_id, text, values JSON, indicators TEXT[], computed_at)
country_dna(country_id, dims JSON /* {income:0-100, demographics:..., urbanization, trade, energy, emissions, innovation, education, public_spending} */, year_ref)
coverage(country_id, n_indicators INT, n_observations INT, latest_year INT, coverage_pct DOUBLE, updated_at)
import_runs(run_id, connector, dataset, started_at, finished_at, status /* ok|failed|partial */, rows_raw, rows_norm, rows_valid,
            warnings INT, errors INT, message, raw_path)
validation_issues(run_id, connector, indicator_id, country_id, period, severity /* info|warning|error */, code, message)
meta(key, value)      -- build_run_id, built_at, schema_version, indicator_count, observation_count, ...

# 3. Registries (YAML, versioned in git, loaded by both pipeline and API)

  • registry/countries.yaml — canonical registry generated by scripts/build_country_registry.py from the World Bank country list (region, income group, capital, lat/long) merged with the mledoze/countries dataset (ISO numeric, official name, subregion, currency, area, UN membership, flag, borders, languages). Hand-edited fields survive regeneration (overrides: block). Aggregates (WLD, OED, EUU, HIC …) are not countries; they live in groups.yaml.
  • registry/groups.yaml — World, WB regions (7), income groups (4), organisations (OECD, EU, G7, G20, BRICS, ASEAN, African Union, Eurozone, Nordic, NAFTA/USMCA, Commonwealth…) with explicit member lists (ISO3).
  • registry/indicators.yaml — the canonical indicator registry (see §4). Every indicator maps to one or more external series (connector + code + params + priority).
  • registry/topics.yaml — topics (economy, government, population, …) with order, blurb, icon, and the ordered list of indicator slugs to show on the country topic page, plus headline indicators for the country overview.

Rule: the code never identifies a country by its name. Always iso3 (id) or slug. URLs use slugs (/countries/canada); the API accepts both iso3 and slug.

# 4. Indicator conventions

  • slug is kebab-case, stable, and is the URL: /indicators/gdp-per-capita.
  • unit is human (current US$, % of GDP, years, people, % of population), unit_short for axes (US$, %, yrs).
  • format drives display: currency (scale automatically: 1.2T / 45.3B), percent (1 decimal), number (thousand separators, scale for large), years, index, per_1000, per_100k, ratio, celsius, tonnes.
  • higher_is_better is only set when it is unambiguous (life expectancy = true, infant mortality = false). Otherwise null (inflation, population, exchange rate) — rankings then sort descending by value and say "highest".
  • ranking_eligible false for series that make no sense to rank (exchange rate, policy rate in local terms, indexes).
  • Period → period is the first day: annual YYYY-01-01, quarterly YYYY-{01,04,07,10}-01, monthly YYYY-MM-01.
  • Forecast observations (IMF WEO projections) are stored with is_forecast=true and never enter latest, rankings, changes or events; they are shown dashed on charts.
  • A "topic" belongs to the fixed set: economy, government, population, labor, income, housing, health, education, trade, energy, climate, environment, infrastructure, digital, innovation, agriculture, tourism, security, quality-of-life.

# 5. Pipeline (ca CLI, package countryatlas)

text
ca registry validate                # YAML sanity (unique slugs, known connectors, topics)
ca fetch [--connector X] [--indicator Y]   # raw payloads → raw/…  (retries, backoff, rate limit, ETag/If-Modified-Since when available)
ca normalize [--connector X]        # raw → staging/<connector>/<dataset>.parquet in the NormalizedObservation schema
ca validate                         # rules (§6) → statuses + validation_issues
ca build                            # merge staging by source priority → new DuckDB → derived tables → integrity → atomic swap
ca refresh [--connector X]          # fetch + normalize + validate + build (idempotent; safe to run any time)
ca schedule                         # long-running loop: refresh daily at 03:15 America/Toronto; also refresh on SIGUSR1
ca status                           # last runs, row counts, freshness, stale sources
ca export indicator <slug> | country <iso3>   # CSV/JSON/Parquet into exports/ (also served by the API on demand)

Connector interface (countryatlas/connectors/base.py):

python
class Connector(Protocol):
    id: str                                   # "worldbank"
    def discover(self) -> list[DatasetDescriptor]
    def fetch(self, spec: IndicatorSourceSpec, ctx: FetchContext) -> RawPayload      # one HTTP "unit" per spec; stored raw
    def normalize(self, raw: RawPayload, spec: IndicatorSourceSpec) -> list[NormalizedObservation]
    def validate(self, rows: list[NormalizedObservation]) -> ValidationReport          # source-specific checks

Error isolation: each (connector, dataset) runs in its own try/except and its own import_runs row. A failed fetch never removes previously good data: build merges the newest successful staging file per dataset. A connector that fails validation with error severity is quarantined (its staging file is ignored and the previous one kept).

# 6. Validation rules (deterministic)

  • duplicates (same key twice in one dataset) → error (dataset quarantined)
  • impossible values: negative for nonnegative indicators, percentages outside [-5, 105] for percent_share indicators, life expectancy outside [20, 100], etc. (bounds in registry bounds: [min, max]) → row quarantined
  • unit change vs registry unit → dataset quarantined
  • extreme jump: |Δ| > jump_threshold × robust std of the country series (MAD) → row warning (kept, flagged)
  • partial download: rows < 30 % of the previous run for the same dataset → dataset quarantined (previous kept)
  • stale: source_updated_at / latest period older than stale_after_days (annual: 800 d, quarterly: 200 d, monthly: 75 d) → stale
  • mapping errors: unknown country code → logged, row dropped (aggregates are dropped unless mapped to a group)

Unusual values are never deleted, only flagged.

# 7. Derived computations

  • latest: last non-forecast observation per country×indicator; previous = previous period of the same frequency; ranks among kind='country' for the same year (if a country's latest year is older than the global max year by > 2, its rank is still computed within that year but flagged rank_year != max_year in the API response).
  • rankings: for every ranking_eligible indicator and every year with ≥ 20 countries.
  • changes / events: per country×indicator series (annual or higher), deterministic detectors: YoY change beyond ±(2 × MAD of yearly diffs) and beyond an indicator-specific absolute floor (e.g. inflation ±2 pts, unemployment ±1 pt, GDP growth ±3 pts, population growth ±0.5 pt), record high/low over the whole series, N-year high/low (N ∈ {10, 20, 30}), sign flip (growth → contraction), acceleration (3 consecutive increases of the diff). severity = min(1, |z| / 4) blended with the floor ratio. Headlines are template strings with computed numbers — no LLM.
  • similarity: features per mode (see registry/similarity.yaml): log-transform heavy-tailed features (GDP pc, population, area), z-score across countries (latest values, only countries with ≥ 70 % of the mode's features), weighted Euclidean distance → score = 100 × exp(−d / d₀); store the top 12 peers with per-feature contributions (explainable).
  • insights: templates in registry/insights.yaml (e.g. "{country}'s population grew {pct}% since {y0}."), each fully computed from data; numbers are never generated by a model.
  • country_dna: 9 dimensions in [0, 100] = percentile rank of the country for a representative indicator (or the mean of 2-3 indicators) among all countries: income (GDP pc PPP), demographics (median age ↔ fertility), urbanization, trade (trade % GDP), energy (energy use pc), emissions (CO₂ pc), innovation (R&D % GDP, patents pc), education (tertiary enrolment, expected years), public spending (gov. expenditure % GDP). Descriptive, not a score.

# 7.1 Pipeline implementation notes and deviations (as built, 2026-09-11 — see docs/PIPELINE.md)

No table or column of schema.sql was changed. The following precisions/deviations from §2, §5–§7 are binding for the API:

  • Staging granularity is one parquet per source spec (connector, dataset, code, indicator) — staging/<connector>/<dataset>__<code>__<indicator>.parquet — not one per dataset. Error isolation, quarantine and the "keep the previous file" rule apply per spec. import_runs holds the latest attempt per spec (not the full history); import_runs.dataset is "<dataset>:<code>→<indicator>"; rows_raw is the raw payload size in bytes.
  • Series-level source selection (build.py::_merge_observations): for each (country_id, indicator_id, frequency) the whole series comes from one source — the highest-priority source with any non-forecast data for that country; if a lower-priority source's latest non-forecast year is more than 3 years more recent, the freshest source wins instead. The decision is stored per row in metadata.merge_reason ("priority" | "fresher"); meta/build counts report series_fresher_source. Consequence: forecast rows appear in observations only when the chosen source itself publishes them (IMF-only indicators, or IMF chosen as fresher); the complete IMF series (history + forecasts) is always available in observations_alt for alternative views. Series with forecast-only data fall back to plain priority.
  • Canonical frequency only in derived tables: latest, rankings, changes, events, similarity, insights, country_dna and coverage use only observations whose frequency equals the indicator's registry frequency (inflation A, policy-rate M, real-house-price-index Q…). Higher-frequency series (e.g. FRED monthly CPI for the USA) stay in observations for the series/chart endpoints only. Integrity emits warnings when a latest row has a non-canonical frequency or a headline indicator is ranked in a pool of fewer than 50 countries.
  • Quarantined rows stay in observations (never deleted) but are excluded from every derived table (latest, rankings, changes, events, similarity, insights, country_dna). A lower-priority source is not promoted when a row is quarantined (the value is flagged, not replaced). observations_alt = every row of every non-chosen source, whatever its status.
  • Stale is evaluated on the end of the period (annual 2024 → 2024-12-31) of each country's latest observation, not on source_updated_at (the WDI vintage date says nothing about a country whose series stops in 2019); only that latest row is flagged stale. With 800 days, an annual series ending in 2023 is stale in September 2026, one ending in 2024 is not.
  • Extreme jump: positive level series (formats currency/number/tonnes/kwh/per_*/km/ha with lower bound ≥ 0) are compared on log-differences; others on absolute differences. Threshold = jump_threshold × 1.4826·MAD with a floor (10 % relative, or 2 % of the country's series range) to avoid flagging smooth series; ≥ 5 points required. ~5 % of WDI rows carry warning.
  • latest ranks are computed among all kind='country' values of the same indicator within one year: the most recent year ≤ the country's latest year in which the country has a value AND at least 20 countries are ranked (MIN_COUNTRIES_FOR_RANKING). latest.rank_year = that year — it may be older than latest.year when only a handful of countries already report the newest year (e.g. CHN internet-users: value 2025, ranks from 2024); ranks are NULL when no such year exists. Q/M series use the last period of each year. rank_income is NULL when the country has no income group. change_10y_* compares with the observation exactly 10 years earlier (same frequency).
  • Ranking direction: rank 1 = lowest value when higher_is_better = false, otherwise the highest value — i.e. "best" when higher_is_better is set, "highest" when it is null. rankings.pct_rank = 1 − (rank − 1)/(n − 1) (1.0 = rank 1). rankings includes every year with ≥ 20 countries for ranking_eligible indicators.
  • Changes / events: working scale = points for percent-like indicators, log-differences (reported as % change) for positive level series, absolute otherwise. z = (Δ − median)/max(1.4826·MAD, 0.25 × floor); a move must also clear the floor (change_floor in the indicator's unit; default 5 % relative, or 2 % of the series range with a 0.5-point minimum for shares/rates). YoY severity = 0.3·min(1,|z|/4) + 0.7·min(1,|Δ|/(3·floor)) when the registry defines change_floor (real-world magnitude dominates), else 0.6·z-part + 0.4·min(1,|Δ|/(2·floor)); records 0.5 + 0.25·min(1,n/100) + 0.25·(exceedance in floor units); N-year highs/lows 0.4–0.6; sign flips 0.7; acceleration 0.4. The stored severity is multiplied by an importance weight (headline indicators ×1.0, featured ×0.9, others ×0.7; detail.weight, detail.raw_severity). changes are recent by construction: a detection is kept only if the series' latest year is within 2 years of the indicator's max year in the snapshot AND within 3 years of today; older detections exist only in events. Record/N-year detectors are skipped for series that are monotone over their whole history, and indicators tagged cumulative (e.g. cumulative-co2) run no detector at all. events (whole history) contain YoY jumps/drops, records reached after a ≥ 5-year gap since the previous record and sign flips, at most 30 per series; changes add N-year highs/lows (N ∈ {10, 20, 30}) and acceleration/deceleration (3 consecutive increases/decreases of the difference) at the latest period only. id = sha1("changes|"+country+indicator+kind+period)[:16] ("events|" for events). Headlines are English templates. Noise rules (changes and events): Q/M series are annualised before detection (last value of the calendar year for indices/shares/rates/ratios, calendar-year mean otherwise; period = Jan 1 of that year) so an indicator yields at most one detection per year; indicators with format: index or ranking_eligible: false only report YoY moves with |Δ%| ≥ 10 %; events drop rows with severity < 0.35 and keep at most the 3 most severe per (country, indicator, year).
  • OWID freshness: raw.githubusercontent.com sends no Last-Modified; source_updated_at for the co2/energy files is the date of the last GitHub commit touching the file (api.github.com/repos/owid/<repo>/commits?path=…), grapher charts use lastUpdated from their metadata.
  • Similarity: features/weights/transforms in registry/similarity.yaml; z-scores from latest (mixed years allowed); a pair needs ≥ 50 % of the mode's weight in common (distance rescaled to full weight); d0 = median pairwise distance of the mode; contributions[feature].contribution = share of the squared distance. country_dna dimensions are defined in the dna: section of the same file (percentile rank 0–100, invert for fertility); year_ref = max year of the inputs.
  • Insights: registry/insights.yaml (16 templates, kinds change_since / rank_in_group / vs_median / avg_growth), English text, values JSON keeps the raw numbers. insights.id = sha1(country|template_id)[:16].
  • Forecasts: OWID grapher charts with a *projected* column (UN WPP) contribute is_forecast=true rows for years without an estimate, capped at current year + 6.
  • World Bank: source=<id> is added automatically when the indicator metadata says the series lives outside WDI (e.g. WGI = source 3, codes GOV_WGI_*); codes moved to "WDI Database Archives" (source 57) fail with a RETIRED hint and must be remapped in the registry. lastupdated (WDI vintage) → source_updated_at; obs_status E/F → estimate/forecast.
  • Integrity: the headline-coverage check (≥ 100 countries in latest) applies only to headline indicators that have staging data; --no-strict turns integrity failures into warnings (stored in meta.integrity_warnings).
  • meta keys: schema_version, build_run_id, built_at, observation_count, observations_alt_count, indicator_count, country_count, latest_count, rankings_count, changes_count, events_count, insights_count, similarity_count, staging_files, connectors, build_duration_s, integrity_warnings.
  • validation_issues are capped at 2 000 rows per rule per spec (5 000 per spec file) to stay small; counts are exact in import_runs.warnings/errors.
  • ca sql "<query>" was added to the CLI (read-only DuckDB); ca normalize re-reads the newest raw files per spec.
  • Change detection 2.0 (2026-09-12): three more kind values in changes/events — structural_break, trend_reversal, volatility_spike — computed with countryatlas.stats (docs/PIPELINE.md §Build step 6). Same row shape; detail records the parameters (gain, shift_ratio, run, ratio…). Similarity contributions[feature] gain value_a / value_b (raw values).
  • Reliability (2026-09-12): validation adds impossible_year (row quarantined), schema_change (dataset quarantined), null_spike and vintage_shift (warnings vs the previous staging file); the build refuses to publish a snapshot with < 90 % of the previous snapshot's observations, records meta.previous_observation_count and meta.source_health, and writes logs/build-<run_id>.json. HTTP retries are deterministic (2/4/8/16 s, 5 attempts, Retry-After honoured).

# 8. API (FastAPI, /api/v1, OpenAPI at /api/v1/openapi.json, docs at /api/v1/docs)

Every response carries meta: {built_at, run_id, generated_at}; every value object carries provenance:

json
{"value": 53372.1, "period": "2024-01-01", "year": 2024, "unit": "current US$", "is_estimate": false, "is_forecast": false,
 "status": "verified",
 "provenance": {"source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD",
               "retrieved_at": "2026-09-11T03:20:11Z", "source_updated_at": "2026-07-01", "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA",
               "transform": null, "licence": "CC BY 4.0"}}

Endpoints (all GET, JSON, cached in-process with the snapshot run_id as cache key):

text
/health                                   {status, run_id, built_at, observations}
/countries?region=&income=&q=             list (id, slug, name, flag, region, income, population_latest, gdp_latest, coverage_pct)
/countries/{id}                           header + headline metrics (topics.yaml `headline`) with latest+rank+change
/countries/{id}/topics/{topic}            all indicators of the topic: latest + sparkline (last 30 pts) + rank
/countries/{id}/series/{indicator}?from=&to=&freq=     full history (values[], with per-observation provenance summary)
/countries/{id}/changes                   recent changes (sorted by severity)
/countries/{id}/events?limit=             timeline
/countries/{id}/similar?mode=overall      peers with contributions
/countries/{id}/insights
/countries/{id}/dna
/countries/{id}/download.{csv|json}       full country dataset
/indicators?topic=&q=                     list
/indicators/{slug}                        definition + sources + coverage + world/regions latest + freshness
/indicators/{slug}/map?year=              {year, values: {ISO3: value}, legend: {min,max,breaks[]}, n}
/indicators/{slug}/trend?group=world      median/aggregate trend (WB aggregate when available)
/indicators/{slug}/download.{csv|json}
/series?country=CAN,FRA&indicator=gdp-per-capita&from=1990&to=2026&freq=A
/rankings                                 list of rankable indicators (featured first)
/rankings/{indicator}?year=&group=&sort=asc|desc&limit=&offset=     rows: rank, country, value, change_1y, change_10y, sparkline
/rankings/{indicator}/history?countries=CAN,USA    rank by year
/compare?countries=CAN,USA,FRA&indicators=…&from=&to=&mode=absolute|per-capita|index100|pct      series bundle
/compare/snapshot?countries=…&topic=      table of latest values for the topic's indicators
/regions | /regions/{slug}                group page data (members, aggregate metrics, member rankings)
/search?q=&limit=                         [{type: country|indicator|topic|region|source, id, slug, name, hint, score}]
/home                                     global snapshot + curated lists (largest economies, fastest growth, …) + recent changes + recently updated
/changes?limit=&kind=                     global recent changes feed
/sources | /sources/{id}                  source metadata + datasets + import runs + freshness
/methodology                              static from registry (units, priorities, validation rules) — for the page
/admin/*   (header X-Admin-Token)         connectors health, import_runs, validation_issues, coverage matrix, trigger refresh (SIGUSR1)

Errors: RFC 7807 problem+json. Unknown country/indicator → 404. Rate limit 120 req/min/IP (in-process token bucket; requests that carry X-CountryAtlas-Internal: <CA_ADMIN_TOKEN> or arrive from loopback without any forwarding header — server-side renders, the build prerender, the pipeline — are exempt). Public GET responses carry a weak ETag derived from the snapshot run id (304 on If-None-Match) and Cache-Control: public, max-age=300, stale-while-revalidate=3600. API 1.1 analytics endpoints: see docs/API.md.

# 9. Web app (Next.js 16, React 19, TypeScript, Tailwind v4, App Router)

  • Server components fetch the API at process.env.API_URL (http://127.0.0.1:8291). Browser-side fetches go to the same origin /api/v1/* (Next rewrites).
  • Routes (2.0, 2026-09-12): / (hero map + time machine, world pulse, movers), /explore (World Explorer, full viewport), /trajectories, /scatter, /finder, /extremes, /peers, /countries, /countries/[slug] (story, timeline 2.0, DNA reference, similar 2.0), /countries/[slug]/[topic], /compare, /compare/[...slugs] (head-to-head for two countries, percentile / change modes), /rankings, /rankings/[indicator] (filters, table/bars/map, rank race), /indicators, /indicators/[slug] (frames map, distribution, related), /regions, /regions/[slug], /regions/compare, /changes, /stories, /stories/[slug], /download (/data → 308), /updates, /sources, /sources/[id], /methodology, /api (interactive explorer), /admin, /sitemap/<shard>.xml, /robots.txt, OG images per country / indicator / ranking / compare. Full-bleed routes (/explore, /trajectories) are declared in components/layout/main-frame.tsx.
  • Charts: our own SVG chart kit in apps/web/src/components/charts/ (server-renderable, touch, accessible summaries): LineChart, AreaChart, StackedArea, RankedBars, Scatter, BubbleChart (animated, region colours, fit line, trails), Histogram (markers), RankRace, Sparkline, SlopeChart, SmallMultiples, PopulationPyramid, Choropleth (d3-geo + world-atlas 110m, SVG; zoom/pan canvas in components/explorer/map-canvas.tsx), DNA radial (reference polygon). Shared controls in components/controls/ (YearSlider with play, IndicatorSelect, Segmented); URL state via lib/url-state.ts (client) / lib/url-params.ts (server-safe).
  • i18n: all UI strings through apps/web/src/i18n/en.ts (t('key')), no hard-coded English in components.
  • Design tokens in apps/web/src/app/globals.css (@theme). Editorial, calm, data-dense. No card soup.
  • Navigation: Explore · Countries · Compare · Rankings · Indicators · Changes · More ▾ (Regions, Trajectories, Scatter, Finder, Extremes, Above/below expected, Stories, Sources, API, Downloads, Data updates, Methodology). Mobile tab bar: Explore · Countries · Compare · Rank · Search; bottom sheets for filters and map details; no horizontal overflow at 320–1920 px (qa/final-sweep.mjs).
  • Direction semantics: --inc/--dec (teal/sienna) and the diverging ramp --div-1..7 colour increase/decrease; --up/--down (green/red) are used only when an indicator declares higher_is_better (ChangeChip). Quality badges: fresh · historical · sparse · limited-coverage · stale · flagged · forecast (components/data/quality-badge.tsx, vocabulary shared with the API).
  • Provenance drawer: any value component (<Metric>, chart point) opens <ProvenanceSheet> with the provenance object.

# 10. Ports & env

Process Port Env
countryatlas-web 8290 API_URL=http://127.0.0.1:8291, NEXT_PUBLIC_SITE_URL=https://www.countryatlas.co
countryatlas-api 8291 (127.0.0.1) CA_DATA_DIR=~/countryatlas-data, CA_ADMIN_TOKEN, FRED_API_KEY
countryatlas-scheduler — same as API

Secrets (FRED key, admin token) live only in the mld manifest on M1M32 and in local .env (git-ignored).