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%

Pipeline core (fetch/normalize/validate/build/derived/scheduler), FastAPI API, WGI remap, UTC coverage timestamp

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent b828cc8

26 changed files +1,701 −43

added apps/web/qa/mock_api.py +442 −0
@@ -0,0 +1,442 @@
1 +"""QA-only fixture API mirroring src/countryatlas/api/schemas.py shapes.
2 +
3 +Serves deterministic SYNTHETIC values (seeded per country×indicator) from the registries so the web app can be
4 +rendered and screenshotted before the real snapshot exists. Every provenance object says "Fixture (mock data)".
5 +NEVER point production at this. Run: cd <repo> && .venv/bin/python apps/web/qa/mock_api.py (port 8299)
6 +"""
7 +from __future__ import annotations
8 +
9 +import math
10 +import random
11 +from datetime import datetime, timezone
12 +from pathlib import Path
13 +from typing import Any
14 +
15 +import yaml
16 +from fastapi import FastAPI, Query
17 +from fastapi.responses import JSONResponse
18 +
19 +ROOT = Path(__file__).resolve().parents[3]
20 +REG = ROOT / "registry"
21 +countries = [c for c in yaml.safe_load((REG / "countries.yaml").read_text())["countries"] if c.get("kind", "country") != "aggregate"]
22 +indicators = yaml.safe_load((REG / "indicators.yaml").read_text())["indicators"]
23 +topics = yaml.safe_load((REG / "topics.yaml").read_text())
24 +groups = yaml.safe_load((REG / "groups.yaml").read_text())["groups"]
25 +BY_ID = {c["id"]: c for c in countries}
26 +BY_SLUG = {c["slug"]: c for c in countries}
27 +IND = {i["slug"]: i for i in indicators}
28 +BUILT = "2026-09-11T03:20:11Z"
29 +META = {"built_at": BUILT, "run_id": "fixture-20260911", "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")}
30 +
31 +app = FastAPI(title="CountryAtlas fixture API")
32 +
33 +BASELINE = { # rough scale per format so numbers look plausible
34 + "currency": (2e9, 2e13), "percent": (1, 90), "number": (1e5, 5e7), "years": (52, 84), "index": (60, 140), "per_1000": (2, 40),
35 + "per_100k": (0.5, 40), "per_million": (50, 8000), "ratio": (0.5, 12), "celsius": (0.001, 0.2), "tonnes": (0.1, 25), "kwh": (200, 15000),
36 + "ha": (0.05, 3), "km": (500, 250000),
37 +}
38 +SIZE = {"HIC": 1.0, "UMC": 0.55, "LMC": 0.3, "LIC": 0.15}
39 +
40 +
41 +def rng(*keys: Any) -> random.Random:
42 + return random.Random("|".join(map(str, keys)))
43 +
44 +
45 +def has_data(c: dict, ind: dict) -> bool:
46 + return rng(c["id"], ind["slug"], "has").random() < (0.92 if c.get("status") == "country" else 0.55)
47 +
48 +
49 +def series(c: dict, ind: dict) -> list[dict]:
50 + r = rng(c["id"], ind["slug"])
51 + lo, hi = BASELINE.get(ind.get("format", "number"), (1, 100))
52 + size = SIZE.get(c.get("income_group") or "LMC", 0.3)
53 + fmt = ind.get("format")
54 + if fmt == "currency" and "per-capita" not in ind["slug"]:
55 + base = lo * (1 + 400 * size * r.random()) * (c.get("area_km2") or 1e5) ** 0.25 / 20
56 + elif fmt == "currency":
57 + base = 1500 + 90000 * size * r.random()
58 + elif fmt == "number":
59 + base = lo * (1 + 200 * r.random()) * (c.get("area_km2") or 1e5) ** 0.3 / 30
60 + else:
61 + base = lo + (hi - lo) * (0.2 + 0.7 * r.random()) * (0.6 + 0.6 * size if fmt in ("years", "kwh") else 1)
62 + start = 1990 if r.random() < 0.7 else 2000
63 + end = 2024 if r.random() < 0.85 else 2023
64 + trend = r.uniform(-0.01, 0.035) if fmt not in ("percent", "years") else r.uniform(-0.2, 0.3)
65 + out = []
66 + v = base
67 + for y in range(start, end + 1):
68 + noise = r.gauss(0, 0.03 if fmt != "percent" else 0.5)
69 + v = v * (1 + trend + noise) if fmt not in ("percent", "years", "celsius") else v + trend + noise
70 + v = max(0.001, v) if fmt != "percent" or "balance" not in ind["slug"] else v
71 + out.append({"period": f"{y}-01-01", "year": y, "frequency": "A", "value": round(v, 3), "is_forecast": False, "is_estimate": y >= 2023, "status": "verified", "source_id": "worldbank", "provenance": prov(ind, c)})
72 + if ind["slug"] in ("gdp", "gdp-per-capita", "gdp-growth", "inflation", "unemployment-rate", "government-debt-pct-gdp"):
73 + for y in range(end + 1, end + 4):
74 + v = v * (1 + trend) if fmt not in ("percent",) else v + trend
75 + out.append({"period": f"{y}-01-01", "year": y, "frequency": "A", "value": round(v, 3), "is_forecast": True, "is_estimate": False, "status": "imported", "source_id": "imf", "provenance": prov(ind, c, "imf")})
76 + return out
77 +
78 +
79 +def prov(ind: dict, c: dict | None = None, src: str = "worldbank") -> dict:
80 + s = next((x for x in ind.get("sources", []) if x.get("connector") == src), (ind.get("sources") or [{}])[0])
81 + return {"source": src, "source_name": "Fixture (mock data)", "dataset": s.get("dataset", "WDI"), "series_code": s.get("code", ind["slug"].upper()),
82 + "retrieved_at": BUILT, "source_updated_at": "2026-07-01", "url": f"https://data.worldbank.org/indicator/{s.get('code', '')}" + (f"?locations={c['iso2']}" if c else ""),
83 + "transform": s.get("transform"), "licence": "CC BY 4.0"}
84 +
85 +
86 +def fmt_value(v: float | None, ind: dict) -> str:
87 + if v is None:
88 + return "—"
89 + f = ind.get("format", "number")
90 + if f == "currency":
91 + for d, s in ((1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "k")):
92 + if abs(v) >= d:
93 + return f"US${v / d:.1f}{s}"
94 + return f"US${v:,.0f}"
95 + if f == "percent":
96 + return f"{v:.1f} %"
97 + if f == "years":
98 + return f"{v:.1f} yrs"
99 + if f == "number":
100 + for d, s in ((1e9, "B"), (1e6, "M")):
101 + if abs(v) >= d:
102 + return f"{v / d:.2f}{s}"
103 + return f"{v:,.0f}"
104 + return f"{v:,.{ind.get('precision', 1)}f}"
105 +
106 +
107 +def card(c: dict) -> dict:
108 + return {"id": c["id"], "iso2": c.get("iso2"), "slug": c["slug"], "name": c["short_name"], "flag": c.get("flag_emoji"), "region": c.get("region_wb"),
109 + "region_name": c.get("region_wb_name"), "income": c.get("income_group"), "income_name": c.get("income_group_name"), "kind": c.get("kind", "country")}
110 +
111 +
112 +def icard(ind: dict) -> dict:
113 + return {"id": ind["slug"], "slug": ind["slug"], "name": ind["name"], "short_name": ind.get("short_name", ind["name"]), "topic": ind.get("topic"), "subtopic": ind.get("subtopic"),
114 + "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"), "precision": ind.get("precision", 1), "frequency": ind.get("frequency", "A"),
115 + "aggregation": ind.get("aggregation"), "higher_is_better": ind.get("higher_is_better"), "ranking_eligible": ind.get("ranking_eligible", True), "featured": ind.get("featured", False)}
116 +
117 +
118 +_latest_cache: dict[str, dict[str, dict]] = {}
119 +
120 +
121 +def latest_all(ind_slug: str) -> dict[str, dict]:
122 + """country id → last actual value dict for an indicator (with ranks)."""
123 + if ind_slug in _latest_cache:
124 + return _latest_cache[ind_slug]
125 + ind = IND[ind_slug]
126 + rows = {}
127 + for c in countries:
128 + if not has_data(c, ind):
129 + continue
130 + s = [p for p in series(c, ind) if not p["is_forecast"]]
131 + if len(s) < 2:
132 + continue
133 + rows[c["id"]] = {"last": s[-1], "prev": s[-2], "ten": s[-11] if len(s) > 11 else None, "spark": [[p["year"], p["value"]] for p in s[-30:]]}
134 + ranked = sorted(rows.items(), key=lambda kv: -(kv[1]["last"]["value"]))
135 + if ind.get("higher_is_better") is False:
136 + ranked.reverse()
137 + for i, (cid, r) in enumerate(ranked):
138 + r["rank_world"] = i + 1
139 + r["n_world"] = len(ranked)
140 + region = BY_ID[cid].get("region_wb")
141 + peers = [x for x, _ in ranked if BY_ID[x].get("region_wb") == region]
142 + r["rank_region"] = peers.index(cid) + 1
143 + r["n_region"] = len(peers)
144 + _latest_cache[ind_slug] = rows
145 + return rows
146 +
147 +
148 +def metric(c: dict, ind: dict) -> dict:
149 + row = latest_all(ind["slug"]).get(c["id"])
150 + if not row:
151 + return {"indicator": ind["slug"], "indicator_name": ind.get("short_name", ind["name"]), "has_data": False, "value": None, "formatted": "—", "unit": ind.get("unit"),
152 + "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"), "higher_is_better": ind.get("higher_is_better"), "sparkline": [], "provenance": None,
153 + "period": None, "year": None, "frequency": None, "is_estimate": False, "is_forecast": False, "status": None, "prev": None, "change": None, "change_10y": None,
154 + "rank_world": None, "n_world": None, "rank_region": None, "n_region": None, "rank_income": None, "n_income": None, "rank_year": None, "rank_is_stale": False}
155 + last, prev = row["last"], row["prev"]
156 + ch_abs = last["value"] - prev["value"]
157 + ch_pct = ch_abs / prev["value"] * 100 if prev["value"] else None
158 + pts = ind.get("format") in ("percent", "index", "ratio", "years")
159 + return {
160 + "indicator": ind["slug"], "indicator_name": ind.get("short_name", ind["name"]), "has_data": True, "value": last["value"], "formatted": fmt_value(last["value"], ind),
161 + "period": last["period"], "year": last["year"], "frequency": "A", "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"),
162 + "is_estimate": last["is_estimate"], "is_forecast": False, "status": "verified", "prev": {"period": prev["period"], "value": prev["value"]},
163 + "change": {"abs": round(ch_abs, 3), "pct": round(ch_pct, 2) if ch_pct is not None else None, "formatted": (f"{ch_abs:+.1f} pts" if pts else f"{ch_pct:+.1f} %") if ch_pct is not None else None},
164 + "change_10y": None, "rank_world": row["rank_world"], "n_world": row["n_world"], "rank_region": row["rank_region"], "n_region": row["n_region"], "rank_income": None, "n_income": None,
165 + "rank_year": last["year"], "rank_is_stale": last["year"] < 2022, "higher_is_better": ind.get("higher_is_better"), "sparkline": row["spark"], "provenance": prov(ind, c),
166 + }
167 +
168 +
169 +def problem(status: int, title: str, detail: str) -> JSONResponse:
170 + return JSONResponse({"type": "about:blank", "title": title, "status": status, "detail": detail}, status_code=status, media_type="application/problem+json")
171 +
172 +
173 +def resolve(ident: str) -> dict | None:
174 + return BY_ID.get(ident.upper()) or BY_SLUG.get(ident.lower())
175 +
176 +
177 +@app.get("/api/v1/health")
178 +def health():
179 + return {"status": "ok", "run_id": META["run_id"], "built_at": BUILT, "observations": 3_412_338, "countries": len(countries), "indicators": len(indicators), "db_path": "fixture", "version": "0.0-fixture", "cache": {}}
180 +
181 +
182 +@app.get("/api/v1/countries")
183 +def list_countries(region: str | None = None, income: str | None = None, q: str | None = None, sort: str = "name", limit: int = 1000, offset: int = 0):
184 + items = []
185 + for c in countries:
186 + if region and (c.get("region_wb") or "").lower() != region.lower() and c.get("region_wb", "").lower() != region.lower():
187 + continue
188 + if income and (c.get("income_group") or "").upper() != income.upper():
189 + continue
190 + if q and q.lower() not in c["short_name"].lower():
191 + continue
192 + pop = latest_all("population").get(c["id"])
193 + gdp = latest_all("gdp").get(c["id"])
194 + gpc = latest_all("gdp-per-capita").get(c["id"])
195 + item = card(c)
196 + item.update({"capital": c.get("capital"), "continent": c.get("continent"), "subregion": c.get("subregion"),
197 + "population_latest": pop["last"]["value"] if pop else None, "population_year": pop["last"]["year"] if pop else None,
198 + "gdp_latest": gdp["last"]["value"] if gdp else None, "gdp_year": gdp["last"]["year"] if gdp else None,
199 + "gdp_per_capita_latest": gpc["last"]["value"] if gpc else None, "gdp_per_capita_year": gpc["last"]["year"] if gpc else None,
200 + "coverage_pct": round(100 * sum(1 for i in indicators if has_data(c, i)) / len(indicators), 1), "n_indicators": sum(1 for i in indicators if has_data(c, i)),
201 + "iso_numeric": c.get("iso_numeric")})
202 + items.append(item)
203 + return {"meta": META, "n": len(items), "filters": {"region": region, "income": income, "q": q, "sort": sort}, "items": items[offset: offset + limit]}
204 +
205 +
206 +@app.get("/api/v1/countries/{ident}")
207 +def get_country(ident: str):
208 + c = resolve(ident)
209 + if not c:
210 + return problem(404, "Country not found", f"Unknown country '{ident}'.")
211 + full = card(c)
212 + for k in ("official_name", "iso3", "iso_numeric", "capital", "continent", "subregion", "currency_code", "currency_name", "area_km2", "latitude", "longitude", "un_member", "independent", "landlocked", "borders", "languages", "demonym", "status"):
213 + full[k] = c.get(k)
214 + with_data = {i["slug"] for i in indicators if has_data(c, i)}
215 + tps = [{"id": tp["id"], "name": tp["name"], "short": tp.get("short"), "order": tp.get("order"), "blurb": tp.get("blurb"), "n_indicators": len(tp["indicators"]),
216 + "n_with_data": sum(1 for s in tp["indicators"] if s in with_data)} for tp in sorted(topics["topics"], key=lambda x: x["order"])]
217 + return {"meta": META, "country": full, "groups": [{"id": g["id"], "slug": g["slug"], "name": g["name"], "kind": g["kind"], "wb_code": g.get("wb_code"), "n_members": None} for g in groups[:4]],
218 + "coverage": {"n_indicators": len(with_data), "n_observations": 34 * len(with_data), "latest_year": 2024, "coverage_pct": round(100 * len(with_data) / len(indicators), 1), "updated_at": BUILT},
219 + "freshness": {"source_updated_at": "2026-07-01", "retrieved_at": BUILT, "built_at": BUILT},
220 + "headline": [metric(c, IND[s]) for s in topics["headline"] if s in IND], "topics": tps,
221 + "neighbours": [card(BY_ID[b]) for b in (c.get("borders") or []) if b in BY_ID]}
222 +
223 +
224 +@app.get("/api/v1/countries/{ident}/topics/{topic}")
225 +def get_topic(ident: str, topic: str):
226 + c = resolve(ident)
227 + tp = next((x for x in topics["topics"] if x["id"] == topic), None)
228 + if not c:
229 + return problem(404, "Country not found", f"Unknown country '{ident}'.")
230 + if not tp:
231 + return problem(404, "Topic not found", f"Unknown topic '{topic}'.")
232 + blocks: dict[str, list] = {}
233 + n_with = 0
234 + for s in tp["indicators"]:
235 + ind = IND.get(s)
236 + if not ind:
237 + continue
238 + m = metric(c, ind)
239 + n_with += m["has_data"]
240 + blocks.setdefault(ind.get("subtopic") or "Other", []).append(m)
241 + return {"meta": META, "country": card(c), "topic": {k: tp.get(k) for k in ("id", "name", "short", "order", "blurb")}, "n_with_data": n_with, "n_indicators": sum(len(v) for v in blocks.values()),
242 + "subtopics": [{"subtopic": k, "indicators": v} for k, v in blocks.items()]}
243 +
244 +
245 +@app.get("/api/v1/countries/{ident}/series/{slug}")
246 +def get_series(ident: str, slug: str):
247 + c = resolve(ident)
248 + ind = IND.get(slug)
249 + if not c or not ind:
250 + return problem(404, "Not found", "Unknown country or indicator.")
251 + vals = series(c, ind) if has_data(c, ind) else []
252 + act = [v for v in vals if not v["is_forecast"]]
253 + stats = {"min": None, "max": None, "first": None, "last": None, "cagr": None, "n": len(act)}
254 + if act:
255 + mn = min(act, key=lambda v: v["value"]); mx = max(act, key=lambda v: v["value"])
256 + stats.update({"min": {"year": mn["year"], "value": mn["value"]}, "max": {"year": mx["year"], "value": mx["value"]}, "first": {"year": act[0]["year"], "value": act[0]["value"]}, "last": {"year": act[-1]["year"], "value": act[-1]["value"]}})
257 + return {"meta": META, "indicator": icard(ind), "country": card(c), "unit": ind.get("unit"), "frequency": "A", "values": vals, "alternatives": None,
258 + "provenance": prov(ind, c) if vals else None, "sources": [dict(prov(ind, c), n_values=len(act))] if vals else [], "stats": stats}
259 +
260 +
261 +KINDS = ["yoy_jump", "yoy_drop", "record_high", "record_low", "n_year_high", "sign_flip", "accelerating"]
262 +
263 +
264 +def change_items(c: dict, n: int, whole: bool = False) -> list[dict]:
265 + r = rng(c["id"], "changes", whole)
266 + items = []
267 + slugs = [s for s in IND if has_data(c, IND[s])]
268 + r.shuffle(slugs)
269 + for s in slugs[:n]:
270 + ind = IND[s]
271 + row = latest_all(s).get(c["id"])
272 + if not row:
273 + continue
274 + kind = r.choice(KINDS)
275 + year = row["last"]["year"] if not whole else r.randint(1995, 2024)
276 + v = row["last"]["value"]; ref = row["prev"]["value"]
277 + d = v - ref
278 + sev = round(r.uniform(0.2, 1.0), 2)
279 + head = {"yoy_jump": f"{ind.get('short_name', ind['name'])} jumped to {fmt_value(v, ind)} in {year}", "yoy_drop": f"{ind.get('short_name', ind['name'])} fell to {fmt_value(v, ind)} in {year}",
280 + "record_high": f"{ind.get('short_name', ind['name'])} reached a record high of {fmt_value(v, ind)} in {year}", "record_low": f"{ind.get('short_name', ind['name'])} hit a record low of {fmt_value(v, ind)} in {year}",
281 + "n_year_high": f"{ind.get('short_name', ind['name'])} at a 10-year high ({fmt_value(v, ind)}) in {year}", "sign_flip": f"{ind.get('short_name', ind['name'])} turned {'positive' if v > 0 else 'negative'} in {year}",
282 + "accelerating": f"{ind.get('short_name', ind['name'])} accelerating for three years running ({year})"}[kind]
283 + items.append({"id": f"{c['id']}-{s}-{year}", "country": card(c), "indicator": icard(ind), "kind": kind, "period": f"{year}-01-01", "year": year, "value": v, "ref_value": ref, "delta": round(d, 3),
284 + "delta_pct": round(d / ref * 100, 2) if ref else None, "window_years": 10 if kind.startswith("n_year") else None, "severity": sev, "headline": head, "detail": None,
285 + "detected_at": BUILT, "formatted": fmt_value(v, ind), "provenance": prov(ind, c)})
286 + items.sort(key=lambda x: (-x["severity"], x["year"]) if not whole else (-x["year"], -x["severity"]))
287 + return items
288 +
289 +
290 +@app.get("/api/v1/countries/{ident}/changes")
291 +def get_changes(ident: str, limit: int = 50):
292 + c = resolve(ident)
293 + if not c:
294 + return problem(404, "Country not found", ident)
295 + items = change_items(c, min(limit, 10))
296 + return {"meta": META, "n": len(items), "items": items}
297 +
298 +
299 +@app.get("/api/v1/countries/{ident}/events")
300 +def get_events(ident: str, limit: int = 100):
301 + c = resolve(ident)
302 + if not c:
303 + return problem(404, "Country not found", ident)
304 + items = change_items(c, min(limit, 24), whole=True)
305 + return {"meta": META, "n": len(items), "items": items}
306 +
307 +
308 +@app.get("/api/v1/countries/{ident}/similar")
309 +def get_similar(ident: str, mode: str = "overall", limit: int = 12):
310 + c = resolve(ident)
311 + if not c:
312 + return problem(404, "Country not found", ident)
313 + r = rng(c["id"], "similar", mode)
314 + pool = [x for x in countries if x["id"] != c["id"] and x.get("income_group") == c.get("income_group")] or countries
315 + r.shuffle(pool)
316 + feats = ["gdp-per-capita-ppp", "median-age", "urban-population-share", "trade-pct-gdp", "energy-use-per-capita", "co2-per-capita", "life-expectancy", "unemployment-rate"]
317 + peers = []
318 + for i, p in enumerate(pool[:limit]):
319 + contrib = {f: {"z_a": round(r.gauss(0, 1), 2), "z_b": round(r.gauss(0, 1), 2), "weight": 1.0, "contribution": round(r.uniform(0, 0.4), 3)} for f in feats}
320 + peers.append({"country": card(p), "score": round(92 - i * 4.3 - r.random() * 2, 1), "rank": i + 1, "contributions": contrib})
321 + return {"meta": META, "country": card(c), "mode": mode, "modes": ["overall", "economic", "demographic", "energy", "social"], "peers": peers}
322 +
323 +
324 +@app.get("/api/v1/countries/{ident}/insights")
325 +def get_insights(ident: str):
326 + c = resolve(ident)
327 + if not c:
328 + return problem(404, "Country not found", ident)
329 + items = []
330 + for s, tmpl in (("population", "{n}'s population grew {pct} % since 1990."), ("gdp-per-capita", "GDP per capita in {n} is {v}, ranking {rank} of {nw}."), ("life-expectancy", "Life expectancy in {n} reached {v} in {y}."), ("renewable-electricity-share", "{v} of {n}'s electricity comes from renewables."), ("co2-per-capita", "{n} emits {v} of CO₂ per person, {cmp} the world median.")):
331 + row = latest_all(s).get(c["id"])
332 + if not row:
333 + continue
334 + ind = IND[s]
335 + v = fmt_value(row["last"]["value"], ind)
336 + items.append({"id": f"{c['id']}-{s}", "template_id": s, "text": tmpl.format(n=c["short_name"], v=v, y=row["last"]["year"], pct=f"{rng(c['id'], s).uniform(3, 60):.0f}", rank=row["rank_world"], nw=row["n_world"], cmp=rng(c['id'], s, 'c').choice(["above", "below"])),
337 + "values": {"value": row["last"]["value"]}, "indicators": [s], "computed_at": BUILT, "provenance": [prov(ind, c)]})
338 + return {"meta": META, "country": card(c), "items": items}
339 +
340 +
341 +@app.get("/api/v1/countries/{ident}/dna")
342 +def get_dna(ident: str):
343 + c = resolve(ident)
344 + if not c:
345 + return problem(404, "Country not found", ident)
346 + r = rng(c["id"], "dna")
347 + keys = ["income", "demographics", "urbanization", "trade", "energy", "emissions", "innovation", "education", "public_spending"]
348 + dims = {k: (round(r.uniform(5, 98), 1) if r.random() > 0.08 else None) for k in keys}
349 + return {"meta": META, "country": card(c), "dims": dims, "year_ref": 2024, "dimensions": [{"id": k, "label": k.title(), "indicator": None, "value": v} for k, v in dims.items()]}
350 +
351 +
352 +@app.get("/api/v1/indicators/{slug}/map")
353 +def get_map(slug: str, year: int | None = None, nearest: bool = False):
354 + ind = IND.get(slug)
355 + if not ind:
356 + return problem(404, "Indicator not found", slug)
357 + rows = latest_all(slug)
358 + values = {cid: r["last"]["value"] for cid, r in rows.items() if BY_ID[cid].get("status") == "country"}
359 + vals = sorted(values.values())
360 + k = 6
361 + breaks = [vals[int(i / k * (len(vals) - 1))] for i in range(1, k)] if len(vals) > 10 else []
362 + return {"meta": META, "indicator": icard(ind), "year": year, "year_used": 2024, "nearest": nearest, "values": values, "years": None, "formatted": {c: fmt_value(v, ind) for c, v in values.items()},
363 + "legend": {"min": vals[0] if vals else None, "max": vals[-1] if vals else None, "breaks": breaks, "n_classes": len(breaks) + 1}, "n": len(values), "provenance": prov(ind), "sources": [prov(ind)]}
364 +
365 +
366 +def ranking_rows(slug: str, n: int, order: str = "desc", min_pop: float | None = None) -> list[dict]:
367 + ind = IND[slug]
368 + rows = [(cid, r) for cid, r in latest_all(slug).items() if BY_ID[cid].get("status") == "country" and (not min_pop or (latest_all("population").get(cid, {}).get("last", {}).get("value", 0) >= min_pop))]
369 + rows.sort(key=lambda kv: kv[1]["last"]["value"], reverse=(order == "desc"))
370 + out = []
371 + for i, (cid, r) in enumerate(rows[:n]):
372 + v = r["last"]["value"]; pv = r["prev"]["value"]
373 + out.append({"rank": i + 1, "rank_world": r["rank_world"], "n_world": r["n_world"], "pct_rank": None, "country": card(BY_ID[cid]), "value": v, "formatted": fmt_value(v, ind), "year": r["last"]["year"],
374 + "change_pct": round((v - pv) / pv * 100, 2) if pv else None, "change_abs": round(v - pv, 3), "change_1y": {"abs": round(v - pv, 3), "pct": round((v - pv) / pv * 100, 2) if pv else None, "formatted": None}, "change_10y": None,
375 + "sparkline": r["spark"], "provenance": prov(ind, BY_ID[cid])})
376 + return out
377 +
378 +
379 +@app.get("/api/v1/rankings/{slug}")
380 +def get_ranking(slug: str, limit: int = 50, sort: str = "desc", offset: int = 0):
381 + ind = IND.get(slug)
382 + if not ind:
383 + return problem(404, "Indicator not found", slug)
384 + rows = ranking_rows(slug, limit + offset, sort)[offset:]
385 + return {"meta": META, "indicator": icard(ind), "group": {"id": "world", "slug": "world", "name": "World", "kind": "world", "wb_code": "WLD", "n_members": len(countries)}, "year": None, "year_used": 2024,
386 + "years_available": list(range(1990, 2025)), "sort": sort, "n": len(latest_all(slug)), "limit": limit, "offset": offset, "rows": rows}
387 +
388 +
389 +def ind_summary(ind: dict) -> dict:
390 + n = sum(1 for c in countries if has_data(c, ind))
391 + out = icard(ind)
392 + out.update({"description": ind.get("description"), "n_countries": n, "n_observations": n * 34, "first_year": 1990, "last_year": 2024, "latest_source_updated_at": "2026-07-01", "primary_source_id": "worldbank", "coverage_pct": round(100 * n / len(countries), 1), "tags": []})
393 + return out
394 +
395 +
396 +@app.get("/api/v1/home")
397 +def home():
398 + lists = {}
399 + for key, title, slug, order, min_pop, desc in (("largest_economies", "Largest economies", "gdp", "desc", None, "GDP, current US$"), ("fastest_population_growth", "Fastest population growth", "population-growth", "desc", 1e6, "Countries above 1 M inhabitants"),
400 + ("highest_life_expectancy", "Highest life expectancy", "life-expectancy", "desc", None, "Years at birth"), ("energy_transition_leaders", "Energy transition leaders", "renewable-electricity-share", "desc", None, "Share of electricity from renewables")):
401 + lists[key] = {"title": title, "description": desc, "indicator": icard(IND[slug]), "sort": order, "rows": ranking_rows(slug, 8, order, min_pop)}
402 + pop = sum(r["last"]["value"] for cid, r in latest_all("population").items() if BY_ID[cid].get("status") == "country")
403 + gdp = sum(r["last"]["value"] for cid, r in latest_all("gdp").items() if BY_ID[cid].get("status") == "country")
404 + les = sorted(r["last"]["value"] for r in latest_all("life-expectancy").values())
405 + changes = []
406 + for c in rng("home").sample(countries, 12):
407 + changes += change_items(c, 1)
408 + changes.sort(key=lambda x: -x["severity"])
409 + featured = [ind_summary(i) for i in indicators if i.get("featured")]
410 + return {"meta": META, "snapshot": {"world_population": pop, "world_population_formatted": fmt_value(pop, {"format": "number"}), "world_population_year": 2024, "world_gdp": gdp, "world_gdp_formatted": fmt_value(gdp, {"format": "currency"}), "world_gdp_year": 2024,
411 + "median_life_expectancy": les[len(les) // 2] if les else None, "median_life_expectancy_year": 2024, "n_countries": sum(1 for c in countries if c.get("status") == "country"), "n_territories": sum(1 for c in countries if c.get("status") != "country"),
412 + "n_indicators": len(indicators), "n_indicators_with_data": len(indicators) - 3, "n_observations": 3_412_338, "n_sources": 9, "built_at": BUILT, "run_id": META["run_id"], "note": "Fixture data."},
413 + "lists": lists, "recent_changes": changes[:12], "recently_updated": [ind_summary(i) for i in indicators[:12]], "featured_indicators": featured, "trending": featured[:12]}
414 +
415 +
416 +@app.get("/api/v1/search")
417 +def search(q: str = Query(""), limit: int = 12):
418 + ql = q.lower().strip()
419 + hits = []
420 + for c in countries:
421 + if ql and (ql in c["short_name"].lower() or ql == c["id"].lower()):
422 + hits.append({"type": "country", "id": c["id"], "slug": c["slug"], "name": c["short_name"], "hint": f"Country · {c.get('region_wb_name')}", "score": 1.0 if c["short_name"].lower().startswith(ql) else 0.8, "url": f"/countries/{c['slug']}", "country": card(c), "topic": None, "indicator": None})
423 + for i in indicators:
424 + if ql and ql in i["name"].lower():
425 + hits.append({"type": "indicator", "id": i["slug"], "slug": i["slug"], "name": i["name"], "hint": f"Indicator · {i['topic'].capitalize()} · {i.get('unit')}", "score": 0.7, "url": f"/indicators/{i['slug']}", "country": None, "topic": None, "indicator": None})
426 + for tp in topics["topics"]:
427 + if ql and ql in tp["name"].lower():
428 + hits.append({"type": "topic", "id": tp["id"], "slug": tp["id"], "name": tp["name"], "hint": "Topic", "score": 0.6, "url": f"/indicators?topic={tp['id']}", "country": None, "topic": tp["id"], "indicator": None})
429 + hits.sort(key=lambda h: -h["score"])
430 + return {"meta": META, "q": q, "n": len(hits), "hits": hits[:limit]}
431 +
432 +
433 +@app.get("/api/v1/countries/{ident}/download.csv")
434 +def download(ident: str):
435 + from fastapi.responses import PlainTextResponse
436 + return PlainTextResponse("country_id,indicator_id,period,value\n", media_type="text/csv")
437 +
438 +
439 +if __name__ == "__main__":
440 + import uvicorn
441 +
442 + uvicorn.run(app, host="127.0.0.1", port=8299, log_level="warning")
added apps/web/src/app/countries/page.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { t } from '@/i18n';
4 +import { api, isNotBuilt } from '@/lib/api';
5 +import { grouped } from '@/lib/format';
6 +import { CountryDirectory } from '@/components/countries/directory';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +
9 +export const metadata: Metadata = {
10 + title: t('countries.title'),
11 + description: t('countries.sub', { n: 218 }),
12 + alternates: { canonical: '/countries' },
13 +};
14 +export const revalidate = 900;
15 +
16 +export default async function CountriesPage() {
17 + let items: Awaited<ReturnType<typeof api.countries>>['items'] = [];
18 + let notBuilt = false;
19 + try {
20 + items = (await api.countries({ sort: 'name' })).items;
21 + } catch (e) {
22 + if (isNotBuilt(e)) notBuilt = true;
23 + else throw e;
24 + }
25 + return (
26 + <>
27 + <header className="pb-2 pt-6 md:pt-10">
28 + <h1 className="display text-3xl text-ink md:text-4xl">{t('countries.title')}</h1>
29 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('countries.sub', { n: grouped(items.length || 218) })}</p>
30 + </header>
31 + {notBuilt ? (
32 + <NotBuiltState />
33 + ) : (
34 + <Suspense fallback={null}>
35 + <CountryDirectory items={items} />
36 + </Suspense>
37 + )}
38 + </>
39 + );
40 +}
added apps/web/src/app/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { api, isNotBuilt, safe } from '@/lib/api';
5 +import { routes } from '@/lib/site';
6 +import { Choropleth } from '@/components/charts/choropleth';
7 +import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars';
8 +import { ChangeList } from '@/components/data/change-list';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { Section } from '@/components/data/section';
11 +import { CompareTeaser } from '@/components/home/compare-teaser';
12 +import { IndicatorList } from '@/components/home/featured-indicators';
13 +import { Hero } from '@/components/home/hero';
14 +import { RegionChips } from '@/components/home/region-chips';
15 +import { SnapshotStrip } from '@/components/home/snapshot-strip';
16 +import { TopicsGrid } from '@/components/home/topics-grid';
17 +
18 +export const metadata: Metadata = { alternates: { canonical: '/' } };
19 +export const revalidate = 900;
20 +
21 +const LIST_KEYS = ['largest_economies', 'fastest_population_growth', 'highest_life_expectancy', 'energy_transition_leaders'] as const;
22 +
23 +export default async function HomePage() {
24 + let home: Awaited<ReturnType<typeof api.home>> | null = null;
25 + let notBuilt = false;
26 + try {
27 + home = await api.home();
28 + } catch (e) {
29 + if (isNotBuilt(e)) notBuilt = true;
30 + else throw e;
31 + }
32 + const [countriesRes, map] = await Promise.all([safe(api.countries()), safe(api.indicatorMap('gdp-per-capita-ppp', { nearest: true }))]);
33 + const countries = countriesRes?.items ?? [];
34 +
35 + return (
36 + <>
37 + <Hero />
38 + {notBuilt || !home ? (
39 + <NotBuiltState />
40 + ) : (
41 + <>
42 + <SnapshotStrip s={home.snapshot} />
43 +
44 + <Section id="explore" title={t('home.explore.title')} subtitle={t('home.explore.sub')}>
45 + <RegionChips />
46 + {map && countries.length ? (
47 + <div className="mt-5 max-w-4xl">
48 + <Choropleth map={map} countries={countries} compact />
49 + </div>
50 + ) : null}
51 + </Section>
52 +
53 + <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}>
54 + <CompareTeaser countries={countries} />
55 + </Section>
56 +
57 + <Section id="rankings" title={t('nav.rankings')} actions={<Link href={routes.rankings()} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}>
58 + <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2">
59 + {LIST_KEYS.map((key) => {
60 + const list = home.lists[key];
61 + if (!list) return null;
62 + return (
63 + <div key={key}>
64 + <div className="mb-2 flex items-baseline justify-between gap-3">
65 + <h3 className="text-base font-semibold text-ink">{list.title}</h3>
66 + <span className="text-xs text-ink-3">{list.description}</span>
67 + </div>
68 + <RankedBars rows={list.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank))} spec={list.indicator} provenance={list.rows[0]?.provenance ?? null} />
69 + <Link href={routes.ranking(list.indicator.slug)} className="mt-2 inline-flex min-h-[36px] items-center text-sm text-accent hover:underline">
70 + {t('common.seeFullRanking')} →
71 + </Link>
72 + </div>
73 + );
74 + })}
75 + </div>
76 + </Section>
77 +
78 + <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}>
79 + <TopicsGrid />
80 + </Section>
81 +
82 + <div className="grid gap-x-10 lg:grid-cols-2">
83 + <Section id="changes" title={t('home.changes.title')} subtitle={t('home.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('home.changes.all')} →</Link>}>
84 + <ChangeList items={home.recent_changes.slice(0, 10)} showCountry />
85 + </Section>
86 + <div>
87 + <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')} tight>
88 + <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={8} />
89 + </Section>
90 + <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')} tight>
91 + <IndicatorList items={home.recently_updated} showUpdated limit={8} />
92 + </Section>
93 + </div>
94 + </div>
95 + </>
96 + )}
97 + </>
98 + );
99 +}
added apps/web/src/components/countries/directory.tsx +259 −0
@@ -0,0 +1,259 @@
1 +'use client';
2 +import { ArrowDownAZ, ArrowDownWideNarrow, SlidersHorizontal, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter, useSearchParams } from 'next/navigation';
5 +import { useCallback, useMemo, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { cn } from '@/lib/cn';
8 +import { compact, formatPct, grouped, isNum } from '@/lib/format';
9 +import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';
10 +import { routes } from '@/lib/site';
11 +import type { CountrySummary } from '@/lib/types';
12 +import { BottomSheet } from '@/components/data/bottom-sheet';
13 +
14 +type SortKey = 'name' | 'population' | 'gdp' | 'coverage';
15 +
16 +/**
17 + * Country directory: the 218 rows are server-rendered (this client component SSRs with the full list) and
18 + * filtered/sorted client-side. Region + income chips (bottom sheet on mobile), sort control, A–Z grouping
19 + * with a sticky letter index on desktop. URL `?region=&income=&q=&sort=` is kept in sync (shallow).
20 + */
21 +export function CountryDirectory({ items }: { items: CountrySummary[] }) {
22 + const params = useSearchParams();
23 + const router = useRouter();
24 + const [q, setQ] = useState(params.get('q') ?? '');
25 + const [region, setRegion] = useState<string | null>(params.get('region'));
26 + const [income, setIncome] = useState<string | null>(params.get('income'));
27 + const [sort, setSort] = useState<SortKey>((params.get('sort') as SortKey) || 'name');
28 + const [sheet, setSheet] = useState(false);
29 +
30 + const sync = useCallback(
31 + (next: { q?: string; region?: string | null; income?: string | null; sort?: SortKey }) => {
32 + const p = new URLSearchParams();
33 + const nq = next.q ?? q;
34 + const nr = next.region === undefined ? region : next.region;
35 + const ni = next.income === undefined ? income : next.income;
36 + const ns = next.sort ?? sort;
37 + if (nq) p.set('q', nq);
38 + if (nr) p.set('region', nr);
39 + if (ni) p.set('income', ni);
40 + if (ns !== 'name') p.set('sort', ns);
41 + const s = p.toString();
42 + router.replace(`${routes.countries()}${s ? `?${s}` : ''}`, { scroll: false });
43 + },
44 + [q, region, income, sort, router],
45 + );
46 +
47 + const filtered = useMemo(() => {
48 + const ql = q.trim().toLowerCase();
49 + let rows = items.filter((c) => (!region || (c.region ?? '').toUpperCase() === region.toUpperCase()) && (!income || (c.income ?? '').toUpperCase() === income.toUpperCase()));
50 + if (ql) rows = rows.filter((c) => (c.name ?? '').toLowerCase().includes(ql) || c.id.toLowerCase() === ql || (c.capital ?? '').toLowerCase().includes(ql));
51 + const num = (v: number | null) => (isNum(v) ? v : -Infinity);
52 + rows.sort((a, b) => {
53 + if (sort === 'population') return num(b.population_latest) - num(a.population_latest) || (a.name ?? '').localeCompare(b.name ?? '');
54 + if (sort === 'gdp') return num(b.gdp_per_capita_latest) - num(a.gdp_per_capita_latest) || (a.name ?? '').localeCompare(b.name ?? '');
55 + if (sort === 'coverage') return num(b.coverage_pct) - num(a.coverage_pct) || (a.name ?? '').localeCompare(b.name ?? '');
56 + return (a.name ?? '').localeCompare(b.name ?? '');
57 + });
58 + return rows;
59 + }, [items, q, region, income, sort]);
60 +
61 + const groupsAZ = useMemo(() => {
62 + if (sort !== 'name') return null;
63 + const m = new Map<string, CountrySummary[]>();
64 + for (const c of filtered) {
65 + const letter = (c.name ?? '#').charAt(0).toUpperCase();
66 + const key = /[A-Z]/.test(letter) ? letter : '#';
67 + if (!m.has(key)) m.set(key, []);
68 + m.get(key)!.push(c);
69 + }
70 + return Array.from(m.entries());
71 + }, [filtered, sort]);
72 +
73 + const activeCount = (region ? 1 : 0) + (income ? 1 : 0);
74 + const letters = groupsAZ?.map(([l]) => l) ?? [];
75 +
76 + const filters = (
77 + <div className="space-y-4">
78 + <ChipGroup label={t('common.region')} value={region} onChange={(v) => { setRegion(v); sync({ region: v }); }} options={WB_REGIONS.map((r) => ({ id: r.id, label: r.short }))} />
79 + <ChipGroup label={t('common.income')} value={income} onChange={(v) => { setIncome(v); sync({ income: v }); }} options={INCOME_GROUPS.map((g) => ({ id: g.id, label: g.name }))} />
80 + </div>
81 + );
82 +
83 + return (
84 + <div className="lg:grid lg:grid-cols-[1fr_2.5rem] lg:gap-6">
85 + <div className="min-w-0">
86 + {/* Controls row */}
87 + <div className="flex flex-wrap items-center gap-2 py-3">
88 + <div className="relative min-w-0 flex-1 basis-56">
89 + <input
90 + type="search"
91 + value={q}
92 + onChange={(e) => { setQ(e.target.value); sync({ q: e.target.value }); }}
93 + placeholder={t('countries.search')}
94 + aria-label={t('countries.search')}
95 + className="h-10 w-full rounded-sm border border-rule bg-surface px-3 text-sm outline-none placeholder:text-ink-3 focus:border-accent"
96 + />
97 + {q ? (
98 + <button type="button" onClick={() => { setQ(''); sync({ q: '' }); }} className="absolute right-1 top-1 grid h-8 w-8 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>
99 + <X size={14} aria-hidden />
100 + </button>
101 + ) : null}
102 + </div>
103 + <label className="inline-flex h-10 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">
104 + {sort === 'name' ? <ArrowDownAZ size={15} aria-hidden /> : <ArrowDownWideNarrow size={15} aria-hidden />}
105 + <span className="sr-only">{t('common.sortBy')}</span>
106 + <select value={sort} onChange={(e) => { const v = e.target.value as SortKey; setSort(v); sync({ sort: v }); }} className="bg-transparent pr-1 text-ink outline-none">
107 + <option value="name">{t('countries.sort.name')}</option>
108 + <option value="population">{t('countries.sort.population')}</option>
109 + <option value="gdp">{t('countries.sort.gdp')}</option>
110 + <option value="coverage">{t('countries.sort.coverage')}</option>
111 + </select>
112 + </label>
113 + <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-10 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', activeCount ? 'border-accent text-accent' : 'border-rule text-ink-2')}>
114 + <SlidersHorizontal size={15} aria-hidden />
115 + {t('countries.filters')}
116 + {activeCount ? <span className="tnum">({activeCount})</span> : null}
117 + </button>
118 + <span className="tnum ml-auto text-xs text-ink-3">{t('countries.count', { n: grouped(filtered.length), total: grouped(items.length) })}</span>
119 + </div>
120 + <div className="hidden pb-3 md:block">{filters}</div>
121 + {(region || income) && (
122 + <div className="flex flex-wrap items-center gap-2 pb-3 md:hidden">
123 + {region ? <ActiveChip label={WB_REGIONS.find((r) => r.id === region)?.short ?? region} onClear={() => { setRegion(null); sync({ region: null }); }} /> : null}
124 + {income ? <ActiveChip label={INCOME_GROUPS.find((g) => g.id === income)?.name ?? income} onClear={() => { setIncome(null); sync({ income: null }); }} /> : null}
125 + </div>
126 + )}
127 +
128 + {/* Header row (sm+) */}
129 + <div className="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] gap-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid md:grid-cols-[minmax(0,1fr)_9rem_8rem_8rem_5rem]">
130 + <span>{t('common.country')}</span>
131 + <span className="hidden md:block">{t('common.region')}</span>
132 + <span className="text-right">{t('common.population')}</span>
133 + <span className="text-right">{t('common.gdpPerCapita')}</span>
134 + <span className="text-right">{t('common.coverage')}</span>
135 + </div>
136 +
137 + {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('countries.noMatch')}</p> : null}
138 +
139 + {groupsAZ ? (
140 + groupsAZ.map(([letter, rows]) => (
141 + <section key={letter} id={`letter-${letter}`} className="scroll-mt-28">
142 + <h2 className="display sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-lg text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14 md:static md:mt-4 md:border-0 md:bg-transparent md:py-0">
143 + {letter}
144 + </h2>
145 + <ul className="divide-y divide-rule">
146 + {rows.map((c) => (
147 + <Row key={c.id} c={c} />
148 + ))}
149 + </ul>
150 + </section>
151 + ))
152 + ) : (
153 + <ol className="divide-y divide-rule">
154 + {filtered.map((c, i) => (
155 + <Row key={c.id} c={c} rank={i + 1} />
156 + ))}
157 + </ol>
158 + )}
159 + </div>
160 +
161 + {/* Sticky letter index (desktop) */}
162 + {letters.length > 1 ? (
163 + <nav aria-label={t('countries.letterIndex')} className="hidden lg:block">
164 + <ol className="sticky top-20 flex flex-col items-center gap-0.5 text-2xs">
165 + {letters.map((l) => (
166 + <li key={l}>
167 + <a href={`#letter-${l}`} className="grid h-5 w-6 place-items-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-accent">
168 + {l}
169 + </a>
170 + </li>
171 + ))}
172 + </ol>
173 + </nav>
174 + ) : null}
175 +
176 + <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('countries.filters')}>
177 + {filters}
178 + <div className="mt-6 flex justify-between">
179 + <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => { setRegion(null); setIncome(null); sync({ region: null, income: null }); }}>
180 + {t('common.reset')}
181 + </button>
182 + <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>
183 + {t('common.apply')}
184 + </button>
185 + </div>
186 + </BottomSheet>
187 + </div>
188 + );
189 +}
190 +
191 +function Row({ c, rank }: { c: CountrySummary; rank?: number }) {
192 + return (
193 + <li>
194 + <Link href={routes.country(c.slug ?? c.id)} className="group grid min-h-[52px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] md:grid-cols-[minmax(0,1fr)_9rem_8rem_8rem_5rem]">
195 + <span className="flex min-w-0 items-center gap-2.5">
196 + {rank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{rank}</span> : null}
197 + <span aria-hidden className="text-xl leading-none">
198 + {c.flag}
199 + </span>
200 + <span className="min-w-0">
201 + <span className="block truncate text-sm font-medium text-ink group-hover:text-accent">
202 + {c.name}
203 + {c.kind === 'territory' ? <span className="ml-1.5 rounded-xs border border-rule px-1 text-2xs font-normal text-ink-3">{t('countries.territory')}</span> : null}
204 + </span>
205 + <span className="block truncate text-xs text-ink-3 md:hidden">{c.region_name ?? ''}</span>
206 + </span>
207 + </span>
208 + <span className="hidden truncate text-xs text-ink-2 md:block">{c.region_name}</span>
209 + <span className="tnum text-right text-sm text-ink sm:text-sm">
210 + <span className="block">{isNum(c.population_latest) ? compact(c.population_latest) : t('common.na')}</span>
211 + <span className="block text-2xs text-ink-3 sm:hidden">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)} /cap` : ''}</span>
212 + </span>
213 + <span className="tnum hidden text-right text-sm text-ink sm:block">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)}` : t('common.na')}</span>
214 + <span className="tnum hidden text-right text-xs text-ink-2 sm:block">{formatPct(c.coverage_pct)}</span>
215 + </Link>
216 + </li>
217 + );
218 +}
219 +
220 +function ChipGroup({ label, value, onChange, options }: { label: string; value: string | null; onChange: (v: string | null) => void; options: Array<{ id: string; label: string }> }) {
221 + return (
222 + <div>
223 + <div className="eyebrow mb-1.5">{label}</div>
224 + <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>
225 + <li>
226 + <Chip active={!value} onClick={() => onChange(null)}>
227 + {t('common.all')}
228 + </Chip>
229 + </li>
230 + {options.map((o) => (
231 + <li key={o.id}>
232 + <Chip active={value?.toUpperCase() === o.id.toUpperCase()} onClick={() => onChange(value?.toUpperCase() === o.id.toUpperCase() ? null : o.id)}>
233 + {o.label}
234 + </Chip>
235 + </li>
236 + ))}
237 + </ul>
238 + </div>
239 + );
240 +}
241 +
242 +function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
243 + return (
244 + <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-9 items-center rounded-sm border px-2.5 text-sm', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
245 + {children}
246 + </button>
247 + );
248 +}
249 +
250 +function ActiveChip({ label, onClear }: { label: string; onClear: () => void }) {
251 + return (
252 + <span className="inline-flex h-8 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-xs text-accent">
253 + {label}
254 + <button type="button" onClick={onClear} className="grid h-7 w-7 place-items-center" aria-label={t('common.reset')}>
255 + <X size={12} aria-hidden />
256 + </button>
257 + </span>
258 + );
259 +}
added apps/web/src/components/country/country-header.tsx +67 −0
@@ -0,0 +1,67 @@
1 +import { Download, Scale } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { compact, formatDate, formatPct, grouped, isNum } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import type { CountryResponse } from '@/lib/types';
7 +import { FreshnessBadge } from '@/components/data/freshness-badge';
8 +import { ShareButton } from './share-button';
9 +
10 +/**
11 + * Compact country header: flag, name, official name, capital · region · income group, population, area,
12 + * currency, freshness + coverage; primary actions Compare / Download / Share. No card: one rule below.
13 + */
14 +export function CountryHeader({ data }: { data: CountryResponse }) {
15 + const c = data.country;
16 + const name = c.name ?? c.id;
17 + const pop = data.headline.find((m) => m.indicator === 'population');
18 + const facts: Array<[string, string]> = [];
19 + if (c.capital) facts.push([t('country.capital'), c.capital]);
20 + if (c.region_name) facts.push([t('country.region'), c.region_name]);
21 + if (c.income_name) facts.push([t('country.incomeGroup'), c.income_name]);
22 + if (pop?.has_data && isNum(pop.value)) facts.push([t('country.population'), `${pop.formatted ?? compact(pop.value)}${pop.year ? ` (${pop.year})` : ''}`]);
23 + if (isNum(c.area_km2)) facts.push([t('country.area'), `${grouped(c.area_km2)} km²`]);
24 + if (c.currency_name) facts.push([t('country.currency'), `${c.currency_name}${c.currency_code ? ` (${c.currency_code})` : ''}`]);
25 +
26 + return (
27 + <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-10">
28 + <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
29 + <div className="min-w-0">
30 + <div className="flex items-start gap-3">
31 + <span aria-hidden className="text-4xl leading-none md:text-5xl">
32 + {c.flag}
33 + </span>
34 + <div className="min-w-0">
35 + <h1 className="display text-3xl leading-none text-ink md:text-5xl">{name}</h1>
36 + {c.official_name && c.official_name !== name ? <p className="mt-1.5 text-sm text-ink-2">{c.official_name}</p> : null}
37 + </div>
38 + </div>
39 + <dl className="mt-4 flex flex-wrap gap-x-5 gap-y-1.5 text-sm">
40 + {facts.map(([k, v]) => (
41 + <div key={k} className="flex items-baseline gap-1.5">
42 + <dt className="text-ink-3">{k}</dt>
43 + <dd className="tnum text-ink">{v}</dd>
44 + </div>
45 + ))}
46 + </dl>
47 + <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
48 + {data.freshness.retrieved_at ? <span className="tnum">{t('country.refreshed', { date: formatDate(data.freshness.built_at ?? data.freshness.retrieved_at) })}</span> : null}
49 + <FreshnessBadge retrievedAt={data.freshness.retrieved_at} now={Date.parse(data.meta.generated_at) || Date.now()} />
50 + {data.coverage ? <span className="tnum">{t('country.coverage', { pct: formatPct(data.coverage.coverage_pct), n: grouped(data.coverage.n_indicators ?? 0) })}</span> : null}
51 + {c.landlocked ? <span className="rounded-xs border border-rule px-1 py-px">{t('country.landlocked')}</span> : null}
52 + {c.status === 'territory' ? <span className="rounded-xs border border-rule px-1 py-px">{t('countries.territory')}</span> : null}
53 + </div>
54 + </div>
55 + <div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch">
56 + <Link href={routes.compare(c.slug ?? c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink" aria-label={t('country.compareWith', { name })}>
57 + <Scale size={15} aria-hidden /> {t('country.compare')}
58 + </Link>
59 + <a href={routes.countryDownload(c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2" title={t('country.downloadHint')}>
60 + <Download size={15} aria-hidden /> {t('country.download')}
61 + </a>
62 + <ShareButton title={t('country.title', { name })} />
63 + </div>
64 + </div>
65 + </header>
66 + );
67 +}
added apps/web/src/components/country/key-facts.tsx +34 −0
@@ -0,0 +1,34 @@
1 +'use client';
2 +import { Info } from 'lucide-react';
3 +import { t } from '@/i18n';
4 +import type { Insight } from '@/lib/types';
5 +import { useProvenance } from '@/components/data/provenance-context';
6 +
7 +/** Verified insights list; each ⓘ opens the provenance of the first indicator used by the insight. */
8 +export function KeyFacts({ items, country }: { items: Insight[]; country: { id: string; slug: string | null; name: string; flag?: string | null } }) {
9 + const { open } = useProvenance();
10 + if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('country.facts.none')}</p>;
11 + return (
12 + <ul className="divide-y divide-rule">
13 + {items.map((ins, i) => {
14 + const p = ins.provenance[0] ?? null;
15 + const slug = ins.indicators[0] ?? ins.template_id ?? 'insight';
16 + return (
17 + <li key={ins.id ?? i} className="flex items-start gap-3 py-3">
18 + <p className="min-w-0 flex-1 text-sm leading-snug text-ink md:text-base">{ins.text}</p>
19 + {p ? (
20 + <button
21 + type="button"
22 + onClick={() => open({ indicator: { slug, name: slug.replace(/-/g, ' '), format: null }, value: { value: null, period: null, provenance: p }, country })}
23 + className="tap -my-2 -mr-2 grid shrink-0 place-items-center text-ink-3 hover:text-accent"
24 + aria-label={t('common.openProvenance')}
25 + >
26 + <Info size={15} aria-hidden />
27 + </button>
28 + ) : null}
29 + </li>
30 + );
31 + })}
32 + </ul>
33 + );
34 +}
added apps/web/src/components/country/share-button.tsx +29 −0
@@ -0,0 +1,29 @@
1 +'use client';
2 +import { Check, Share2 } from 'lucide-react';
3 +import { useState } from 'react';
4 +import { t } from '@/i18n';
5 +
6 +/** Web Share API when available, otherwise copies the URL to the clipboard. */
7 +export function ShareButton({ title, className }: { title: string; className?: string }) {
8 + const [done, setDone] = useState(false);
9 + const share = async () => {
10 + const url = window.location.href;
11 + try {
12 + if (navigator.share) {
13 + await navigator.share({ title, url });
14 + return;
15 + }
16 + await navigator.clipboard.writeText(url);
17 + setDone(true);
18 + setTimeout(() => setDone(false), 1800);
19 + } catch {
20 + /* cancelled */
21 + }
22 + };
23 + return (
24 + <button type="button" onClick={share} className={`inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 ${className ?? ''}`} aria-live="polite">
25 + {done ? <Check size={15} aria-hidden className="text-up" /> : <Share2 size={15} aria-hidden />}
26 + {done ? t('common.copied') : t('common.share')}
27 + </button>
28 + );
29 +}
added apps/web/src/components/country/similar-panel.tsx +123 −0
@@ -0,0 +1,123 @@
1 +'use client';
2 +import { ChevronDown } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientApi } from '@/lib/client-api';
7 +import { cn } from '@/lib/cn';
8 +import { fixed } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import type { Contributions, SimilarResponse, SimilarityMode } from '@/lib/types';
11 +
12 +const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social'];
13 +
14 +/**
15 + * "Countries similar to X": mode tabs (overall/economic/…), peers with a score bar (0–100) and a "why"
16 + * expander listing the top contributing features. The server passes the `overall` payload; other modes are
17 + * fetched on demand via the same-origin API and cached in state.
18 + */
19 +export function SimilarPanel({ countryId, initial }: { countryId: string; initial: SimilarResponse | null }) {
20 + const [mode, setMode] = useState<SimilarityMode>('overall');
21 + const [data, setData] = useState<Partial<Record<SimilarityMode, SimilarResponse | null>>>({ overall: initial });
22 + const [loading, setLoading] = useState(false);
23 + const [openPeer, setOpenPeer] = useState<string | null>(null);
24 + const available = new Set((initial?.modes ?? MODES) as SimilarityMode[]);
25 +
26 + useEffect(() => {
27 + if (data[mode] !== undefined) return;
28 + const ctrl = new AbortController();
29 + setLoading(true);
30 + clientApi
31 + .countrySimilar(countryId, mode, ctrl.signal)
32 + .then((r) => setData((d) => ({ ...d, [mode]: r })))
33 + .catch(() => setData((d) => ({ ...d, [mode]: null })))
34 + .finally(() => setLoading(false));
35 + return () => ctrl.abort();
36 + }, [mode, countryId, data]);
37 +
38 + const current = data[mode];
39 + const peers = current?.peers ?? [];
40 +
41 + return (
42 + <div>
43 + <div role="tablist" aria-label={t('country.similar.title', { name: '' }).trim()} className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0">
44 + {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => (
45 + <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-9 shrink-0 items-center rounded-sm px-3 text-sm', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
46 + {t(`country.similar.mode.${m}` as const)}
47 + </button>
48 + ))}
49 + </div>
50 + <div className={cn('mt-3 min-h-[200px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}>
51 + {current === null || (current && peers.length === 0) ? (
52 + <p className="py-4 text-sm text-ink-3">{t('country.similar.none')}</p>
53 + ) : (
54 + <ol className="divide-y divide-rule">
55 + {peers.slice(0, 8).map((p) => {
56 + const key = `${mode}-${p.country.id}`;
57 + const open = openPeer === key;
58 + const contribs = parseContribs(p.contributions);
59 + return (
60 + <li key={p.country.id} className="py-2">
61 + <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_minmax(4rem,7rem)_3.5rem_2rem] items-center gap-x-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,12rem)_3.5rem_2rem]">
62 + <span className="tnum text-xs text-ink-3">{p.rank}</span>
63 + <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-w-0 items-center gap-1.5 text-sm">
64 + <span aria-hidden className="text-base leading-none">
65 + {p.country.flag}
66 + </span>
67 + <span className="truncate">{p.country.name}</span>
68 + </Link>
69 + <div className="h-2 rounded-xs bg-surface-2" aria-hidden>
70 + <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />
71 + </div>
72 + <span className="tnum text-right text-sm font-medium text-ink" aria-label={t('country.similar.score', { score: fixed(p.score ?? 0, 0) })}>
73 + {p.score != null ? fixed(p.score, 0) : t('common.na')}
74 + </span>
75 + {contribs.length ? (
76 + <button type="button" onClick={() => setOpenPeer(open ? null : key)} aria-expanded={open} className="tap -mr-2 grid place-items-center text-ink-3 hover:text-ink" aria-label={t('common.why')}>
77 + <ChevronDown size={16} aria-hidden className={cn('transition-transform', open && 'rotate-180')} />
78 + </button>
79 + ) : (
80 + <span />
81 + )}
82 + </div>
83 + {open ? (
84 + <div className="mt-2 rounded-sm bg-surface-2/60 px-3 py-2 text-xs text-ink-2">
85 + <div className="eyebrow mb-1">{t('country.similar.why')}</div>
86 + <ul className="grid gap-x-6 gap-y-0.5 sm:grid-cols-2">
87 + {contribs.slice(0, 6).map((c) => (
88 + <li key={c.indicator} className="flex justify-between gap-2 tnum">
89 + <span className="truncate">{c.indicator.replace(/-/g, ' ')}</span>
90 + <span className="text-ink-3">
91 + z {fmtZ(c.z_a)} vs {fmtZ(c.z_b)}
92 + </span>
93 + </li>
94 + ))}
95 + </ul>
96 + </div>
97 + ) : null}
98 + </li>
99 + );
100 + })}
101 + </ol>
102 + )}
103 + </div>
104 + </div>
105 + );
106 +}
107 +
108 +function fmtZ(v: number | null | undefined): string {
109 + return v == null ? '—' : (v >= 0 ? '+' : '−') + fixed(Math.abs(v), 1);
110 +}
111 +
112 +function parseContribs(raw: Contributions | string | null): Array<{ indicator: string; z_a: number | null; z_b: number | null; contribution: number }> {
113 + if (!raw) return [];
114 + let obj: Contributions;
115 + try {
116 + obj = typeof raw === 'string' ? (JSON.parse(raw) as Contributions) : raw;
117 + } catch {
118 + return [];
119 + }
120 + return Object.entries(obj)
121 + .map(([indicator, c]) => ({ indicator, z_a: c.z_a ?? null, z_b: c.z_b ?? null, contribution: c.contribution ?? 0 }))
122 + .sort((a, b) => Math.abs(a.contribution) - Math.abs(b.contribution)); // smallest distance contribution = most similar
123 +}
added apps/web/src/components/home/compare-teaser.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import { ArrowRight } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { routes } from '@/lib/site';
5 +import type { CountrySummary } from '@/lib/types';
6 +
7 +export const COMPARE_PRESETS: Array<[string, string]> = [
8 + ['canada', 'united-states'],
9 + ['france', 'germany'],
10 + ['japan', 'south-korea'],
11 + ['brazil', 'mexico'],
12 + ['india', 'china'],
13 + ['nigeria', 'south-africa'],
14 + ['australia', 'united-kingdom'],
15 + ['sweden', 'norway'],
16 +];
17 +
18 +/** Preset pairs linking to /compare/a/b (built by the next agent). Uses the countries list for flags/names. */
19 +export function CompareTeaser({ countries }: { countries: CountrySummary[] }) {
20 + const bySlug = new Map(countries.map((c) => [c.slug, c]));
21 + const pairs = COMPARE_PRESETS.map(([a, b]) => [bySlug.get(a), bySlug.get(b)] as const).filter((p): p is [CountrySummary, CountrySummary] => !!p[0] && !!p[1]);
22 + return (
23 + <div>
24 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-4">
25 + {pairs.map(([a, b]) => (
26 + <li key={`${a.slug}-${b.slug}`} className="border-t border-rule">
27 + <Link href={routes.compare(a.slug ?? a.id, b.slug ?? b.id)} className="group flex min-h-[56px] items-center gap-2 py-2.5 text-sm">
28 + <span aria-hidden className="text-lg leading-none">
29 + {a.flag}
30 + </span>
31 + <span className="truncate text-ink group-hover:text-accent">{a.name}</span>
32 + <span className="text-ink-3">vs</span>
33 + <span aria-hidden className="text-lg leading-none">
34 + {b.flag}
35 + </span>
36 + <span className="truncate text-ink group-hover:text-accent">{b.name}</span>
37 + <ArrowRight size={14} aria-hidden className="ml-auto shrink-0 text-ink-3 group-hover:text-accent" />
38 + </Link>
39 + </li>
40 + ))}
41 + </ul>
42 + <Link href={routes.compare()} className="mt-3 inline-flex min-h-[40px] items-center text-sm text-accent hover:underline">
43 + {t('home.compare.custom')} →
44 + </Link>
45 + </div>
46 + );
47 +}
added apps/web/src/components/home/featured-indicators.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { formatDate, formatPct, grouped } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import { topicById } from '@/lib/topics';
6 +import type { IndicatorSummary } from '@/lib/types';
7 +
8 +/** Featured / recently updated indicators as a dense list (name, topic, coverage, last year). */
9 +export function IndicatorList({ items, showUpdated = false, limit = 12 }: { items: IndicatorSummary[]; showUpdated?: boolean; limit?: number }) {
10 + if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('common.noDataLong')}</p>;
11 + return (
12 + <ul className="divide-y divide-rule">
13 + {items.slice(0, limit).map((ind) => (
14 + <li key={ind.id}>
15 + <Link href={routes.indicator(ind.slug)} className="group grid min-h-[48px] grid-cols-[1fr_auto] items-center gap-x-4 py-2">
16 + <span className="min-w-0">
17 + <span className="block truncate text-sm text-ink group-hover:text-accent">{ind.name ?? ind.slug}</span>
18 + <span className="block truncate text-xs text-ink-3">
19 + {topicById(ind.topic ?? '')?.short ?? ind.topic}
20 + {ind.unit ? ` · ${ind.unit}` : ''}
21 + </span>
22 + </span>
23 + <span className="tnum text-right text-xs text-ink-2">
24 + {showUpdated && ind.latest_source_updated_at ? <span className="block">{formatDate(ind.latest_source_updated_at)}</span> : null}
25 + {ind.n_countries != null ? <span className="block">{grouped(ind.n_countries)} countries</span> : null}
26 + {ind.coverage_pct != null && !showUpdated ? <span className="block text-ink-3">{formatPct(ind.coverage_pct)}</span> : ind.last_year ? <span className="block text-ink-3">→ {ind.last_year}</span> : null}
27 + </span>
28 + </Link>
29 + </li>
30 + ))}
31 + </ul>
32 + );
33 +}
added apps/web/src/components/home/hero.tsx +17 −0
@@ -0,0 +1,17 @@
1 +import { t } from '@/i18n';
2 +import { SearchTrigger } from '@/components/layout/search-trigger';
3 +
4 +/** Compact editorial hero: tagline + search box. No marketing block — data starts right below. */
5 +export function Hero() {
6 + return (
7 + <section className="pb-6 pt-8 md:pb-10 md:pt-14">
8 + <div className="max-w-3xl">
9 + <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('home.hero.title')}</h1>
10 + <p className="mt-3 max-w-2xl text-base text-ink-2 md:text-lg">{t('home.hero.sub')}</p>
11 + <div className="mt-5 max-w-xl">
12 + <SearchTrigger variant="hero" />
13 + </div>
14 + </div>
15 + </section>
16 + );
17 +}
added apps/web/src/components/home/region-chips.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { WB_REGIONS } from '@/lib/regions';
4 +import { routes } from '@/lib/site';
5 +
6 +/** Region chips → /countries?region=… (client-side filter on the directory). */
7 +export function RegionChips() {
8 + return (
9 + <ul className="scrollbar-none -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
10 + <li>
11 + <Link href={routes.countries()} className="inline-flex h-9 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink hover:border-accent hover:text-accent">
12 + {t('home.explore.allCountries')}
13 + </Link>
14 + </li>
15 + {WB_REGIONS.map((r) => (
16 + <li key={r.id}>
17 + <Link href={`${routes.countries()}?region=${r.id}`} className="inline-flex h-9 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent">
18 + {r.short}
19 + </Link>
20 + </li>
21 + ))}
22 + </ul>
23 + );
24 +}
added apps/web/src/components/home/snapshot-strip.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { t } from '@/i18n';
2 +import { compact, fixed, formatDate, grouped, isNum } from '@/lib/format';
3 +import type { GlobalSnapshot } from '@/lib/types';
4 +
5 +/** Global snapshot strip: 6 figures on one rule, wrapping to 2–3 columns on phones. */
6 +export function SnapshotStrip({ s }: { s: GlobalSnapshot }) {
7 + const cells: Array<{ label: string; value: string; sub?: string }> = [
8 + { label: t('home.snapshot.population'), value: s.world_population_formatted ?? (isNum(s.world_population) ? compact(s.world_population) : t('common.na')), sub: s.world_population_year ? String(s.world_population_year) : undefined },
9 + { label: t('home.snapshot.gdp'), value: s.world_gdp_formatted ?? (isNum(s.world_gdp) ? `US$${compact(s.world_gdp)}` : t('common.na')), sub: s.world_gdp_year ? String(s.world_gdp_year) : undefined },
10 + { label: t('home.snapshot.lifeExpectancy'), value: isNum(s.median_life_expectancy) ? `${fixed(s.median_life_expectancy, 1)} yrs` : t('common.na'), sub: s.median_life_expectancy_year ? String(s.median_life_expectancy_year) : undefined },
11 + { label: t('home.snapshot.countries'), value: grouped(s.n_countries + (s.n_territories ?? 0)), sub: `${grouped(s.n_countries)} countries` },
12 + { label: t('home.snapshot.indicators'), value: grouped(s.n_indicators), sub: s.n_indicators_with_data ? `${grouped(s.n_indicators_with_data)} with data` : undefined },
13 + { label: t('home.snapshot.observations'), value: compact(s.n_observations), sub: s.built_at ? t('home.snapshot.refreshed', { date: formatDate(s.built_at) }) : undefined },
14 + ];
15 + return (
16 + <section aria-label={t('home.snapshot.title')} className="border-y border-rule">
17 + <dl className="grid grid-cols-2 divide-rule sm:grid-cols-3 lg:grid-cols-6 lg:divide-x">
18 + {cells.map((c, i) => (
19 + <div key={c.label} className={`py-4 lg:px-4 ${i % 2 === 1 ? 'pl-4 sm:pl-0' : ''} ${i >= 2 ? 'border-t border-rule sm:border-t-0' : ''} ${i >= 3 ? 'sm:border-t sm:border-rule lg:border-t-0' : ''} lg:first:pl-0`}>
20 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{c.label}</dt>
21 + <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{c.value}</dd>
22 + {c.sub ? <dd className="tnum mt-1 text-xs text-ink-3">{c.sub}</dd> : null}
23 + </div>
24 + ))}
25 + </dl>
26 + </section>
27 + );
28 +}
added apps/web/src/components/home/topics-grid.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import Link from 'next/link';
2 +import { routes } from '@/lib/site';
3 +import { TOPICS } from '@/lib/topics';
4 +
5 +/** 19 topics with a one-line blurb, 1 px separators, linking to /indicators?topic=. */
6 +export function TopicsGrid() {
7 + return (
8 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
9 + {TOPICS.map((tp) => (
10 + <li key={tp.id} className="border-t border-rule">
11 + <Link href={routes.indicators(tp.id)} className="group flex min-h-[64px] flex-col justify-center py-3">
12 + <span className="text-sm font-semibold text-ink group-hover:text-accent">{tp.name}</span>
13 + <span className="mt-0.5 text-xs text-ink-2">{tp.blurb}</span>
14 + </Link>
15 + </li>
16 + ))}
17 + </ul>
18 + );
19 +}
modified apps/web/src/lib/client-api.ts +3 −1
@@ -1,5 +1,5 @@
1 1 'use client';
2 −import type { SearchResponse, SeriesResponse } from './types';
2 +import type { SearchResponse, SeriesResponse, SimilarResponse } from './types';
3 3
4 4 /**
5 5 * Browser-side fetch helpers: same-origin `/api/v1/*` (rewritten by next.config.ts to the FastAPI service).
@@ -26,4 +26,6 @@ export const clientApi = {
26 26 get<SearchResponse>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal),
27 27 countrySeries: (id: string, indicator: string, signal?: AbortSignal) =>
28 28 get<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, signal),
29 + countrySimilar: (id: string, mode: string, signal?: AbortSignal) =>
30 + get<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar?mode=${encodeURIComponent(mode)}`, signal),
29 31 };
added apps/web/src/lib/severity.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { t } from '@/i18n';
2 +
3 +export type SeverityLevel = 'high' | 'medium' | 'low';
4 +
5 +/** 0–1 severity → 3 levels (icon + label always accompany the colour). */
6 +export function severityLevel(s: number | null | undefined): SeverityLevel {
7 + if (s == null) return 'low';
8 + if (s >= 0.7) return 'high';
9 + if (s >= 0.4) return 'medium';
10 + return 'low';
11 +}
12 +
13 +export function severityLabel(s: number | null | undefined): string {
14 + return t(`change.severity.${severityLevel(s)}` as const);
15 +}
added docs/API.md +186 −0
@@ -0,0 +1,186 @@
1 +# CountryAtlas API — reference
2 +
3 +Base URL: `https://www.countryatlas.co/api/v1` (proxied by the Next.js app to the FastAPI process on `127.0.0.1:8291`).
4 +Interactive docs: [`/api/v1/docs`](https://www.countryatlas.co/api/v1/docs) (Swagger) · [`/api/v1/redoc`](https://www.countryatlas.co/api/v1/redoc) · spec `/api/v1/openapi.json`.
5 +
6 +* All endpoints are `GET` (except `POST /admin/refresh`, `POST /admin/cache/clear`) and return JSON (gzip when accepted).
7 +* Identifiers: countries by **ISO3 or slug** (case-insensitive: `CAN`, `can`, `canada`); indicators, topics and groups by **slug**.
8 +* Every response carries `meta: {built_at, run_id, generated_at}`; the header `X-CountryAtlas-Run` repeats the snapshot run id and
9 + `X-Cache: HIT|MISS` tells whether the in-process cache served it (cache keys include the run id, so a new snapshot invalidates everything).
10 +* Errors are RFC 7807 `application/problem+json`: `{"type","title","status","detail","instance",…}`.
11 + `404` unknown country/indicator/topic/group (with a helpful `detail`), `400` bad combination, `422` invalid parameter (with `errors[]`),
12 + `429` rate limit (120 req/min/IP, `Retry-After`), `503 {"title":"Data not built yet"}` while no snapshot exists.
13 +* Run locally: `CA_DATA_DIR=~/countryatlas-data ca-api` (or `python -m uvicorn countryatlas.api.main:app --port 8291`).
14 +
15 +## The provenance object
16 +
17 +Every value the API returns (headline metrics, series points, ranking rows, map values, curated lists, changes…) carries a `provenance`
18 +object built from `observations` → `indicator_sources` → `sources`:
19 +
20 +```json
21 +{
22 + "value": 55697.66, "period": "2025-01-01", "year": 2025, "unit": "current US$", "is_estimate": false, "is_forecast": false,
23 + "status": "imported", "formatted": "55.7k",
24 + "provenance": {
25 + "source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD",
26 + "retrieved_at": "2026-09-11T06:49:30Z", "source_updated_at": "2026-07-13T00:00:00Z",
27 + "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA", "transform": null, "licence": "CC BY 4.0"
28 + }
29 +}
30 +```
31 +
32 +| field | meaning |
33 +|---|---|
34 +| `source` / `source_name` | connector id (`worldbank`, `imf`, `oecd`, `eurostat`, `who`, `fred`, `owid`, `bis`, `ilo`) and display name |
35 +| `dataset`, `series_code` | dataset and series inside the source (WDI `NY.GDP.PCAP.CD`, WEO `NGDPDPC`, OWID `co2_per_capita`, FRED `FEDFUNDS`…) |
36 +| `retrieved_at` | when the pipeline fetched the raw payload (UTC) |
37 +| `source_updated_at` | last update advertised by the source (may be `null` when the source does not publish it) |
38 +| `url` | deep link: World Bank `…/indicator/{code}?locations={iso2}`, OWID grapher `ourworldindata.org/grapher/{slug}` or the `owid/co2-data` / `owid/energy-data` repos, Eurostat databrowser, WHO GHO indicator page, FRED series page, IMF / OECD / BIS / ILO data portals |
39 +| `transform` | expression applied at normalisation (e.g. `x*1e9`), `null` if none |
40 +| `licence` | licence of the source |
41 +
42 +Series responses also list **all** sources used (`sources[]`, with `n_values`) and the dominant one as `provenance`.
43 +
44 +## Endpoints
45 +
46 +### Health
47 +```
48 +GET /health → {status: "ok"|"empty", run_id, built_at, observations, countries, indicators, version, cache}
49 +```
50 +`/health` never returns 503; `status: "empty"` means the pipeline has not produced `atlas.duckdb` yet.
51 +
52 +### Countries
53 +```
54 +GET /countries?region=&income=&q=&sort=name|population|gdp|gdp_per_capita|coverage&kind=&limit=&offset=
55 +GET /countries/{id} overview: country header, groups, coverage, freshness, headline metrics, topics summary, neighbours
56 +GET /countries/{id}/topics/{topic} indicators of a topic grouped by subtopic (registry order); indicators without data → has_data:false
57 +GET /countries/{id}/series/{indicator}?from=&to=&freq=A|Q|M&include_forecast=true&include_alt=false
58 +GET /countries/{id}/changes?limit=&kind= recent detected changes (by severity)
59 +GET /countries/{id}/events?limit=&kind=&indicator= whole-history timeline
60 +GET /countries/{id}/similar?mode=overall|economic|demographic|energy|social&limit=
61 +GET /countries/{id}/insights
62 +GET /countries/{id}/dna
63 +GET /countries/{id}/download.csv|json?include_forecast=&topic=
64 +```
65 +`region` accepts any group id or slug (`ecs`, `europe-central-asia`, `oecd`, `g7`…); `income` accepts `HIC|UMC|LMC|LIC` or the slug.
66 +
67 +```bash
68 +curl -s https://www.countryatlas.co/api/v1/countries/canada | jq '.headline[] | {indicator, formatted, year, rank_world, n_world, source: .provenance.source}'
69 +# {"indicator":"gdp-per-capita","formatted":"55.7k","year":2025,"rank_world":21,"n_world":188,"source":"worldbank"} …
70 +curl -s "https://www.countryatlas.co/api/v1/countries/CAN/series/gdp-per-capita?from=2000" | jq '.stats, .provenance'
71 +```
72 +
73 +Headline metric object (`MetricValue`): `value, formatted, period, year, unit, is_estimate, is_forecast, status, prev{period,value},
74 +change{abs,pct,formatted}, change_10y{…}, rank_world/n_world, rank_region/n_region (World Bank region), rank_income/n_income, rank_year,
75 +rank_is_stale (rank computed on a year > 2 years older than the indicator's latest year), higher_is_better, sparkline [[year, value]…]
76 +(last 30 non-forecast points), provenance`.
77 +
78 +### Indicators
79 +```
80 +GET /indicators?topic=&q=&featured=&source=&with_data=
81 +GET /indicators/{slug} definition, sources (priority order, deep links), coverage (+ by_year), world_latest, freshness, top5/bottom5, years, topics
82 +GET /indicators/{slug}/map?year=&nearest=false&classes=6
83 +GET /indicators/{slug}/trend?group=world&from=&to=&min_n=5
84 +GET /indicators/{slug}/download.csv|json?from=&to=&include_forecast=
85 +```
86 +* `world_latest` is **computed across countries** (World Bank aggregates such as WLD are not stored): `kind` is `sum` for additive indicators,
87 + `weighted_mean` (population-weighted) for per-capita / share indicators, `median` otherwise; `median`, `mean`, `weighted_mean` and `n` are always returned.
88 +* `map`: without `year`, the latest year with ≥ 50 countries is used (`year_used`). With `nearest=true` each country's latest value within
89 + 3 years of the reference year is used and `years{ISO3: year}` says which. `legend.breaks` are quantile breaks (5–7 classes).
90 +* `trend`: per year `median`, `mean`, `weighted_mean` (population) for per-capita/percent indicators, `sum` for additive ones, `n`; `preferred` says which to plot.
91 +
92 +```bash
93 +curl -s "https://www.countryatlas.co/api/v1/indicators/life-expectancy/map?year=2023" | jq '{year_used, n, legend, CAN: .values.CAN}'
94 +curl -s "https://www.countryatlas.co/api/v1/indicators/gdp/trend?group=oecd" | jq '.points[-1]'
95 +```
96 +
97 +### Series bundle
98 +```
99 +GET /series?country=CAN,FRA&indicator=gdp-per-capita,inflation&from=1990&to=2026&freq=A&include_forecast=true
100 +```
101 +Returns `series[]` (one per indicator × country), each with `values[]` (per-point provenance), `sources[]`, `provenance`, `stats{min,max,first,last,cagr}`.
102 +
103 +### Rankings
104 +```
105 +GET /rankings?topic= rankable indicators (featured first) with ranking_year / ranking_n
106 +GET /rankings/{indicator}?year=&group=world&sort=asc|desc&limit=50&offset=0&sparkline=true
107 +GET /rankings/{indicator}/history?countries=CAN,USA&from=&to=
108 +```
109 +Rows: `rank` (within the requested group), `rank_world/n_world` (from the snapshot), `pct_rank`, `country{id,slug,name,flag,region,income}`,
110 +`value`, `formatted`, `year`, `change_1y`, `change_10y`, `sparkline`, `provenance`. Default `sort` is `asc` when `higher_is_better=false`
111 +(e.g. infant mortality), otherwise `desc`. `year_used` falls back to the nearest available year; `years_available` lists them.
112 +
113 +```bash
114 +curl -s "https://www.countryatlas.co/api/v1/rankings/gdp-per-capita?limit=3" | jq '.rows[] | [.rank, .country.name, .formatted, .change_1y.formatted]'
115 +# [1,"Luxembourg","147.3k","+6.9 %"] [2,"Ireland","131.6k","+16.6 %"] [3,"Switzerland","114.8k","+6.6 %"]
116 +```
117 +
118 +### Compare
119 +```
120 +GET /compare?countries=CAN,USA,FRA&indicators=gdp,gdp-per-capita&from=&to=&mode=absolute|per-capita|index100|pct&include_forecast=
121 +GET /compare/snapshot?countries=CAN,USA&topic=economy (or &indicators=a,b; default = headline indicators)
122 +GET /compare/download.csv|json?countries=&indicators=&from=&to=
123 +```
124 +Modes: `index100` rebases each series to 100 at the first available year ≥ `from`; `per-capita` divides additive (`aggregation=sum`)
125 +indicators by the `population` series of the same year (already per-capita indicators are left unchanged, `transform.applied=false`);
126 +`pct` = % change vs previous period. `snapshot` returns one row per indicator with a `values{ISO3: MetricValue}` map and `best` (when `higher_is_better` is known).
127 +
128 +### Regions / groups
129 +```
130 +GET /regions?kind=world|region|continent|income|org
131 +GET /regions/{slug}?indicator=gdp-per-capita&sort=asc|desc
132 +```
133 +Group page: members with headline values (+ provenance), `aggregates` (sum population/GDP, population-weighted GDP per capita, median life
134 +expectancy…, each with `kind`), and a member ranking on the chosen indicator.
135 +
136 +### Search
137 +```
138 +GET /search?q=&limit=10&type=country|indicator|topic|region|source
139 +```
140 +Typed hits `{type, id, slug, name, hint, score, url}` from `search_index` (exact → prefix → word → substring → Jaro-Winkler fuzzy).
141 +Hints: `Country · North America`, `Indicator · Economy · annual %`, `Topic · 12 indicators`, `Region · Organisation · 38 members`.
142 +Two-word combos such as `housing canada` or `canada gdp` also return `country_topic` / `country_indicator` hits with a ready URL
143 +(`/countries/canada/housing`).
144 +
145 +### Home
146 +```
147 +GET /home
148 +```
149 +`snapshot` (world population / GDP sums, median life expectancy, counts, built_at), `lists` (largest economies, fastest population growth
150 +among countries > 1 M, highest life expectancy, energy transition leaders, highest GDP per capita PPP, lowest unemployment among countries
151 +> 5 M; 8 rows each with provenance), `recent_changes` (12 most severe), `recently_updated`, `featured_indicators`, `trending`.
152 +
153 +### Changes, sources, methodology
154 +```
155 +GET /changes?limit=&offset=&kind=&indicator=&country=&topic=&min_severity=
156 +GET /sources n_observations, n_indicators, licence, last_retrieved_at
157 +GET /sources/{id} indicators mapped, datasets, import_runs, freshness
158 +GET /methodology registry-derived: topics, units/formats, source priority rule + URL patterns, validation rules, derived computations, DNA dimensions
159 +```
160 +
161 +### Downloads
162 +CSV is streamed with a leading `# CountryAtlas export · run … · built …` comment line and the columns
163 +`country_id, country_name, indicator_id, indicator_name, period, year, frequency, value, unit, is_estimate, is_forecast, status, source,
164 +source_name, dataset, series_code, retrieved_at, source_updated_at, url, licence`. JSON returns `{meta, n, columns, rows}`.
165 +
166 +### Admin (header `X-Admin-Token: $CA_ADMIN_TOKEN`)
167 +```
168 +GET /admin/overview db, meta, table counts, connectors health (import_runs), sources freshness/stale counts, scheduler heartbeat (data_dir/scheduler.json + scheduler.pid), cache stats
169 +GET /admin/runs?limit=&connector=&status=
170 +GET /admin/issues?severity=&connector=&indicator=&code=&run_id=&limit=
171 +GET /admin/coverage indicator × n_countries / last_year matrix + per-country coverage
172 +GET /admin/raw?run_id= raw files stored for a run
173 +POST /admin/refresh sends SIGUSR1 to the scheduler pid (409 when no scheduler)
174 +POST /admin/cache/clear
175 +```
176 +`403` on a wrong/missing token, `503 {"title":"Admin disabled"}` when `CA_ADMIN_TOKEN` is not set.
177 +
178 +## Operations notes
179 +
180 +* **Snapshot swap**: the API opens `~/countryatlas-data/atlas.duckdb` read-only and compares `st_ino`/`st_mtime_ns` on every request; when
181 + the pipeline `os.replace()`s a new file, the old connection is closed and the new one opened (DuckDB caches instances per path, so the
182 + close must happen first). Responses are cached in-process per `(run_id, path, query)`; nothing survives a new run id.
183 +* **Performance** (real snapshot, 2.0 M observations): country overview ≈ 12 ms warm (≈ 170 ms on the very first request while static
184 + lookups load), rankings ≈ 20 ms, home ≈ 33 ms, map ≈ 4 ms, cached hits ≈ 1 ms.
185 +* **Formatting**: `formatted` strings use the indicator's `format`: currency/number compact (`53.4k`, `1.2T`), percent `3.4 %`, years
186 + `82.1 yrs`, tonnes `5.2 t`, per-1000 `3.2 per 1,000`, per-100k `1.2 per 100k`.
modified docs/ARCHITECTURE.md +54 −0
@@ -182,6 +182,60 @@ Unusual values are never deleted, only flagged.
182 182 trade (trade % GDP), energy (energy use pc), emissions (CO₂ pc), innovation (R&D % GDP, patents pc), education
183 183 (tertiary enrolment, expected years), public spending (gov. expenditure % GDP). Descriptive, not a score.
184 184
185 +### 7.1 Pipeline implementation notes and deviations (as built, 2026-09-11 — see docs/PIPELINE.md)
186 +
187 +No table or column of `schema.sql` was changed. The following precisions/deviations from §2, §5–§7 are binding for the API:
188 +
189 +* **Staging granularity** is one parquet per *source spec* `(connector, dataset, code, indicator)` —
190 + `staging/<connector>/<dataset>__<code>__<indicator>.parquet` — not one per dataset. Error isolation, quarantine and the
191 + "keep the previous file" rule apply per spec. `import_runs` holds the **latest attempt per spec** (not the full history);
192 + `import_runs.dataset` is `"<dataset>:<code>→<indicator>"`; `rows_raw` is the raw payload size in bytes.
193 +* **Quarantined rows stay in `observations`** (never deleted) but are excluded from every derived table (`latest`,
194 + `rankings`, `changes`, `events`, `similarity`, `insights`, `country_dna`). A lower-priority source is *not* promoted when
195 + the primary row is quarantined (the value is flagged, not replaced). `observations_alt` = all losing rows of the priority
196 + race, whatever their status.
197 +* **Stale** is evaluated on the *end* of the period (annual 2024 → 2024-12-31) of each country's latest observation, not on
198 + `source_updated_at` (the WDI vintage date says nothing about a country whose series stops in 2019); only that latest row is
199 + flagged `stale`. With 800 days, an annual series ending in 2023 is stale in September 2026, one ending in 2024 is not.
200 +* **Extreme jump**: positive level series (formats currency/number/tonnes/kwh/per_*/km/ha with lower bound ≥ 0) are
201 + compared on log-differences; others on absolute differences. Threshold = `jump_threshold` × 1.4826·MAD with a floor
202 + (10 % relative, or 2 % of the country's series range) to avoid flagging smooth series; ≥ 5 points required. ~5 % of WDI
203 + rows carry `warning`.
204 +* **`latest` ranks** are computed among *all* `kind='country'` values of the same indicator and the same **year** as the
205 + country's latest observation (not only among countries whose latest year coincides); `latest.rank_year` = that year, the
206 + API compares it with the indicator's max year. Q/M series use the last period of each year. `rank_income` is NULL when the
207 + country has no income group. `change_10y_*` compares with the observation exactly 10 years earlier (same frequency).
208 +* **Ranking direction**: rank 1 = lowest value when `higher_is_better = false`, otherwise the highest value — i.e. "best"
209 + when `higher_is_better` is set, "highest" when it is null. `rankings.pct_rank = 1 − (rank − 1)/(n − 1)` (1.0 = rank 1).
210 + `rankings` includes every year with ≥ 20 countries for `ranking_eligible` indicators.
211 +* **Changes / events**: working scale = points for percent-like indicators, log-differences (reported as % change) for
212 + positive level series, absolute otherwise. z = (Δ − median)/max(1.4826·MAD, 0.25 × floor); a move must also clear the
213 + floor (`change_floor` in the indicator's unit; default 5 % relative or 2 % of the series range). `severity = 0.6·min(1, |z|/4)
214 + + 0.4·min(1, |Δ|/(2·floor))` for YoY moves; records 0.6–1.0; N-year highs/lows 0.4–0.6; sign flips 0.7;
215 + acceleration 0.4. `events` (whole history) contain YoY jumps/drops, records reached after a ≥ 5-year gap since the previous
216 + record (monotone series stay silent) and sign flips, at most 30 per series; `changes` add N-year highs/lows (N ∈ {10, 20, 30})
217 + and acceleration/deceleration (3 consecutive increases/decreases of the difference) at the latest period only.
218 + `id = sha1("changes|"+country+indicator+kind+period)[:16]` (`"events|"` for events). Headlines are English templates.
219 +* **Similarity**: features/weights/transforms in `registry/similarity.yaml`; z-scores from `latest` (mixed years allowed);
220 + a pair needs ≥ 50 % of the mode's weight in common (distance rescaled to full weight); `d0` = median pairwise distance of
221 + the mode; `contributions[feature].contribution` = share of the squared distance. `country_dna` dimensions are defined in
222 + the `dna:` section of the same file (percentile rank 0–100, `invert` for fertility); `year_ref` = max year of the inputs.
223 +* **Insights**: `registry/insights.yaml` (16 templates, kinds `change_since` / `rank_in_group` / `vs_median` / `avg_growth`),
224 + English text, `values` JSON keeps the raw numbers. `insights.id = sha1(country|template_id)[:16]`.
225 +* **Forecasts**: OWID grapher charts with a `*projected*` column (UN WPP) contribute `is_forecast=true` rows for years
226 + without an estimate, capped at current year + 6.
227 +* **World Bank**: `source=<id>` is added automatically when the indicator metadata says the series lives outside WDI
228 + (e.g. WGI = source 3, codes `GOV_WGI_*`); codes moved to "WDI Database Archives" (source 57) fail with a RETIRED hint and
229 + must be remapped in the registry. `lastupdated` (WDI vintage) → `source_updated_at`; `obs_status` E/F → estimate/forecast.
230 +* **Integrity**: the headline-coverage check (≥ 100 countries in `latest`) applies only to headline indicators that have
231 + staging data; `--no-strict` turns integrity failures into warnings (stored in `meta.integrity_warnings`).
232 +* **`meta` keys**: `schema_version`, `build_run_id`, `built_at`, `observation_count`, `observations_alt_count`,
233 + `indicator_count`, `country_count`, `latest_count`, `rankings_count`, `changes_count`, `events_count`, `insights_count`,
234 + `similarity_count`, `staging_files`, `connectors`, `build_duration_s`, `integrity_warnings`.
235 +* **`validation_issues`** are capped at 2 000 rows per rule per spec (5 000 per spec file) to stay small; counts are exact in
236 + `import_runs.warnings/errors`.
237 +* `ca sql "<query>"` was added to the CLI (read-only DuckDB); `ca normalize` re-reads the newest raw files per spec.
238 +
185 239 ## 8. API (FastAPI, `/api/v1`, OpenAPI at `/api/v1/openapi.json`, docs at `/api/v1/docs`)
186 240
187 241 Every response carries `meta: {built_at, run_id, generated_at}`; every value object carries provenance:
added docs/PIPELINE.md +138 −0
@@ -0,0 +1,138 @@
1 +# CountryAtlas data pipeline (`ca`)
2 +
3 +The pipeline turns external statistical series into one read-only DuckDB snapshot (`atlas.duckdb`) that the API
4 +serves. It is deterministic, idempotent and never edits the live database in place (ARCHITECTURE §2 snapshot swap).
5 +
6 +```
7 +registry/*.yaml ──┐
8 + ▼
9 + connectors ──► raw/ (gzip, forever) ──► normalize ──► validate ──► staging/<connector>/<spec>.parquet
10 + │
11 + build ◄────────────────────────────────────────────┘
12 + │ registry tables → merge by priority → revisions → latest/rankings/coverage
13 + │ → changes/events → similarity/DNA → insights → search_index → meta → integrity
14 + ▼
15 + build/atlas-<run_id>.duckdb ──os.replace──► atlas.duckdb (+ snapshots/atlas-<run_id>.duckdb, keep 7)
16 +```
17 +
18 +## Commands
19 +
20 +| Command | What it does |
21 +|---|---|
22 +| `ca registry validate` | YAML sanity (unique slugs, topics, connectors, `similarity.yaml` / `insights.yaml` references). Exit 1 on hard errors. |
23 +| `ca fetch [-c worldbank] [-i gdp]` | Download → store raw → normalize → validate → staging, one unit of work per source spec, concurrently (`CA_HTTP_CONCURRENCY`, default 6). |
24 +| `ca normalize [-c X]` | Same, but re-reads the newest raw files instead of downloading (re-run after fixing a connector). |
25 +| `ca validate [-c X]` | Re-applies the generic rules to every staging file and rewrites statuses + `.issues.json`. |
26 +| `ca build [--no-strict] [--no-swap]` | New snapshot from staging; derived tables; integrity checks; atomic swap + snapshot copy. |
27 +| `ca refresh [-c X]` | `fetch` + `build` under one run id (what the scheduler runs). |
28 +| `ca schedule [--now]` | Loop: refresh daily at `CA_REFRESH_HOUR:CA_REFRESH_MINUTE` (03:15 `America/Toronto`); `kill -USR1 <pid>` refreshes now; heartbeat in `data_dir/scheduler.json`; logs in `logs/refresh-<date>.log`. |
29 +| `ca status` | Connectors (implemented, specs, staging files, last run, ok/failed), failed specs, snapshot `meta`, scheduler heartbeat. |
30 +| `ca export indicator <slug> -f csv\|json\|parquet` / `ca export country <ISO3>` | Files under `exports/`; the API reuses `countryatlas.pipeline.export`. |
31 +| `ca sql "select …"` / `ca sql` | Read-only DuckDB query / mini REPL on the live snapshot. |
32 +
33 +Everything runs with `.venv/bin/ca …`. Data lives in `CA_DATA_DIR` (default `~/countryatlas-data`).
34 +
35 +## Where files live (`CA_DATA_DIR`)
36 +
37 +```
38 +raw/<connector>/<dataset>/<YYYY-MM-DD>/<code>-<sha1[:10]>.{json|csv}.gz payload exactly as served (kept forever)
39 + <code>-<sha1[:10]>.{json|csv}.meta.json url, retrieved_at, source_updated_at, pages, meta
40 +staging/<connector>/<dataset>__<code>__<indicator>.parquet NormalizedObservation columns (+ status), one file per spec
41 +staging/<connector>/<…>.run.json ImportRun of the LAST attempt (ok|partial|failed|quarantined)
42 +staging/<connector>/<…>.issues.json validation issues of the last successful write (capped)
43 +staging/<connector>/<…>.meta.json source_url, notes (sourceNote…), source_updated_at, licence
44 +build/atlas-<run_id>.duckdb work in progress (deleted on failure)
45 +atlas.duckdb live snapshot (API opens read-only)
46 +snapshots/atlas-<run_id>.duckdb last CA_KEEP_SNAPSHOTS (7) successful builds
47 +exports/indicators/<slug>.<fmt>, exports/countries/<ISO3>.<fmt>
48 +logs/refresh-<date>.log, scheduler.json
49 +```
50 +
51 +Shared OWID CSVs (`co2`, `energy`) are downloaded once per run and stored once (raw code `owid-co2-data` /
52 +`owid-energy-data`); `store_raw` de-duplicates by content hash, so re-running on the same day is free.
53 +
54 +## The unit of work: one `IndicatorSourceSpec`
55 +
56 +Each entry of `indicators[].sources` (plus `registry/sources/<connector>.yaml`) is processed in isolation
57 +(`pipeline/fetch.py::process_spec`):
58 +
59 +1. `connector.fetch(spec)` → one or more `RawPayload` (pagination inside the connector) → `store_raw`.
60 +2. `connector.normalize(raw, spec)` → `list[NormalizedObservation]` (ISO3 via `registry.lookup()`, aggregates dropped,
61 + `spec.transform` applied, `is_forecast` / `is_estimate` set).
62 +3. `connector.validate(rows)` — duplicates → dataset quarantined.
63 +4. Generic rules (`pipeline/validate.py`, below) → `status` per row, issues, possibly dataset quarantine.
64 +5. Atomic write of the parquet + sidecars.
65 +
66 +Any exception → `run.json` with `status: failed` and the message; the previous parquet stays. A connector whose module
67 +is not implemented yet is skipped with a warning (its specs are simply not staged). Retired World Bank codes are reported
68 +as `failed` with the API message and a "RETIRED … fix the registry mapping" hint.
69 +
70 +## Validation & quarantine
71 +
72 +Row statuses (`observations.status`): `imported` (default) · `warning` (extreme jump, kept) · `stale` (the country's
73 +latest period ended more than `stale_after_days` ago; only that latest row) · `quarantined` (outside registry `bounds`
74 +or non-finite). Nothing is deleted. Quarantined rows stay in `observations` for inspection but are **excluded from every
75 +derived table** (`latest`, `rankings`, `changes`, `events`, `similarity`, `insights`, `country_dna`).
76 +
77 +Dataset-level quarantine (`quarantine_dataset=True`): duplicates, unit ≠ registry unit, or fewer than 30 % of the rows
78 +of the previous staging file for the same spec. The new file is **not written**, the previous one is kept, and the
79 +`run.json` says `quarantined` with the reason. `ca status` lists these.
80 +
81 +Extreme jumps: positive level series (`format` in currency/number/tonnes/kwh/per_*/km/ha with a lower bound ≥ 0) are
82 +compared on log-differences, others on absolute differences; threshold = `jump_threshold` × 1.4826 × MAD of the
83 +country's differences, with a floor of 10 % (relative) or 2 % of the series range (absolute); at least 5 points.
84 +
85 +## Build (`pipeline/build.py`, ≈ 10 s for 2.3 M staging rows)
86 +
87 +1. `schema.sql` → registry tables (`countries`, `groups`, `group_members`, `sources`, `indicators`,
88 + `indicator_sources` incl. `source_url`/`notes` from the staging sidecars).
89 +2. All staging parquet files → `staging_all` (joined with `indicator_sources.priority`). For each
90 + `(country, indicator, period, frequency)` the row with the smallest priority (then source id) goes to `observations`,
91 + the rest to `observations_alt`. Forecast rows are kept (dashed on charts) but never enter derived tables.
92 +3. `observation_revisions`: the previous `atlas.duckdb` is attached read-only; every key whose value or source changed
93 + is recorded with the new `run_id`; the previous revisions table is copied over.
94 +4. `import_runs` (latest `run.json` per spec) and `validation_issues` (issues sidecars).
95 +5. Derived tables (`pipeline/derived.py`, SQL): `latest` (prev period, 10-year change, ranks within the same year among
96 + `kind='country'` — world / WB region / income group), `rankings` (ranking-eligible indicators, years with ≥ 20
97 + countries), `coverage`, indicator/source coverage columns, `search_index`.
98 + **Rank direction:** rank 1 = lowest value when `higher_is_better = false`, otherwise highest value ("best" when
99 + `higher_is_better` is set, "highest" when null). `pct_rank = 1 − (rank−1)/(n−1)`.
100 +6. `changes` / `events` (`pipeline/changes.py`, numpy per series, ≈ 45 k series in ~3 s) — see ARCHITECTURE §7 and the
101 + module docstring; headlines are English templates such as
102 + *"Inflation fell 3.4 points to 3.4 % in 2025 (largest drop since 2009)."*
103 +7. `similarity` (5 modes, `registry/similarity.yaml`) and `country_dna` (9 percentile dimensions, `dna:` section of the
104 + same file). 8. `insights` (`registry/insights.yaml`, 16 templates, all numbers computed).
105 +9. `meta` (schema_version, build_run_id, built_at, counts, connectors, duration) → `CHECKPOINT`.
106 +10. Integrity: ≥ 100 000 observations when World Bank staging exists; every headline indicator that has staging data
107 + has ≥ 100 countries in `latest`. Failure (or any exception) deletes the build file and leaves the live DB untouched.
108 +11. `os.replace` → `atlas.duckdb`, copy to `snapshots/`, prune to `CA_KEEP_SNAPSHOTS`.
109 +
110 +## Adding a connector
111 +
112 +1. Create `src/countryatlas/connectors/<id>.py` with a subclass of `countryatlas.connectors.base.Connector`:
113 +
114 + ```python
115 + class FooConnector(Connector):
116 + id = "foo"; name = "Foo Stats"; organization = "…"; url = "…"; licence = "…"; attribution = "…"
117 + api_base = "https://…"; rate_per_minute = 60; country_codes = "iso3"
118 +
119 + def fetch(self, spec: IndicatorSourceSpec) -> RawPayload | list[RawPayload]:
120 + r = self.get(f"{self.api_base}/{spec.code}", params=spec.params) # retries/backoff/rate limit built in
121 + return self.payload(r, dataset=spec.dataset, code=spec.code, source_url=…, notes=…)
122 +
123 + def normalize(self, raw, spec) -> list[NormalizedObservation]:
124 + lk = lookup(); unit = indicators_by_id()[spec.indicator_id].unit
125 + … iso3 = lk.from_iso3(code) / from_iso2 / from_name … (None → drop)
126 + … value = self.apply_transform(float(v), spec.transform) …
127 + ```
128 + Put `source_url` / `notes` in `RawPayload.meta` — the pipeline copies them into `indicator_sources`. Raise
129 + `countryatlas.connectors._util.ConnectorError` for non-transient problems (unknown code, empty dataset). If one raw
130 + file serves many specs, cache it in the instance and implement `raw_code_for(spec)` so `ca normalize` can find it.
131 +2. Add one line to `CONNECTORS` in `src/countryatlas/connectors/__init__.py` (`"foo": "countryatlas.connectors.foo:FooConnector"`).
132 + The package is also scanned for `Connector` subclasses, so a missing entry is not fatal.
133 +3. Map indicators: `sources:` entries in `registry/indicators.yaml` **or** a `registry/sources/foo.yaml` file
134 + (`sources: [{indicator, dataset, code, params, priority, transform, countries, frequency, notes}]`). Add `foo` to
135 + `registry.CONNECTORS` if it is a new id.
136 +4. `ca fetch -c foo -i <one-indicator>` → check the staging parquet, then `ca build` and `ca status`.
137 +
138 +Tests: `.venv/bin/python -m pytest tests/` (fixtures in `tests/fixtures/` are small recorded subsets of real payloads).
modified registry/indicators.yaml +9 −11
@@ -564,7 +564,7 @@ indicators:
564 564 bounds: [-3, 3]
565 565 description: Worldwide Governance Indicators — perceptions of the quality of public services and policy implementation.
566 566 sources:
567 − - {connector: worldbank, dataset: WDI, code: GE.EST, priority: 1}
567 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_GE.EST, priority: 1, params: {source: 3}}
568 568 - slug: control-of-corruption
569 569 name: Control of corruption (WGI estimate)
570 570 topic: government
@@ -576,7 +576,7 @@ indicators:
576 576 higher_is_better: true
577 577 bounds: [-3, 3]
578 578 sources:
579 − - {connector: worldbank, dataset: WDI, code: CC.EST, priority: 1}
579 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_CC.EST, priority: 1, params: {source: 3}}
580 580 - slug: rule-of-law
581 581 name: Rule of law (WGI estimate)
582 582 topic: government
@@ -588,7 +588,7 @@ indicators:
588 588 higher_is_better: true
589 589 bounds: [-3, 3]
590 590 sources:
591 − - {connector: worldbank, dataset: WDI, code: RL.EST, priority: 1}
591 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_RL.EST, priority: 1, params: {source: 3}}
592 592 - slug: political-stability
593 593 name: Political stability and absence of violence (WGI estimate)
594 594 short_name: Political stability
@@ -601,7 +601,7 @@ indicators:
601 601 higher_is_better: true
602 602 bounds: [-3, 3]
603 603 sources:
604 − - {connector: worldbank, dataset: WDI, code: PV.EST, priority: 1}
604 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_PV.EST, priority: 1, params: {source: 3}}
605 605 - slug: voice-and-accountability
606 606 name: Voice and accountability (WGI estimate)
607 607 topic: security
@@ -613,7 +613,7 @@ indicators:
613 613 higher_is_better: true
614 614 bounds: [-3, 3]
615 615 sources:
616 − - {connector: worldbank, dataset: WDI, code: VA.EST, priority: 1}
616 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_VA.EST, priority: 1, params: {source: 3}}
617 617 - slug: regulatory-quality
618 618 name: Regulatory quality (WGI estimate)
619 619 topic: security
@@ -625,7 +625,7 @@ indicators:
625 625 higher_is_better: true
626 626 bounds: [-3, 3]
627 627 sources:
628 − - {connector: worldbank, dataset: WDI, code: RQ.EST, priority: 1}
628 + - {connector: worldbank, dataset: WGI, code: GOV_WGI_RQ.EST, priority: 1, params: {source: 3}}
629 629
630 630 # ========================================================= POPULATION ==========================================================
631 631 - slug: population
@@ -847,8 +847,7 @@ indicators:
847 847 precision: 0
848 848 aggregation: sum
849 849 bounds: [0, null]
850 − sources:
851 − - {connector: worldbank, dataset: WDI, code: SM.POP.REFG.OR, priority: 1}
850 + sources: [] # WB SM.POP.REFG.OR archived → owid grapher (registry/sources/owid.yaml)
852 851 - slug: internally-displaced-persons
853 852 name: Internally displaced persons (conflict and violence)
854 853 topic: security
@@ -859,8 +858,7 @@ indicators:
859 858 precision: 0
860 859 aggregation: sum
861 860 bounds: [0, null]
862 − sources:
863 − - {connector: worldbank, dataset: WDI, code: VC.IDP.TOCV, priority: 1}
861 + sources: [] # WB VC.IDP.TOCV archived (no successor)
864 862 - slug: life-expectancy
865 863 name: Life expectancy at birth
866 864 short_name: Life expectancy
@@ -2813,7 +2811,7 @@ indicators:
2813 2811 aggregation: sum
2814 2812 bounds: [0, null]
2815 2813 sources:
2816 − - {connector: worldbank, dataset: WDI, code: IP.TMK.TOTL, priority: 1}
2814 + - {connector: worldbank, dataset: WDI, code: IP.TMK.RSCT, priority: 1, notes: 'resident applications by count (IP.TMK.TOTL archived)'}
2817 2815
2818 2816 # ========================================================== AGRICULTURE ========================================================
2819 2817 - slug: arable-land-per-capita
modified registry/insights.yaml +1 −1
@@ -100,4 +100,4 @@ templates:
100 100 indicator: fertility-rate
101 101 since: 2000
102 102 verb_style: rise
103 − text: "The fertility rate {verb} from {v0} children per woman in {y0} to {v1} in {y1}."
103 + text: "The fertility rate {verb} from {v0} in {y0} to {v1} in {y1}."
modified src/countryatlas/api/routers/changes.py +4 −7
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
8 8 from countryatlas.api import schemas
9 9 from countryatlas.api.common import meta_block, resolve_country, resolve_indicator
10 10 from countryatlas.api.db import Snapshot, get_snapshot
11 −from countryatlas.api.routers.countries import CHANGES_SQL, change_item
11 +from countryatlas.api.routers.countries import change_item, query_changes
12 12
13 13 router = APIRouter(tags=["changes"])
14 14
@@ -17,7 +17,7 @@ router = APIRouter(tags=["changes"])
17 17 def list_changes(limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), kind: str | None = Query(None),
18 18 indicator: str | None = Query(None), country: str | None = Query(None), topic: str | None = Query(None),
19 19 min_severity: float | None = Query(None, ge=0, le=1), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
20 − where, params = ["coalesce(c.kind, 'country') = 'country'"], []
20 + where, params = [], []
21 21 if kind:
22 22 where.append("x.kind = ?")
23 23 params.append(kind)
@@ -33,10 +33,7 @@ def list_changes(limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge
33 33 if min_severity is not None:
34 34 where.append("x.severity >= ?")
35 35 params.append(min_severity)
36 − rows = snap.query(
37 − CHANGES_SQL.format(table="changes") + " JOIN countries c ON c.id = x.country_id "
38 − + f"WHERE {' AND '.join(where)} ORDER BY x.severity DESC NULLS LAST, x.period DESC, x.country_id LIMIT ? OFFSET ?",
39 − [*params, limit, offset],
40 − )
36 + rows = query_changes(snap, "changes", where, params, "x.severity DESC NULLS LAST, x.period DESC, x.country_id", limit, offset,
37 + countries_only=True)
41 38 kinds = [r[0] for r in snap.query_rows("SELECT DISTINCT kind FROM changes ORDER BY kind")]
42 39 return {"meta": meta_block(snap), "n": len(rows), "kinds": kinds, "items": [change_item(snap, r) for r in rows]}
modified src/countryatlas/api/routers/countries.py +19 −10
@@ -326,12 +326,23 @@ def change_item(snap: Snapshot, r: dict[str, Any], with_country: bool = True) ->
326 326 }
327 327
328 328
329 −CHANGES_SQL = """
330 −SELECT x.*, o.source_id, o.source_dataset, o.source_series_code, o.retrieved_at, o.source_updated_at
331 −FROM {table} x
332 −LEFT JOIN observations o ON o.country_id = x.country_id AND o.indicator_id = x.indicator_id AND o.period = x.period
333 − AND o.frequency = 'A'
334 −"""
329 +def query_changes(snap: Snapshot, table: str, where: list[str], params: list[Any], order: str, limit: int, offset: int = 0,
330 + countries_only: bool = False) -> list[dict[str, Any]]:
331 + """Filter + order + page the `changes`/`events` rows FIRST, then attach provenance from the observation at the same period.
332 +
333 + `changes`/`events` carry no frequency: monthly/quarterly periods only exist at their own frequency; for YYYY-01-01 an
334 + annual row is preferred (QUALIFY). Joining after LIMIT keeps the feed fast on 2 M observations.
335 + """
336 + join_c = " JOIN countries c ON c.id = x.country_id AND coalesce(c.kind, 'country') = 'country'" if countries_only else ""
337 + where_sql = f" WHERE {' AND '.join(where)}" if where else ""
338 + sql = f"""
339 + WITH x AS (SELECT x.* FROM {table} x{join_c}{where_sql} ORDER BY {order} LIMIT ? OFFSET ?)
340 + SELECT x.*, o.source_id, o.source_dataset, o.source_series_code, o.retrieved_at, o.source_updated_at
341 + FROM x LEFT JOIN observations o ON o.country_id = x.country_id AND o.indicator_id = x.indicator_id AND o.period = x.period
342 + QUALIFY row_number() OVER (PARTITION BY x.id ORDER BY CASE o.frequency WHEN 'A' THEN 0 WHEN 'Q' THEN 1 ELSE 2 END) = 1
343 + ORDER BY {order}
344 + """
345 + return snap.query(sql, [*params, limit, offset])
335 346
336 347
337 348 @router.get("/{id}/changes", response_model=schemas.ChangesResponse, summary="What changed recently for this country")
@@ -342,8 +353,7 @@ def get_country_changes(id: str, limit: int = Query(50, ge=1, le=500), kind: str
342 353 if kind:
343 354 where.append("x.kind = ?")
344 355 params.append(kind)
345 − rows = snap.query(CHANGES_SQL.format(table="changes") + f" WHERE {' AND '.join(where)} ORDER BY x.severity DESC NULLS LAST, x.period DESC LIMIT ?",
346 − [*params, limit])
356 + rows = query_changes(snap, "changes", where, params, "x.severity DESC NULLS LAST, x.period DESC", limit)
347 357 return {"meta": meta_block(snap), "n": len(rows), "items": [change_item(snap, r, with_country=False) for r in rows]}
348 358
349 359
@@ -358,8 +368,7 @@ def get_country_events(id: str, limit: int = Query(100, ge=1, le=1000), kind: st
358 368 if indicator:
359 369 where.append("x.indicator_id = ?")
360 370 params.append(resolve_indicator(snap, indicator)["id"])
361 − rows = snap.query(CHANGES_SQL.format(table="events") + f" WHERE {' AND '.join(where)} ORDER BY x.period DESC, x.severity DESC NULLS LAST LIMIT ?",
362 − [*params, limit])
371 + rows = query_changes(snap, "events", where, params, "x.period DESC, x.severity DESC NULLS LAST", limit)
363 372 return {"meta": meta_block(snap), "n": len(rows), "items": [change_item(snap, r, with_country=False) for r in rows]}
364 373
365 374
modified src/countryatlas/api/routers/home.py +2 −4
@@ -10,7 +10,7 @@ from countryatlas.api.common import clean_float, country_card, indicator_card, m
10 10 from countryatlas.api.db import Snapshot, get_snapshot
11 11 from countryatlas.api.formatting import format_value
12 12 from countryatlas.api.provenance import _iso, build_provenance
13 −from countryatlas.api.routers.countries import CHANGES_SQL, change_item
13 +from countryatlas.api.routers.countries import change_item, query_changes
14 14 from countryatlas.api.routers.indicators import indicator_summary
15 15
16 16 router = APIRouter(tags=["home"])
@@ -91,9 +91,7 @@ def home(snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
91 91 lst = curated_list(snap, iid, order, min_pop)
92 92 if lst:
93 93 lists[key] = {"title": title, "description": desc, **lst}
94 − changes = snap.query(
95 − CHANGES_SQL.format(table="changes") + " JOIN countries c ON c.id = x.country_id AND coalesce(c.kind, 'country') = 'country'"
96 − " ORDER BY x.severity DESC NULLS LAST, x.period DESC LIMIT 12")
94 + changes = query_changes(snap, "changes", [], [], "x.severity DESC NULLS LAST, x.period DESC", 12, countries_only=True)
97 95 ind_rows = [merged_indicator(i) for i in snap.indicators().values() if i.get("latest_source_updated_at") is not None]
98 96 ind_rows.sort(key=lambda i: (i.get("latest_source_updated_at") is None, _iso(i.get("latest_source_updated_at")) or ""), reverse=True)
99 97 recently = [indicator_summary(snap, i) for i in ind_rows[:12]]
modified src/countryatlas/pipeline/derived.py +1 −1
@@ -128,7 +128,7 @@ def build_coverage(con: duckdb.DuckDBPyConnection) -> None:
128 128 count(o.value),
129 129 max(o.year),
130 130 CASE WHEN (SELECT n FROM tot) > 0 THEN 100.0 * count(DISTINCT o.indicator_id) / (SELECT n FROM tot) ELSE 0 END,
131 − now()::TIMESTAMP
131 + (now() AT TIME ZONE 'UTC')::TIMESTAMP
132 132 FROM countries c LEFT JOIN obs_ok o ON o.country_id = c.id
133 133 GROUP BY c.id
134 134 """
modified tests/test_build.py +8 −8
@@ -97,19 +97,19 @@ def test_failed_build_keeps_live_db_and_revisions_carry_forward(monkeypatch: pyt
97 97 def boom(con):
98 98 raise RuntimeError("synthetic failure")
99 99
100 − monkeypatch.setattr(build_mod.derived, "build_latest", boom)
101 − with pytest.raises(RuntimeError, match="synthetic"):
102 − build_mod.build(run_id="20260102T000000Z", strict=False)
100 + with pytest.MonkeyPatch.context() as mp: # scoped: must not undo the data_dir fixture patch
101 + mp.setattr(build_mod.derived, "build_latest", boom)
102 + with pytest.raises(RuntimeError, match="synthetic"):
103 + build_mod.build(run_id="20260102T000000Z", strict=False)
103 104 after = settings.db_path.stat()
104 105 assert (after.st_ino, after.st_mtime_ns, after.st_size) == (before.st_ino, before.st_mtime_ns, before.st_size)
105 106 assert not list(settings.build_dir.glob("atlas-20260102*"))
106 − monkeypatch.undo()
107 107 # 2) an integrity failure in strict mode also keeps the live DB
108 − monkeypatch.setattr(build_mod, "HEADLINE_MIN_COUNTRIES", 10_000)
109 − with pytest.raises(build_mod.IntegrityError):
110 − build_mod.build(run_id="20260103T000000Z", strict=True)
108 + with pytest.MonkeyPatch.context() as mp:
109 + mp.setattr(build_mod, "HEADLINE_MIN_COUNTRIES", 10_000)
110 + with pytest.raises(build_mod.IntegrityError, match="only 24 countries"):
111 + build_mod.build(run_id="20260103T000000Z", strict=True)
111 112 assert settings.db_path.stat().st_ino == before.st_ino
112 − monkeypatch.undo()
113 113 # 3) a changed value is recorded in observation_revisions on the next successful build
114 114 spec = IndicatorSourceSpec(indicator_id="gdp-per-capita", connector="worldbank", dataset="WDI", code="NY.GDP.PCAP.CD")
115 115 p = spec_paths(spec)["parquet"]
116 116