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>
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 | |