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)
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
mldfrom the gateway M1M32 (manifestdeploy/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 towww(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):
- The pipeline builds a brand-new file
~/countryatlas-data/build/atlas-<run_id>.duckdb. - Derived tables are computed inside it (latest, rankings, changes, events, similarity, insights, coverage).
- Integrity checks pass →
os.replace()it onto~/countryatlas-data/atlas.duckdb(atomic rename) and copy tosnapshots/atlas-<run_id>.duckdb(keep last 7). - The API opens the DB read-only; on every request it compares the file's
st_ino/st_mtimewith 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)
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 byscripts/build_country_registry.pyfrom 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 ingroups.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, plusheadlineindicators 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
slugis kebab-case, stable, and is the URL:/indicators/gdp-per-capita.unitis human (current US$,% of GDP,years,people,% of population),unit_shortfor axes (US$,%,yrs).formatdrives 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_betteris 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_eligiblefalse for series that make no sense to rank (exchange rate, policy rate in local terms, indexes).- Period →
periodis the first day: annualYYYY-01-01, quarterlyYYYY-{01,04,07,10}-01, monthlyYYYY-MM-01. - Forecast observations (IMF WEO projections) are stored with
is_forecast=trueand never enterlatest,rankings,changesorevents; 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)
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):
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 checksError 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
nonnegativeindicators, percentages outside [-5, 105] forpercent_shareindicators, life expectancy outside [20, 100], etc. (bounds in registrybounds: [min, max]) → rowquarantined - unit change vs registry unit → dataset quarantined
- extreme jump: |Δ| >
jump_threshold× robust std of the country series (MAD) → rowwarning(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 thanstale_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 flaggedrank_year != max_yearin the API response). - rankings: for every
ranking_eligibleindicator 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_runsholds the latest attempt per spec (not the full history);import_runs.datasetis"<dataset>:<code>→<indicator>";rows_rawis 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 inmetadata.merge_reason("priority"|"fresher");meta/build counts reportseries_fresher_source. Consequence: forecast rows appear inobservationsonly when the chosen source itself publishes them (IMF-only indicators, or IMF chosen as fresher); the complete IMF series (history + forecasts) is always available inobservations_altfor 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_dnaandcoverageuse only observations whosefrequencyequals the indicator's registryfrequency(inflation A, policy-rate M, real-house-price-index Q…). Higher-frequency series (e.g. FRED monthly CPI for the USA) stay inobservationsfor the series/chart endpoints only. Integrity emits warnings when alatestrow 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 flaggedstale. 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 carrywarning. latestranks are computed among allkind='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 thanlatest.yearwhen 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_incomeis 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" whenhigher_is_betteris set, "highest" when it is null.rankings.pct_rank = 1 − (rank − 1)/(n − 1)(1.0 = rank 1).rankingsincludes every year with ≥ 20 countries forranking_eligibleindicators. - 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_floorin 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 defineschange_floor(real-world magnitude dominates), else0.6·z-part + 0.4·min(1,|Δ|/(2·floor)); records0.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).changesare 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 inevents. Record/N-year detectors are skipped for series that are monotone over their whole history, and indicators taggedcumulative(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;changesadd 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 withformat: indexorranking_eligible: falseonly report YoY moves with |Δ%| ≥ 10 %;eventsdrop 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_atfor 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 uselastUpdatedfrom their metadata. - Similarity: features/weights/transforms in
registry/similarity.yaml; z-scores fromlatest(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_dnadimensions are defined in thedna:section of the same file (percentile rank 0–100,invertfor fertility);year_ref= max year of the inputs. - Insights:
registry/insights.yaml(16 templates, kindschange_since/rank_in_group/vs_median/avg_growth), English text,valuesJSON keeps the raw numbers.insights.id = sha1(country|template_id)[:16]. - Forecasts: OWID grapher charts with a
*projected*column (UN WPP) contributeis_forecast=truerows 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, codesGOV_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_statusE/F → estimate/forecast. - Integrity: the headline-coverage check (≥ 100 countries in
latest) applies only to headline indicators that have staging data;--no-strictturns integrity failures into warnings (stored inmeta.integrity_warnings). metakeys: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_issuesare capped at 2 000 rows per rule per spec (5 000 per spec file) to stay small; counts are exact inimport_runs.warnings/errors.ca sql "<query>"was added to the CLI (read-only DuckDB);ca normalizere-reads the newest raw files per spec.- Change detection 2.0 (2026-09-12): three more
kindvalues inchanges/events—structural_break,trend_reversal,volatility_spike— computed withcountryatlas.stats(docs/PIPELINE.md §Build step 6). Same row shape;detailrecords the parameters (gain,shift_ratio,run,ratio…). Similaritycontributions[feature]gainvalue_a/value_b(raw values). - Reliability (2026-09-12): validation adds
impossible_year(row quarantined),schema_change(dataset quarantined),null_spikeandvintage_shift(warnings vs the previous staging file); the build refuses to publish a snapshot with < 90 % of the previous snapshot's observations, recordsmeta.previous_observation_countandmeta.source_health, and writeslogs/build-<run_id>.json. HTTP retries are deterministic (2/4/8/16 s, 5 attempts,Retry-Afterhonoured).
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:
{"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):
/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/*(Nextrewrites). - 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 incomponents/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 incomponents/explorer/map-canvas.tsx), DNA radial (reference polygon). Shared controls incomponents/controls/(YearSlider with play, IndicatorSelect, Segmented); URL state vialib/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..7colour increase/decrease;--up/--down(green/red) are used only when an indicator declareshigher_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).