SPB Git

spb/countryatlas

Public

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 (Sep 11, 2026) parent b828cc8 Browse files

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 'use client'; 1 'use client';
2−import type { SearchResponse, SeriesResponse } from './types'; 2+import type { SearchResponse, SeriesResponse, SimilarResponse } from './types';
3 3
4 /** 4 /**
5 * Browser-side fetch helpers: same-origin `/api/v1/*` (rewritten by next.config.ts to the FastAPI service). 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 get<SearchResponse>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal), 26 get<SearchResponse>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal),
27 countrySeries: (id: string, indicator: string, signal?: AbortSignal) => 27 countrySeries: (id: string, indicator: string, signal?: AbortSignal) =>
28 get<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, signal), 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 trade (trade % GDP), energy (energy use pc), emissions (CO₂ pc), innovation (R&D % GDP, patents pc), education 182 trade (trade % GDP), energy (energy use pc), emissions (CO₂ pc), innovation (R&D % GDP, patents pc), education
183 (tertiary enrolment, expected years), public spending (gov. expenditure % GDP). Descriptive, not a score. 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 ## 8. API (FastAPI, `/api/v1`, OpenAPI at `/api/v1/openapi.json`, docs at `/api/v1/docs`) 239 ## 8. API (FastAPI, `/api/v1`, OpenAPI at `/api/v1/openapi.json`, docs at `/api/v1/docs`)
186 240
187 Every response carries `meta: {built_at, run_id, generated_at}`; every value object carries provenance: 241 Every response carries `meta: {built_at, run_id, generated_at}`; every value object carries provenance:
added docs/PIPELINE.md +138 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified registry/indicators.yaml +9 −11
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
modified registry/insights.yaml +1 −1
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/api/routers/changes.py +4 −7
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/api/routers/countries.py +19 −10
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/api/routers/home.py +2 −4
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/pipeline/derived.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified tests/test_build.py +8 −8
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme