SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%

Pipeline: recent-only changes, series-level source merge, severity weights, OWID freshness; registry split central/general government

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

111 changed files +2,290 −63

added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added apps/web/qa/screens.mjs +81 −0
@@ -0,0 +1,81 @@
1 +/**
2 + * Mobile-first QA: screenshots of the key pages at phone + desktop widths, plus automated checks:
3 + * - no horizontal overflow (scrollWidth <= innerWidth)
4 + * - interactive elements >= 44 px tall (buttons/links in header, tab bar, chips, metrics)
5 + * - fixed bottom tab bar does not overlap the footer (body padding-bottom)
6 + * - layout shift after fonts/charts settle (compare heights before/after)
7 + * Run: NODE_PATH=/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules node qa/screens.mjs [BASE_URL]
8 + */
9 +import { chromium } from 'playwright';
10 +import { mkdirSync, writeFileSync } from 'node:fs';
11 +import { createRequire } from 'node:module';
12 +
13 +const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';
14 +const OUT = new URL('./screens/', import.meta.url).pathname;
15 +mkdirSync(OUT, { recursive: true });
16 +
17 +const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy'];
18 +const WIDTHS = [320, 360, 375, 390, 414, 430, 1280, 1440];
19 +const report = [];
20 +
21 +const browser = await chromium.launch();
22 +for (const width of WIDTHS) {
23 + const mobile = width < 768;
24 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'light' });
25 + const page = await ctx.newPage();
26 + const errors = [];
27 + page.on('pageerror', (e) => errors.push(String(e)));
28 + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
29 + for (const path of PAGES) {
30 + await page.goto(BASE + path, { waitUntil: 'networkidle' });
31 + await page.evaluate(() => document.fonts.ready);
32 + const h1 = await page.evaluate(() => document.documentElement.scrollHeight);
33 + await page.waitForTimeout(600);
34 + const metrics = await page.evaluate(() => {
35 + const de = document.documentElement;
36 + const overflow = de.scrollWidth - de.clientWidth;
37 + // elements wider than the viewport
38 + const wide = [...document.querySelectorAll('body *')].filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0; }).slice(0, 8).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
39 + const small = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden') return false; return r.height < 44 && r.width < 44; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
40 + const smallH = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 32; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" h=${Math.round(el.getBoundingClientRect().height)}`);
41 + const tab = document.querySelector('nav.fixed');
42 + const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom);
43 + const footer = document.querySelector('footer');
44 + return { overflow, wide, small, smallH: smallH.slice(0, 12), tabBar: tab ? tab.getBoundingClientRect().height : 0, bodyPad, footerBottom: footer ? footer.getBoundingClientRect().bottom + window.scrollY : 0, docH: de.scrollHeight };
45 + });
46 + const h2 = metrics.docH;
47 + const name = `${path === '/' ? 'home' : path.slice(1).replace(/\//g, '_')}-${width}.png`;
48 + await page.screenshot({ path: OUT + name, fullPage: true });
49 + report.push({ path, width, overflow: metrics.overflow, wide: metrics.wide, small: metrics.small.slice(0, 10), smallH: metrics.smallH, tabBar: metrics.tabBar, bodyPad: metrics.bodyPad, cls: h2 - h1, errors: errors.splice(0), file: name });
50 + }
51 + await ctx.close();
52 +}
53 +// Dark-mode sample + favicon rasterisation
54 +{
55 + const ctx = await browser.newContext({ viewport: { width: 390, height: 800 }, colorScheme: 'dark', isMobile: true, hasTouch: true });
56 + const page = await ctx.newPage();
57 + await page.goto(BASE + '/countries/canada', { waitUntil: 'networkidle' });
58 + await page.screenshot({ path: OUT + 'countries_canada-390-dark.png', fullPage: true });
59 + await ctx.close();
60 +}
61 +{
62 + const require = createRequire(import.meta.url);
63 + const { readFileSync } = require('node:fs');
64 + const svg = readFileSync(new URL('../src/app/icon.svg', import.meta.url), 'utf8');
65 + for (const [size, file] of [[512, '../src/app/icon.png'], [180, '../src/app/apple-icon.png']]) {
66 + const ctx = await browser.newContext({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
67 + const page = await ctx.newPage();
68 + await page.setContent(`<html><body style="margin:0;background:transparent">${svg.replace(/width="\d+" height="\d+"/, `width="${size}" height="${size}"`)}</body></html>`);
69 + await page.screenshot({ path: new URL(file, import.meta.url).pathname, omitBackground: true, clip: { x: 0, y: 0, width: size, height: size } });
70 + await ctx.close();
71 + }
72 +}
73 +await browser.close();
74 +writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
75 +for (const r of report) {
76 + const flags = [r.overflow > 0 ? `OVERFLOW +${r.overflow}px` : 'ok', r.small.length ? `${r.small.length} small targets` : '', r.cls ? `Δh ${r.cls}` : '', r.errors.length ? `${r.errors.length} console errors` : ''].filter(Boolean).join(' · ');
77 + console.log(`${r.width.toString().padStart(4)} ${r.path.padEnd(28)} ${flags}`);
78 + if (r.overflow > 0) console.log(' wide:', r.wide.join(' | '));
79 + if (r.small.length) console.log(' small:', r.small.slice(0, 6).join(' | '));
80 + if (r.errors.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));
81 +}
added apps/web/qa/screens/countries-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-390-dark.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/og-canada.png +0 −0

Binary file not shown.

added apps/web/qa/screens/og-default.png +0 −0

Binary file not shown.

added apps/web/qa/screens/report.json +1104 −0
@@ -0,0 +1,1104 @@
1 +[
2 + {
3 + "path": "/",
4 + "width": 320,
5 + "overflow": 0,
6 + "wide": [
7 + "li. right=357",
8 + "a.inline-flex.h-9.items-center right=357",
9 + "li. right=501",
10 + "a.inline-flex.h-9.items-center right=501",
11 + "li. right=585",
12 + "a.inline-flex.h-9.items-center right=585",
13 + "li. right=690",
14 + "a.inline-flex.h-9.items-center right=690"
15 + ],
16 + "small": [
17 + "a \"Skip to content\" 1x1",
18 + "a \"API\" 22x32"
19 + ],
20 + "smallH": [
21 + "a \"Skip to content\" h=1",
22 + "a \"See all →\" h=21",
23 + "a \"🇨🇦Canada\" h=21",
24 + "a \"🇰🇷South Korea\" h=21",
25 + "a \"🇦🇪United Arab Emirates\" h=21",
26 + "a \"🇨🇱Chile\" h=21",
27 + "a \"🇸🇦Saudi Arabia\" h=21",
28 + "a \"🇲🇳Mongolia\" h=21",
29 + "a \"🇿🇦South Africa\" h=21",
30 + "a \"🇩🇿Algeria\" h=21",
31 + "a \"🇿🇲Zambia\" h=21",
32 + "a \"🇵🇰Pakistan\" h=21"
33 + ],
34 + "tabBar": 57,
35 + "bodyPad": 56,
36 + "cls": 0,
37 + "errors": [],
38 + "file": "home-320.png"
39 + },
40 + {
41 + "path": "/countries",
42 + "width": 320,
43 + "overflow": 0,
44 + "wide": [],
45 + "small": [
46 + "a \"Skip to content\" 1x1",
47 + "a \"API\" 22x32"
48 + ],
49 + "smallH": [
50 + "a \"Skip to content\" h=1",
51 + "select \"NamePopulationGDP per capitaCo\" h=19",
52 + "a \"contact@spboucher.ai\" h=15",
53 + "a \"MacLustr\" h=15"
54 + ],
55 + "tabBar": 57,
56 + "bodyPad": 56,
57 + "cls": 0,
58 + "errors": [],
59 + "file": "countries-320.png"
60 + },
61 + {
62 + "path": "/countries/canada",
63 + "width": 320,
64 + "overflow": 0,
65 + "wide": [
66 + "li.snap-start right=330",
67 + "a.inline-flex.h-9.items-center right=330",
68 + "li.snap-start right=444",
69 + "a.inline-flex.h-9.items-center right=444",
70 + "span.tnum.text-2xs.text-ink-3 right=434",
71 + "li.snap-start right=526",
72 + "a.inline-flex.h-9.items-center right=526",
73 + "span.tnum.text-2xs.text-ink-3 right=516"
74 + ],
75 + "small": [
76 + "a \"Skip to content\" 1x1",
77 + "a \"API\" 22x32"
78 + ],
79 + "smallH": [
80 + "a \"Skip to content\" h=1",
81 + "a \"Internet users at a 10-year hi\" h=19",
82 + "a \"Learning poverty turned positi\" h=19",
83 + "a \"🇱🇺Luxembourg\" h=21",
84 + "a \"🇸🇪Sweden\" h=21",
85 + "a \"🇳🇷Nauru\" h=21",
86 + "a \"🇳🇴Norway\" h=21",
87 + "a \"🇨🇾Cyprus\" h=21",
88 + "a \"🇩🇪Germany\" h=21",
89 + "a \"🇳🇨New Caledonia\" h=21",
90 + "a \"🇬🇮Gibraltar\" h=21",
91 + "a \"🇺🇸 United States\" h=17"
92 + ],
93 + "tabBar": 57,
94 + "bodyPad": 56,
95 + "cls": 0,
96 + "errors": [],
97 + "file": "countries_canada-320.png"
98 + },
99 + {
100 + "path": "/countries/canada/economy",
101 + "width": 320,
102 + "overflow": 0,
103 + "wide": [
104 + "li.snap-start right=383",
105 + "a.inline-flex.h-9.items-center right=383",
106 + "li.snap-start right=445",
107 + "a.inline-flex.h-9.items-center right=445",
108 + "li.snap-start right=518",
109 + "a.inline-flex.h-9.items-center right=518",
110 + "li.snap-start right=597",
111 + "a.inline-flex.h-9.items-center right=597"
112 + ],
113 + "small": [
114 + "a \"Skip to content\" 1x1",
115 + "a \"REER\" 40x20",
116 + "a \"API\" 22x32"
117 + ],
118 + "smallH": [
119 + "a \"Skip to content\" h=1",
120 + "a \"Countries\" h=15",
121 + "a \"🇨🇦 Canada\" h=15",
122 + "a \"GDP (current US$)\" h=20",
123 + "a \"GDP, PPP (current internationa\" h=20",
124 + "a \"GDP per capita (current US$)\" h=20",
125 + "a \"Real GDP\" h=20",
126 + "a \"Industrial production index\" h=20",
127 + "a \"GDP growth\" h=20",
128 + "a \"GDP per capita growth\" h=20",
129 + "a \"Inflation\" h=20",
130 + "a \"Inflation, GDP deflator\" h=20"
131 + ],
132 + "tabBar": 57,
133 + "bodyPad": 56,
134 + "cls": 0,
135 + "errors": [],
136 + "file": "countries_canada_economy-320.png"
137 + },
138 + {
139 + "path": "/",
140 + "width": 360,
141 + "overflow": 0,
142 + "wide": [
143 + "li. right=501",
144 + "a.inline-flex.h-9.items-center right=501",
145 + "li. right=585",
146 + "a.inline-flex.h-9.items-center right=585",
147 + "li. right=690",
148 + "a.inline-flex.h-9.items-center right=690",
149 + "li. right=846",
150 + "a.inline-flex.h-9.items-center right=846"
151 + ],
152 + "small": [
153 + "a \"Skip to content\" 1x1",
154 + "a \"API\" 22x32"
155 + ],
156 + "smallH": [
157 + "a \"Skip to content\" h=1",
158 + "a \"See all →\" h=21",
159 + "a \"🇨🇦Canada\" h=21",
160 + "a \"🇰🇷South Korea\" h=21",
161 + "a \"🇦🇪United Arab Emirates\" h=21",
162 + "a \"🇨🇱Chile\" h=21",
163 + "a \"🇸🇦Saudi Arabia\" h=21",
164 + "a \"🇲🇳Mongolia\" h=21",
165 + "a \"🇿🇦South Africa\" h=21",
166 + "a \"🇩🇿Algeria\" h=21",
167 + "a \"🇿🇲Zambia\" h=21",
168 + "a \"🇵🇰Pakistan\" h=21"
169 + ],
170 + "tabBar": 57,
171 + "bodyPad": 56,
172 + "cls": 0,
173 + "errors": [],
174 + "file": "home-360.png"
175 + },
176 + {
177 + "path": "/countries",
178 + "width": 360,
179 + "overflow": 0,
180 + "wide": [],
181 + "small": [
182 + "a \"Skip to content\" 1x1",
183 + "a \"API\" 22x32"
184 + ],
185 + "smallH": [
186 + "a \"Skip to content\" h=1",
187 + "select \"NamePopulationGDP per capitaCo\" h=19",
188 + "a \"contact@spboucher.ai\" h=15",
189 + "a \"MacLustr\" h=15"
190 + ],
191 + "tabBar": 57,
192 + "bodyPad": 56,
193 + "cls": 0,
194 + "errors": [],
195 + "file": "countries-360.png"
196 + },
197 + {
198 + "path": "/countries/canada",
199 + "width": 360,
200 + "overflow": 0,
201 + "wide": [
202 + "li.snap-start right=444",
203 + "a.inline-flex.h-9.items-center right=444",
204 + "span.tnum.text-2xs.text-ink-3 right=434",
205 + "li.snap-start right=526",
206 + "a.inline-flex.h-9.items-center right=526",
207 + "span.tnum.text-2xs.text-ink-3 right=516",
208 + "li.snap-start right=612",
209 + "a.inline-flex.h-9.items-center right=612"
210 + ],
211 + "small": [
212 + "a \"Skip to content\" 1x1",
213 + "a \"API\" 22x32"
214 + ],
215 + "smallH": [
216 + "a \"Skip to content\" h=1",
217 + "a \"Internet users at a 10-year hi\" h=19",
218 + "a \"Learning poverty turned positi\" h=19",
219 + "a \"🇱🇺Luxembourg\" h=21",
220 + "a \"🇸🇪Sweden\" h=21",
221 + "a \"🇳🇷Nauru\" h=21",
222 + "a \"🇳🇴Norway\" h=21",
223 + "a \"🇨🇾Cyprus\" h=21",
224 + "a \"🇩🇪Germany\" h=21",
225 + "a \"🇳🇨New Caledonia\" h=21",
226 + "a \"🇬🇮Gibraltar\" h=21",
227 + "a \"🇺🇸 United States\" h=17"
228 + ],
229 + "tabBar": 57,
230 + "bodyPad": 56,
231 + "cls": 0,
232 + "errors": [],
233 + "file": "countries_canada-360.png"
234 + },
235 + {
236 + "path": "/countries/canada/economy",
237 + "width": 360,
238 + "overflow": 0,
239 + "wide": [
240 + "li.snap-start right=383",
241 + "a.inline-flex.h-9.items-center right=383",
242 + "li.snap-start right=445",
243 + "a.inline-flex.h-9.items-center right=445",
244 + "li.snap-start right=518",
245 + "a.inline-flex.h-9.items-center right=518",
246 + "li.snap-start right=597",
247 + "a.inline-flex.h-9.items-center right=597"
248 + ],
249 + "small": [
250 + "a \"Skip to content\" 1x1",
251 + "a \"REER\" 40x20",
252 + "a \"API\" 22x32"
253 + ],
254 + "smallH": [
255 + "a \"Skip to content\" h=1",
256 + "a \"Countries\" h=15",
257 + "a \"🇨🇦 Canada\" h=15",
258 + "a \"GDP (current US$)\" h=20",
259 + "a \"GDP, PPP (current internationa\" h=20",
260 + "a \"GDP per capita (current US$)\" h=20",
261 + "a \"Real GDP\" h=20",
262 + "a \"Industrial production index\" h=20",
263 + "a \"GDP growth\" h=20",
264 + "a \"GDP per capita growth\" h=20",
265 + "a \"Inflation\" h=20",
266 + "a \"Inflation, GDP deflator\" h=20"
267 + ],
268 + "tabBar": 57,
269 + "bodyPad": 56,
270 + "cls": 0,
271 + "errors": [],
272 + "file": "countries_canada_economy-360.png"
273 + },
274 + {
275 + "path": "/",
276 + "width": 375,
277 + "overflow": 0,
278 + "wide": [
279 + "li. right=501",
280 + "a.inline-flex.h-9.items-center right=501",
281 + "li. right=585",
282 + "a.inline-flex.h-9.items-center right=585",
283 + "li. right=690",
284 + "a.inline-flex.h-9.items-center right=690",
285 + "li. right=846",
286 + "a.inline-flex.h-9.items-center right=846"
287 + ],
288 + "small": [
289 + "a \"Skip to content\" 1x1",
290 + "a \"API\" 22x32"
291 + ],
292 + "smallH": [
293 + "a \"Skip to content\" h=1",
294 + "a \"See all →\" h=21",
295 + "a \"🇨🇦Canada\" h=21",
296 + "a \"🇰🇷South Korea\" h=21",
297 + "a \"🇦🇪United Arab Emirates\" h=21",
298 + "a \"🇨🇱Chile\" h=21",
299 + "a \"🇸🇦Saudi Arabia\" h=21",
300 + "a \"🇲🇳Mongolia\" h=21",
301 + "a \"🇿🇦South Africa\" h=21",
302 + "a \"🇩🇿Algeria\" h=21",
303 + "a \"🇿🇲Zambia\" h=21",
304 + "a \"🇵🇰Pakistan\" h=21"
305 + ],
306 + "tabBar": 57,
307 + "bodyPad": 56,
308 + "cls": 0,
309 + "errors": [],
310 + "file": "home-375.png"
311 + },
312 + {
313 + "path": "/countries",
314 + "width": 375,
315 + "overflow": 0,
316 + "wide": [],
317 + "small": [
318 + "a \"Skip to content\" 1x1",
319 + "a \"API\" 22x32"
320 + ],
321 + "smallH": [
322 + "a \"Skip to content\" h=1",
323 + "select \"NamePopulationGDP per capitaCo\" h=19",
324 + "a \"contact@spboucher.ai\" h=15",
325 + "a \"MacLustr\" h=15"
326 + ],
327 + "tabBar": 57,
328 + "bodyPad": 56,
329 + "cls": 0,
330 + "errors": [],
331 + "file": "countries-375.png"
332 + },
333 + {
334 + "path": "/countries/canada",
335 + "width": 375,
336 + "overflow": 0,
337 + "wide": [
338 + "li.snap-start right=444",
339 + "a.inline-flex.h-9.items-center right=444",
340 + "span.tnum.text-2xs.text-ink-3 right=434",
341 + "li.snap-start right=526",
342 + "a.inline-flex.h-9.items-center right=526",
343 + "span.tnum.text-2xs.text-ink-3 right=516",
344 + "li.snap-start right=612",
345 + "a.inline-flex.h-9.items-center right=612"
346 + ],
347 + "small": [
348 + "a \"Skip to content\" 1x1",
349 + "a \"API\" 22x32"
350 + ],
351 + "smallH": [
352 + "a \"Skip to content\" h=1",
353 + "a \"Internet users at a 10-year hi\" h=19",
354 + "a \"Learning poverty turned positi\" h=19",
355 + "a \"🇱🇺Luxembourg\" h=21",
356 + "a \"🇸🇪Sweden\" h=21",
357 + "a \"🇳🇷Nauru\" h=21",
358 + "a \"🇳🇴Norway\" h=21",
359 + "a \"🇨🇾Cyprus\" h=21",
360 + "a \"🇩🇪Germany\" h=21",
361 + "a \"🇳🇨New Caledonia\" h=21",
362 + "a \"🇬🇮Gibraltar\" h=21",
363 + "a \"🇺🇸 United States\" h=17"
364 + ],
365 + "tabBar": 57,
366 + "bodyPad": 56,
367 + "cls": 0,
368 + "errors": [],
369 + "file": "countries_canada-375.png"
370 + },
371 + {
372 + "path": "/countries/canada/economy",
373 + "width": 375,
374 + "overflow": 0,
375 + "wide": [
376 + "li.snap-start right=383",
377 + "a.inline-flex.h-9.items-center right=383",
378 + "li.snap-start right=445",
379 + "a.inline-flex.h-9.items-center right=445",
380 + "li.snap-start right=518",
381 + "a.inline-flex.h-9.items-center right=518",
382 + "li.snap-start right=597",
383 + "a.inline-flex.h-9.items-center right=597"
384 + ],
385 + "small": [
386 + "a \"Skip to content\" 1x1",
387 + "a \"REER\" 40x20",
388 + "a \"API\" 22x32"
389 + ],
390 + "smallH": [
391 + "a \"Skip to content\" h=1",
392 + "a \"Countries\" h=15",
393 + "a \"🇨🇦 Canada\" h=15",
394 + "a \"GDP (current US$)\" h=20",
395 + "a \"GDP, PPP (current internationa\" h=20",
396 + "a \"GDP per capita (current US$)\" h=20",
397 + "a \"Real GDP\" h=20",
398 + "a \"Industrial production index\" h=20",
399 + "a \"GDP growth\" h=20",
400 + "a \"GDP per capita growth\" h=20",
401 + "a \"Inflation\" h=20",
402 + "a \"Inflation, GDP deflator\" h=20"
403 + ],
404 + "tabBar": 57,
405 + "bodyPad": 56,
406 + "cls": 0,
407 + "errors": [],
408 + "file": "countries_canada_economy-375.png"
409 + },
410 + {
411 + "path": "/",
412 + "width": 390,
413 + "overflow": 0,
414 + "wide": [
415 + "li. right=501",
416 + "a.inline-flex.h-9.items-center right=501",
417 + "li. right=585",
418 + "a.inline-flex.h-9.items-center right=585",
419 + "li. right=690",
420 + "a.inline-flex.h-9.items-center right=690",
421 + "li. right=846",
422 + "a.inline-flex.h-9.items-center right=846"
423 + ],
424 + "small": [
425 + "a \"Skip to content\" 1x1",
426 + "a \"API\" 22x32"
427 + ],
428 + "smallH": [
429 + "a \"Skip to content\" h=1",
430 + "a \"See all →\" h=21",
431 + "a \"🇨🇦Canada\" h=21",
432 + "a \"🇰🇷South Korea\" h=21",
433 + "a \"🇦🇪United Arab Emirates\" h=21",
434 + "a \"🇨🇱Chile\" h=21",
435 + "a \"🇸🇦Saudi Arabia\" h=21",
436 + "a \"🇲🇳Mongolia\" h=21",
437 + "a \"🇿🇦South Africa\" h=21",
438 + "a \"🇩🇿Algeria\" h=21",
439 + "a \"🇿🇲Zambia\" h=21",
440 + "a \"🇵🇰Pakistan\" h=21"
441 + ],
442 + "tabBar": 57,
443 + "bodyPad": 56,
444 + "cls": 0,
445 + "errors": [],
446 + "file": "home-390.png"
447 + },
448 + {
449 + "path": "/countries",
450 + "width": 390,
451 + "overflow": 0,
452 + "wide": [],
453 + "small": [
454 + "a \"Skip to content\" 1x1",
455 + "a \"API\" 22x32"
456 + ],
457 + "smallH": [
458 + "a \"Skip to content\" h=1",
459 + "select \"NamePopulationGDP per capitaCo\" h=19",
460 + "a \"contact@spboucher.ai\" h=15",
461 + "a \"MacLustr\" h=15"
462 + ],
463 + "tabBar": 57,
464 + "bodyPad": 56,
465 + "cls": 0,
466 + "errors": [],
467 + "file": "countries-390.png"
468 + },
469 + {
470 + "path": "/countries/canada",
471 + "width": 390,
472 + "overflow": 0,
473 + "wide": [
474 + "li.snap-start right=444",
475 + "a.inline-flex.h-9.items-center right=444",
476 + "span.tnum.text-2xs.text-ink-3 right=434",
477 + "li.snap-start right=526",
478 + "a.inline-flex.h-9.items-center right=526",
479 + "span.tnum.text-2xs.text-ink-3 right=516",
480 + "li.snap-start right=612",
481 + "a.inline-flex.h-9.items-center right=612"
482 + ],
483 + "small": [
484 + "a \"Skip to content\" 1x1",
485 + "a \"API\" 22x32"
486 + ],
487 + "smallH": [
488 + "a \"Skip to content\" h=1",
489 + "a \"Internet users at a 10-year hi\" h=19",
490 + "a \"Learning poverty turned positi\" h=19",
491 + "a \"🇱🇺Luxembourg\" h=21",
492 + "a \"🇸🇪Sweden\" h=21",
493 + "a \"🇳🇷Nauru\" h=21",
494 + "a \"🇳🇴Norway\" h=21",
495 + "a \"🇨🇾Cyprus\" h=21",
496 + "a \"🇩🇪Germany\" h=21",
497 + "a \"🇳🇨New Caledonia\" h=21",
498 + "a \"🇬🇮Gibraltar\" h=21",
499 + "a \"🇺🇸 United States\" h=17"
500 + ],
501 + "tabBar": 57,
502 + "bodyPad": 56,
503 + "cls": 0,
504 + "errors": [],
505 + "file": "countries_canada-390.png"
506 + },
507 + {
508 + "path": "/countries/canada/economy",
509 + "width": 390,
510 + "overflow": 0,
511 + "wide": [
512 + "li.snap-start right=445",
513 + "a.inline-flex.h-9.items-center right=445",
514 + "li.snap-start right=518",
515 + "a.inline-flex.h-9.items-center right=518",
516 + "li.snap-start right=597",
517 + "a.inline-flex.h-9.items-center right=597",
518 + "li.snap-start right=664",
519 + "a.inline-flex.h-9.items-center right=664"
520 + ],
521 + "small": [
522 + "a \"Skip to content\" 1x1",
523 + "a \"REER\" 40x20",
524 + "a \"API\" 22x32"
525 + ],
526 + "smallH": [
527 + "a \"Skip to content\" h=1",
528 + "a \"Countries\" h=15",
529 + "a \"🇨🇦 Canada\" h=15",
530 + "a \"GDP (current US$)\" h=20",
531 + "a \"GDP, PPP (current internationa\" h=20",
532 + "a \"GDP per capita (current US$)\" h=20",
533 + "a \"GDP per capita, PPP (current i\" h=20",
534 + "a \"Real GDP\" h=20",
535 + "a \"Industrial production index\" h=20",
536 + "a \"GDP growth\" h=20",
537 + "a \"GDP per capita growth\" h=20",
538 + "a \"Inflation\" h=20"
539 + ],
540 + "tabBar": 57,
541 + "bodyPad": 56,
542 + "cls": 0,
543 + "errors": [],
544 + "file": "countries_canada_economy-390.png"
545 + },
546 + {
547 + "path": "/",
548 + "width": 414,
549 + "overflow": 0,
550 + "wide": [
551 + "li. right=501",
552 + "a.inline-flex.h-9.items-center right=501",
553 + "li. right=585",
554 + "a.inline-flex.h-9.items-center right=585",
555 + "li. right=690",
556 + "a.inline-flex.h-9.items-center right=690",
557 + "li. right=846",
558 + "a.inline-flex.h-9.items-center right=846"
559 + ],
560 + "small": [
561 + "a \"Skip to content\" 1x1",
562 + "a \"API\" 22x32"
563 + ],
564 + "smallH": [
565 + "a \"Skip to content\" h=1",
566 + "a \"See all →\" h=21",
567 + "a \"🇨🇦Canada\" h=21",
568 + "a \"🇰🇷South Korea\" h=21",
569 + "a \"🇦🇪United Arab Emirates\" h=21",
570 + "a \"🇨🇱Chile\" h=21",
571 + "a \"🇸🇦Saudi Arabia\" h=21",
572 + "a \"🇲🇳Mongolia\" h=21",
573 + "a \"🇿🇦South Africa\" h=21",
574 + "a \"🇩🇿Algeria\" h=21",
575 + "a \"🇿🇲Zambia\" h=21",
576 + "a \"🇵🇰Pakistan\" h=21"
577 + ],
578 + "tabBar": 57,
579 + "bodyPad": 56,
580 + "cls": 0,
581 + "errors": [],
582 + "file": "home-414.png"
583 + },
584 + {
585 + "path": "/countries",
586 + "width": 414,
587 + "overflow": 0,
588 + "wide": [],
589 + "small": [
590 + "a \"Skip to content\" 1x1",
591 + "a \"API\" 22x32"
592 + ],
593 + "smallH": [
594 + "a \"Skip to content\" h=1",
595 + "select \"NamePopulationGDP per capitaCo\" h=19",
596 + "a \"contact@spboucher.ai\" h=15",
597 + "a \"MacLustr\" h=15"
598 + ],
599 + "tabBar": 57,
600 + "bodyPad": 56,
601 + "cls": 0,
602 + "errors": [],
603 + "file": "countries-414.png"
604 + },
605 + {
606 + "path": "/countries/canada",
607 + "width": 414,
608 + "overflow": 0,
609 + "wide": [
610 + "li.snap-start right=444",
611 + "a.inline-flex.h-9.items-center right=444",
612 + "span.tnum.text-2xs.text-ink-3 right=434",
613 + "li.snap-start right=526",
614 + "a.inline-flex.h-9.items-center right=526",
615 + "span.tnum.text-2xs.text-ink-3 right=516",
616 + "li.snap-start right=612",
617 + "a.inline-flex.h-9.items-center right=612"
618 + ],
619 + "small": [
620 + "a \"Skip to content\" 1x1",
621 + "a \"API\" 22x32"
622 + ],
623 + "smallH": [
624 + "a \"Skip to content\" h=1",
625 + "a \"Internet users at a 10-year hi\" h=19",
626 + "a \"Learning poverty turned positi\" h=19",
627 + "a \"🇱🇺Luxembourg\" h=21",
628 + "a \"🇸🇪Sweden\" h=21",
629 + "a \"🇳🇷Nauru\" h=21",
630 + "a \"🇳🇴Norway\" h=21",
631 + "a \"🇨🇾Cyprus\" h=21",
632 + "a \"🇩🇪Germany\" h=21",
633 + "a \"🇳🇨New Caledonia\" h=21",
634 + "a \"🇬🇮Gibraltar\" h=21",
635 + "a \"🇺🇸 United States\" h=17"
636 + ],
637 + "tabBar": 57,
638 + "bodyPad": 56,
639 + "cls": 0,
640 + "errors": [],
641 + "file": "countries_canada-414.png"
642 + },
643 + {
644 + "path": "/countries/canada/economy",
645 + "width": 414,
646 + "overflow": 0,
647 + "wide": [
648 + "li.snap-start right=445",
649 + "a.inline-flex.h-9.items-center right=445",
650 + "li.snap-start right=518",
651 + "a.inline-flex.h-9.items-center right=518",
652 + "li.snap-start right=597",
653 + "a.inline-flex.h-9.items-center right=597",
654 + "li.snap-start right=664",
655 + "a.inline-flex.h-9.items-center right=664"
656 + ],
657 + "small": [
658 + "a \"Skip to content\" 1x1",
659 + "a \"REER\" 40x20",
660 + "a \"API\" 22x32"
661 + ],
662 + "smallH": [
663 + "a \"Skip to content\" h=1",
664 + "a \"Countries\" h=15",
665 + "a \"🇨🇦 Canada\" h=15",
666 + "a \"GDP (current US$)\" h=20",
667 + "a \"GDP, PPP (current internationa\" h=20",
668 + "a \"GDP per capita (current US$)\" h=20",
669 + "a \"GDP per capita, PPP (current i\" h=20",
670 + "a \"Real GDP\" h=20",
671 + "a \"Industrial production index\" h=20",
672 + "a \"GDP growth\" h=20",
673 + "a \"GDP per capita growth\" h=20",
674 + "a \"Inflation\" h=20"
675 + ],
676 + "tabBar": 57,
677 + "bodyPad": 56,
678 + "cls": 0,
679 + "errors": [],
680 + "file": "countries_canada_economy-414.png"
681 + },
682 + {
683 + "path": "/",
684 + "width": 430,
685 + "overflow": 0,
686 + "wide": [
687 + "li. right=501",
688 + "a.inline-flex.h-9.items-center right=501",
689 + "li. right=585",
690 + "a.inline-flex.h-9.items-center right=585",
691 + "li. right=690",
692 + "a.inline-flex.h-9.items-center right=690",
693 + "li. right=846",
694 + "a.inline-flex.h-9.items-center right=846"
695 + ],
696 + "small": [
697 + "a \"Skip to content\" 1x1",
698 + "a \"API\" 22x32"
699 + ],
700 + "smallH": [
701 + "a \"Skip to content\" h=1",
702 + "a \"See all →\" h=21",
703 + "a \"🇨🇦Canada\" h=21",
704 + "a \"🇰🇷South Korea\" h=21",
705 + "a \"🇦🇪United Arab Emirates\" h=21",
706 + "a \"🇨🇱Chile\" h=21",
707 + "a \"🇸🇦Saudi Arabia\" h=21",
708 + "a \"🇲🇳Mongolia\" h=21",
709 + "a \"🇿🇦South Africa\" h=21",
710 + "a \"🇩🇿Algeria\" h=21",
711 + "a \"🇿🇲Zambia\" h=21",
712 + "a \"🇵🇰Pakistan\" h=21"
713 + ],
714 + "tabBar": 57,
715 + "bodyPad": 56,
716 + "cls": 0,
717 + "errors": [],
718 + "file": "home-430.png"
719 + },
720 + {
721 + "path": "/countries",
722 + "width": 430,
723 + "overflow": 0,
724 + "wide": [],
725 + "small": [
726 + "a \"Skip to content\" 1x1",
727 + "a \"API\" 22x32"
728 + ],
729 + "smallH": [
730 + "a \"Skip to content\" h=1",
731 + "select \"NamePopulationGDP per capitaCo\" h=19",
732 + "a \"contact@spboucher.ai\" h=15",
733 + "a \"MacLustr\" h=15"
734 + ],
735 + "tabBar": 57,
736 + "bodyPad": 56,
737 + "cls": 0,
738 + "errors": [],
739 + "file": "countries-430.png"
740 + },
741 + {
742 + "path": "/countries/canada",
743 + "width": 430,
744 + "overflow": 0,
745 + "wide": [
746 + "li.snap-start right=444",
747 + "a.inline-flex.h-9.items-center right=444",
748 + "span.tnum.text-2xs.text-ink-3 right=434",
749 + "li.snap-start right=526",
750 + "a.inline-flex.h-9.items-center right=526",
751 + "span.tnum.text-2xs.text-ink-3 right=516",
752 + "li.snap-start right=612",
753 + "a.inline-flex.h-9.items-center right=612"
754 + ],
755 + "small": [
756 + "a \"Skip to content\" 1x1",
757 + "a \"API\" 22x32"
758 + ],
759 + "smallH": [
760 + "a \"Skip to content\" h=1",
761 + "a \"Internet users at a 10-year hi\" h=19",
762 + "a \"Learning poverty turned positi\" h=19",
763 + "a \"🇱🇺Luxembourg\" h=21",
764 + "a \"🇸🇪Sweden\" h=21",
765 + "a \"🇳🇷Nauru\" h=21",
766 + "a \"🇳🇴Norway\" h=21",
767 + "a \"🇨🇾Cyprus\" h=21",
768 + "a \"🇩🇪Germany\" h=21",
769 + "a \"🇳🇨New Caledonia\" h=21",
770 + "a \"🇬🇮Gibraltar\" h=21",
771 + "a \"🇺🇸 United States\" h=17"
772 + ],
773 + "tabBar": 57,
774 + "bodyPad": 56,
775 + "cls": 0,
776 + "errors": [],
777 + "file": "countries_canada-430.png"
778 + },
779 + {
780 + "path": "/countries/canada/economy",
781 + "width": 430,
782 + "overflow": 0,
783 + "wide": [
784 + "li.snap-start right=445",
785 + "a.inline-flex.h-9.items-center right=445",
786 + "li.snap-start right=518",
787 + "a.inline-flex.h-9.items-center right=518",
788 + "li.snap-start right=597",
789 + "a.inline-flex.h-9.items-center right=597",
790 + "li.snap-start right=664",
791 + "a.inline-flex.h-9.items-center right=664"
792 + ],
793 + "small": [
794 + "a \"Skip to content\" 1x1",
795 + "a \"REER\" 40x20",
796 + "a \"API\" 22x32"
797 + ],
798 + "smallH": [
799 + "a \"Skip to content\" h=1",
800 + "a \"Countries\" h=15",
801 + "a \"🇨🇦 Canada\" h=15",
802 + "a \"GDP (current US$)\" h=20",
803 + "a \"GDP, PPP (current internationa\" h=20",
804 + "a \"GDP per capita (current US$)\" h=20",
805 + "a \"GDP per capita, PPP (current i\" h=20",
806 + "a \"Real GDP\" h=20",
807 + "a \"Industrial production index\" h=20",
808 + "a \"GDP growth\" h=20",
809 + "a \"GDP per capita growth\" h=20",
810 + "a \"Inflation\" h=20"
811 + ],
812 + "tabBar": 57,
813 + "bodyPad": 56,
814 + "cls": 0,
815 + "errors": [],
816 + "file": "countries_canada_economy-430.png"
817 + },
818 + {
819 + "path": "/",
820 + "width": 1280,
821 + "overflow": 0,
822 + "wide": [],
823 + "small": [
824 + "a \"Skip to content\" 1x1",
825 + "a \"API\" 22x32"
826 + ],
827 + "smallH": [
828 + "a \"Skip to content\" h=1",
829 + "a \"See all →\" h=21",
830 + "a \"🇨🇦Canada\" h=21",
831 + "a \"🇰🇷South Korea\" h=21",
832 + "a \"🇦🇪United Arab Emirates\" h=21",
833 + "a \"🇨🇱Chile\" h=21",
834 + "a \"🇸🇦Saudi Arabia\" h=21",
835 + "a \"🇲🇳Mongolia\" h=21",
836 + "a \"🇿🇦South Africa\" h=21",
837 + "a \"🇩🇿Algeria\" h=21",
838 + "a \"🇿🇲Zambia\" h=21",
839 + "a \"🇵🇰Pakistan\" h=21"
840 + ],
841 + "tabBar": 0,
842 + "bodyPad": 0,
843 + "cls": 0,
844 + "errors": [],
845 + "file": "home-1280.png"
846 + },
847 + {
848 + "path": "/countries",
849 + "width": 1280,
850 + "overflow": 0,
851 + "wide": [],
852 + "small": [
853 + "a \"Skip to content\" 1x1",
854 + "button \"All\" 38x36",
855 + "button \"All\" 38x36",
856 + "a \"A\" 24x20",
857 + "a \"B\" 24x20",
858 + "a \"C\" 24x20",
859 + "a \"D\" 24x20",
860 + "a \"E\" 24x20",
861 + "a \"F\" 24x20",
862 + "a \"G\" 24x20"
863 + ],
864 + "smallH": [
865 + "a \"Skip to content\" h=1",
866 + "select \"NamePopulationGDP per capitaCo\" h=19",
867 + "a \"A\" h=20",
868 + "a \"B\" h=20",
869 + "a \"C\" h=20",
870 + "a \"D\" h=20",
871 + "a \"E\" h=20",
872 + "a \"F\" h=20",
873 + "a \"G\" h=20",
874 + "a \"H\" h=20",
875 + "a \"I\" h=20",
876 + "a \"J\" h=20"
877 + ],
878 + "tabBar": 0,
879 + "bodyPad": 0,
880 + "cls": 0,
881 + "errors": [],
882 + "file": "countries-1280.png"
883 + },
884 + {
885 + "path": "/countries/canada",
886 + "width": 1280,
887 + "overflow": 0,
888 + "wide": [
889 + "li.snap-start right=1311",
890 + "a.inline-flex.h-9.items-center right=1311",
891 + "span.tnum.text-2xs.text-ink-3 right=1301",
892 + "li.snap-start right=1438",
893 + "a.inline-flex.h-9.items-center right=1438",
894 + "span.tnum.text-2xs.text-ink-3 right=1428",
895 + "li.snap-start right=1516",
896 + "a.inline-flex.h-9.items-center right=1516"
897 + ],
898 + "small": [
899 + "a \"Skip to content\" 1x1",
900 + "a \"API\" 22x32"
901 + ],
902 + "smallH": [
903 + "a \"Skip to content\" h=1",
904 + "a \"Exports of goods and services \" h=19",
905 + "a \"Internet users at a 10-year hi\" h=19",
906 + "a \"Gross debt / GDP accelerating \" h=19",
907 + "a \"ICT goods exports (% of total \" h=19",
908 + "a \"Imports of goods and services \" h=19",
909 + "a \"Industrial production index hi\" h=19",
910 + "a \"Learning poverty turned positi\" h=19",
911 + "a \"🇱🇺Luxembourg\" h=21",
912 + "a \"🇸🇪Sweden\" h=21",
913 + "a \"🇳🇷Nauru\" h=21",
914 + "a \"🇳🇴Norway\" h=21"
915 + ],
916 + "tabBar": 0,
917 + "bodyPad": 0,
918 + "cls": 0,
919 + "errors": [],
920 + "file": "countries_canada-1280.png"
921 + },
922 + {
923 + "path": "/countries/canada/economy",
924 + "width": 1280,
925 + "overflow": 0,
926 + "wide": [
927 + "li.snap-start right=1347",
928 + "a.inline-flex.h-9.items-center right=1347",
929 + "li.snap-start right=1444",
930 + "a.inline-flex.h-9.items-center right=1444",
931 + "li.snap-start right=1521",
932 + "a.inline-flex.h-9.items-center right=1521",
933 + "li.snap-start right=1600",
934 + "a.inline-flex.h-9.items-center right=1600"
935 + ],
936 + "small": [
937 + "a \"Skip to content\" 1x1",
938 + "a \"REER\" 40x20",
939 + "a \"API\" 22x32"
940 + ],
941 + "smallH": [
942 + "a \"Skip to content\" h=1",
943 + "a \"Countries\" h=15",
944 + "a \"🇨🇦 Canada\" h=15",
945 + "a \"GDP (current US$)\" h=20",
946 + "a \"GDP, PPP (current internationa\" h=20",
947 + "a \"GDP per capita (current US$)\" h=20",
948 + "a \"Real GDP\" h=20",
949 + "a \"Industrial production index\" h=20",
950 + "a \"GDP growth\" h=20",
951 + "a \"GDP per capita growth\" h=20",
952 + "a \"Inflation\" h=20",
953 + "a \"Inflation, GDP deflator\" h=20"
954 + ],
955 + "tabBar": 0,
956 + "bodyPad": 0,
957 + "cls": 0,
958 + "errors": [],
959 + "file": "countries_canada_economy-1280.png"
960 + },
961 + {
962 + "path": "/",
963 + "width": 1440,
964 + "overflow": 0,
965 + "wide": [],
966 + "small": [
967 + "a \"Skip to content\" 1x1",
968 + "a \"API\" 22x32"
969 + ],
970 + "smallH": [
971 + "a \"Skip to content\" h=1",
972 + "a \"See all →\" h=21",
973 + "a \"🇨🇦Canada\" h=21",
974 + "a \"🇰🇷South Korea\" h=21",
975 + "a \"🇦🇪United Arab Emirates\" h=21",
976 + "a \"🇨🇱Chile\" h=21",
977 + "a \"🇸🇦Saudi Arabia\" h=21",
978 + "a \"🇲🇳Mongolia\" h=21",
979 + "a \"🇿🇦South Africa\" h=21",
980 + "a \"🇩🇿Algeria\" h=21",
981 + "a \"🇿🇲Zambia\" h=21",
982 + "a \"🇵🇰Pakistan\" h=21"
983 + ],
984 + "tabBar": 0,
985 + "bodyPad": 0,
986 + "cls": 0,
987 + "errors": [],
988 + "file": "home-1440.png"
989 + },
990 + {
991 + "path": "/countries",
992 + "width": 1440,
993 + "overflow": 0,
994 + "wide": [],
995 + "small": [
996 + "a \"Skip to content\" 1x1",
997 + "button \"All\" 38x36",
998 + "button \"All\" 38x36",
999 + "a \"A\" 24x20",
1000 + "a \"B\" 24x20",
1001 + "a \"C\" 24x20",
1002 + "a \"D\" 24x20",
1003 + "a \"E\" 24x20",
1004 + "a \"F\" 24x20",
1005 + "a \"G\" 24x20"
1006 + ],
1007 + "smallH": [
1008 + "a \"Skip to content\" h=1",
1009 + "select \"NamePopulationGDP per capitaCo\" h=19",
1010 + "a \"A\" h=20",
1011 + "a \"B\" h=20",
1012 + "a \"C\" h=20",
1013 + "a \"D\" h=20",
1014 + "a \"E\" h=20",
1015 + "a \"F\" h=20",
1016 + "a \"G\" h=20",
1017 + "a \"H\" h=20",
1018 + "a \"I\" h=20",
1019 + "a \"J\" h=20"
1020 + ],
1021 + "tabBar": 0,
1022 + "bodyPad": 0,
1023 + "cls": 0,
1024 + "errors": [],
1025 + "file": "countries-1440.png"
1026 + },
1027 + {
1028 + "path": "/countries/canada",
1029 + "width": 1440,
1030 + "overflow": 0,
1031 + "wide": [
1032 + "li.snap-start right=1458",
1033 + "a.inline-flex.h-9.items-center right=1458",
1034 + "span.tnum.text-2xs.text-ink-3 right=1448",
1035 + "li.snap-start right=1536",
1036 + "a.inline-flex.h-9.items-center right=1536",
1037 + "span.tnum.text-2xs.text-ink-3 right=1526",
1038 + "li.snap-start right=1642",
1039 + "a.inline-flex.h-9.items-center right=1642"
1040 + ],
1041 + "small": [
1042 + "a \"Skip to content\" 1x1",
1043 + "a \"API\" 22x32"
1044 + ],
1045 + "smallH": [
1046 + "a \"Skip to content\" h=1",
1047 + "a \"Exports of goods and services \" h=19",
1048 + "a \"Internet users at a 10-year hi\" h=19",
1049 + "a \"Gross debt / GDP accelerating \" h=19",
1050 + "a \"ICT goods exports (% of total \" h=19",
1051 + "a \"High-technology exports (curre\" h=19",
1052 + "a \"Imports of goods and services \" h=19",
1053 + "a \"Industrial production index hi\" h=19",
1054 + "a \"Learning poverty turned positi\" h=19",
1055 + "a \"🇱🇺Luxembourg\" h=21",
1056 + "a \"🇸🇪Sweden\" h=21",
1057 + "a \"🇳🇷Nauru\" h=21"
1058 + ],
1059 + "tabBar": 0,
1060 + "bodyPad": 0,
1061 + "cls": 0,
1062 + "errors": [],
1063 + "file": "countries_canada-1440.png"
1064 + },
1065 + {
1066 + "path": "/countries/canada/economy",
1067 + "width": 1440,
1068 + "overflow": 0,
1069 + "wide": [
1070 + "li.snap-start right=1464",
1071 + "a.inline-flex.h-9.items-center right=1464",
1072 + "li.snap-start right=1541",
1073 + "a.inline-flex.h-9.items-center right=1541",
1074 + "li.snap-start right=1620",
1075 + "a.inline-flex.h-9.items-center right=1620",
1076 + "li.snap-start right=1731",
1077 + "a.inline-flex.h-9.items-center right=1731"
1078 + ],
1079 + "small": [
1080 + "a \"Skip to content\" 1x1",
1081 + "a \"REER\" 40x20",
1082 + "a \"API\" 22x32"
1083 + ],
1084 + "smallH": [
1085 + "a \"Skip to content\" h=1",
1086 + "a \"Countries\" h=15",
1087 + "a \"🇨🇦 Canada\" h=15",
1088 + "a \"GDP (current US$)\" h=20",
1089 + "a \"GDP, PPP (current internationa\" h=20",
1090 + "a \"GDP per capita (current US$)\" h=20",
1091 + "a \"Real GDP\" h=20",
1092 + "a \"Industrial production index\" h=20",
1093 + "a \"GDP growth\" h=20",
1094 + "a \"GDP per capita growth\" h=20",
1095 + "a \"Inflation\" h=20",
1096 + "a \"Inflation, GDP deflator\" h=20"
1097 + ],
1098 + "tabBar": 0,
1099 + "bodyPad": 0,
1100 + "cls": 0,
1101 + "errors": [],
1102 + "file": "countries_canada_economy-1440.png"
1103 + }
1104 +]
\ No newline at end of file
added apps/web/qa/screens/seg/countries-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/segments.mjs +28 −0
@@ -0,0 +1,28 @@
1 +// Viewport-sized segments for visual review (top of page + scroll offsets).
2 +import { chromium } from 'playwright';
3 +const BASE = process.argv[2] ?? 'http://localhost:8290';
4 +const OUT = new URL('./screens/seg/', import.meta.url).pathname;
5 +import { mkdirSync } from 'node:fs';
6 +mkdirSync(OUT, { recursive: true });
7 +const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy'];
8 +const browser = await chromium.launch();
9 +for (const width of [390, 1440]) {
10 + const mobile = width < 768;
11 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile });
12 + const page = await ctx.newPage();
13 + for (const path of PAGES) {
14 + await page.goto(BASE + path, { waitUntil: 'networkidle' });
15 + await page.evaluate(() => document.fonts.ready);
16 + const h = await page.evaluate(() => document.documentElement.scrollHeight);
17 + const vh = mobile ? 844 : 900;
18 + const offsets = [0, Math.round(h * 0.25), Math.round(h * 0.5), Math.round(h * 0.75), h - vh].filter((o, i, a) => a.indexOf(o) === i);
19 + const name = path === '/' ? 'home' : path.slice(1).replace(/\//g, '_');
20 + for (const [i, o] of offsets.entries()) {
21 + await page.evaluate((y) => window.scrollTo(0, y), o);
22 + await page.waitForTimeout(400);
23 + await page.screenshot({ path: `${OUT}${name}-${width}-${i}.png` });
24 + }
25 + }
26 + await ctx.close();
27 +}
28 +await browser.close();
added apps/web/src/app/(home)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { t } from '@/i18n';
2 +
3 +/** Root loading UI: a calm, low-contrast skeleton with reserved heights (no spinner, no layout jump). */
4 +export default function Loading() {
5 + return (
6 + <div className="animate-fade py-8" aria-busy="true" aria-label={t('common.loading')}>
7 + <div className="h-8 w-2/3 max-w-md rounded-sm bg-surface-2" />
8 + <div className="mt-3 h-4 w-1/2 max-w-sm rounded-sm bg-surface-2" />
9 + <div className="mt-8 grid gap-6 min-[361px]:grid-cols-2 md:grid-cols-4">
10 + {Array.from({ length: 8 }, (_, i) => (
11 + <div key={i} className="border-t border-rule pt-3">
12 + <div className="h-3 w-24 rounded-sm bg-surface-2" />
13 + <div className="mt-3 h-7 w-28 rounded-sm bg-surface-2" />
14 + <div className="mt-2 h-3 w-20 rounded-sm bg-surface-2" />
15 + </div>
16 + ))}
17 + </div>
18 + </div>
19 + );
20 +}
renamed apps/web/src/app/page.tsx → apps/web/src/app/(home)/page.tsx +0 −0
added apps/web/src/app/apple-icon.png +0 −0

Binary file not shown.

added apps/web/src/app/countries/(list)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { t } from '@/i18n';
2 +
3 +/** Root loading UI: a calm, low-contrast skeleton with reserved heights (no spinner, no layout jump). */
4 +export default function Loading() {
5 + return (
6 + <div className="animate-fade py-8" aria-busy="true" aria-label={t('common.loading')}>
7 + <div className="h-8 w-2/3 max-w-md rounded-sm bg-surface-2" />
8 + <div className="mt-3 h-4 w-1/2 max-w-sm rounded-sm bg-surface-2" />
9 + <div className="mt-8 grid gap-6 min-[361px]:grid-cols-2 md:grid-cols-4">
10 + {Array.from({ length: 8 }, (_, i) => (
11 + <div key={i} className="border-t border-rule pt-3">
12 + <div className="h-3 w-24 rounded-sm bg-surface-2" />
13 + <div className="mt-3 h-7 w-28 rounded-sm bg-surface-2" />
14 + <div className="mt-2 h-3 w-20 rounded-sm bg-surface-2" />
15 + </div>
16 + ))}
17 + </div>
18 + </div>
19 + );
20 +}
renamed apps/web/src/app/countries/page.tsx → apps/web/src/app/countries/(list)/page.tsx +0 −0
added apps/web/src/app/countries/[slug]/[topic]/page.tsx +137 −0
@@ -0,0 +1,137 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { t } from '@/i18n';
5 +import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6 +import { routes } from '@/lib/site';
7 +import { TOPICS, isTopicId, topicById } from '@/lib/topics';
8 +import type { CountryTopicResponse, MetricValue, SeriesResponse } from '@/lib/types';
9 +import { IndicatorRow } from '@/components/country/indicator-row';
10 +import { NotBuiltState } from '@/components/data/empty-state';
11 +import { Section } from '@/components/data/section';
12 +import { TopicNav } from '@/components/data/topic-nav';
13 +
14 +export const revalidate = 900;
15 +
16 +type Params = { slug: string; topic: string };
17 +const EAGER = 4; // charts rendered with server-fetched full history; the rest mount on scroll
18 +
19 +async function load(slug: string, topic: string): Promise<CountryTopicResponse | 'not-built' | null> {
20 + if (!isTopicId(topic)) return null;
21 + try {
22 + return await api.countryTopic(slug, topic);
23 + } catch (e) {
24 + if (isNotFound(e)) return null;
25 + if (isNotBuilt(e)) return 'not-built';
26 + throw e;
27 + }
28 +}
29 +
30 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
31 + const { slug, topic } = await params;
32 + const data = await load(slug, topic);
33 + if (!data || data === 'not-built') return { title: t('topic.notFound'), robots: { index: false } };
34 + const country = data.country.name ?? slug;
35 + const list = data.subtopics
36 + .flatMap((b) => b.indicators)
37 + .slice(0, 4)
38 + .map((m) => (m.indicator_name ?? m.indicator).toLowerCase())
39 + .join(', ');
40 + const title = t('topic.title', { country, topic: data.topic.name });
41 + const canonical = routes.countryTopic(data.country.slug ?? slug, topic);
42 + return {
43 + title,
44 + description: t('topic.description', { topic: data.topic.name, country, list }),
45 + alternates: { canonical },
46 + openGraph: { title: `${title} — ${t('site.name')}`, url: canonical, type: 'article' },
47 + };
48 +}
49 +
50 +export default async function CountryTopicPage({ params }: { params: Promise<Params> }) {
51 + const { slug, topic } = await params;
52 + const data = await load(slug, topic);
53 + if (data === null) notFound();
54 + if (data === 'not-built') return <NotBuiltState />;
55 +
56 + const c = data.country;
57 + const countryRef = { id: c.id, slug: c.slug ?? slug, name: c.name ?? c.id, flag: c.flag };
58 + const all = data.subtopics.flatMap((b) => b.indicators);
59 + const withData = all.filter((m) => m.has_data);
60 + const noData = all.filter((m) => !m.has_data);
61 + const eagerIds = withData.slice(0, EAGER).map((m) => m.indicator);
62 + const eagerSeries = await Promise.all(eagerIds.map((iid) => safe(api.countrySeries(c.id, iid))));
63 + const seriesById = new Map<string, SeriesResponse | null>(eagerIds.map((iid, i) => [iid, eagerSeries[i] ?? null]));
64 + const def = topicById(topic);
65 + const others = TOPICS.filter((tp) => tp.id !== topic);
66 +
67 + return (
68 + <>
69 + <header className="pb-3 pt-6 md:pt-10">
70 + <nav aria-label="Breadcrumb" className="text-xs text-ink-3">
71 + <Link href={routes.countries()} className="hover:text-accent">
72 + {t('nav.countries')}
73 + </Link>
74 + <span className="mx-1.5">/</span>
75 + <Link href={routes.country(countryRef.slug)} className="hover:text-accent">
76 + <span aria-hidden>{c.flag} </span>
77 + {countryRef.name}
78 + </Link>
79 + </nav>
80 + <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">
81 + {countryRef.name} <span className="text-ink-3">·</span> {data.topic.name}
82 + </h1>
83 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{data.topic.blurb ?? def?.blurb}</p>
84 + <p className="tnum mt-1 text-xs text-ink-3">
85 + {t('topic.indicators', { n: data.n_indicators })} · {t('topic.withData', { n: data.n_with_data })}
86 + </p>
87 + </header>
88 + <TopicNav slug={countryRef.slug} />
89 +
90 + {data.subtopics.map((block) => {
91 + const rows = block.indicators.filter((m) => m.has_data);
92 + if (rows.length === 0) return null;
93 + return (
94 + <Section key={block.subtopic} id={`sub-${block.subtopic.toLowerCase().replace(/\W+/g, '-')}`} title={block.subtopic} level={3} tight className="pb-2">
95 + <div>
96 + {rows.map((m: MetricValue) => (
97 + <IndicatorRow key={m.indicator} metric={m} country={countryRef} regionName={c.region_name} series={seriesById.get(m.indicator)} eager={seriesById.has(m.indicator)} />
98 + ))}
99 + </div>
100 + </Section>
101 + );
102 + })}
103 +
104 + {noData.length ? (
105 + <details className="hairline group py-4">
106 + <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 text-sm font-medium text-ink-2 hover:text-ink">
107 + <span className="inline-block transition-transform group-open:rotate-90">›</span>
108 + {t('topic.noData', { n: noData.length })}
109 + </summary>
110 + <p className="mt-1 text-xs text-ink-3">{t('topic.noDataHint', { country: countryRef.name })}</p>
111 + <ul className="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
112 + {noData.map((m) => (
113 + <li key={m.indicator} className="flex justify-between gap-3 border-t border-rule py-2 text-sm">
114 + <Link href={routes.indicator(m.indicator)} className="link-quiet truncate text-ink-2">
115 + {m.indicator_name ?? m.indicator}
116 + </Link>
117 + <span className="shrink-0 text-xs text-ink-3">{t('common.noData')}</span>
118 + </li>
119 + ))}
120 + </ul>
121 + </details>
122 + ) : null}
123 +
124 + <Section id="other-topics" title={t('topic.otherTopics')} level={3} tight>
125 + <ul className="flex flex-wrap gap-1.5">
126 + {others.map((tp) => (
127 + <li key={tp.id}>
128 + <Link href={routes.countryTopic(countryRef.slug, tp.id)} className="inline-flex h-9 items-center rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-accent hover:text-accent">
129 + {tp.short}
130 + </Link>
131 + </li>
132 + ))}
133 + </ul>
134 + </Section>
135 + </>
136 + );
137 +}
added apps/web/src/app/countries/[slug]/opengraph-image.tsx +48 −0
@@ -0,0 +1,48 @@
1 +import { ImageResponse } from 'next/og';
2 +import { t } from '@/i18n';
3 +import { api, safe } from '@/lib/api';
4 +import { OG_INK2, OG_INK3, OG_RULE, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from '../../og-shared';
5 +
6 +export const alt = 'Country statistics on CountryAtlas';
7 +export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
8 +export const contentType = 'image/png';
9 +export const revalidate = 3600;
10 +
11 +/** Per-country social image: flag + name + three headline values (population, GDP per capita, life expectancy). */
12 +export default async function CountryOgImage({ params }: { params: Promise<{ slug: string }> }) {
13 + const { slug } = await params;
14 + const data = await safe(api.country(slug));
15 + const c = data?.country;
16 + const name = c?.name ?? slug.replace(/-/g, ' ');
17 + const pick = (id: string) => data?.headline.find((m) => m.indicator === id && m.has_data) ?? null;
18 + const facts = [pick('population'), pick('gdp-per-capita'), pick('life-expectancy')].filter((m): m is NonNullable<typeof m> => !!m).slice(0, 3);
19 + const sub = [c?.capital, c?.region_name, c?.income_name].filter(Boolean).join(' · ');
20 + return new ImageResponse(
21 + (
22 + <OgFrame>
23 + <OgWordmark size={28} />
24 + <div style={{ display: 'flex', alignItems: 'center', gap: 28, marginTop: 56 }}>
25 + <div style={{ fontSize: 120, lineHeight: 1, display: 'flex' }}>{c?.flag ?? ''}</div>
26 + <div style={{ display: 'flex', flexDirection: 'column' }}>
27 + <div style={{ fontSize: 68, fontWeight: 600, letterSpacing: -1.5, lineHeight: 1.05 }}>{name}</div>
28 + {sub ? <div style={{ marginTop: 10, fontSize: 24, color: OG_INK2 }}>{sub}</div> : null}
29 + </div>
30 + </div>
31 + <div style={{ display: 'flex', gap: 48, marginTop: 'auto', paddingTop: 24, borderTop: `1px solid ${OG_RULE}` }}>
32 + {facts.length === 0 ? (
33 + <div style={{ fontSize: 24, color: OG_INK2 }}>{t('country.description', { name }).split(':')[1]?.trim() ?? ''}</div>
34 + ) : null}
35 + {facts.map((m) => (
36 + <div key={m.indicator} style={{ display: 'flex', flexDirection: 'column' }}>
37 + <div style={{ fontSize: 18, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 1.5 }}>{m.indicator_name ?? m.indicator}</div>
38 + <div style={{ fontSize: 44, fontWeight: 600, marginTop: 6 }}>{m.formatted ?? String(m.value ?? '')}</div>
39 + <div style={{ fontSize: 18, color: OG_INK3, marginTop: 2 }}>{String(m.year ?? '')}</div>
40 + </div>
41 + ))}
42 + </div>
43 + <div style={{ position: 'absolute', right: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
44 + </OgFrame>
45 + ),
46 + { ...size },
47 + );
48 +}
added apps/web/src/app/countries/[slug]/page.tsx +135 −0
@@ -0,0 +1,135 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { t } from '@/i18n';
5 +import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6 +import { routes } from '@/lib/site';
7 +import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';
8 +import type { CountryResponse } from '@/lib/types';
9 +import { DnaRadial } from '@/components/charts/dna-radial';
10 +import { CountryHeader } from '@/components/country/country-header';
11 +import { KeyFacts } from '@/components/country/key-facts';
12 +import { SimilarPanel } from '@/components/country/similar-panel';
13 +import { Timeline } from '@/components/country/timeline';
14 +import { CountryTopicsGrid } from '@/components/country/topics-grid';
15 +import { ChangeList } from '@/components/data/change-list';
16 +import { NotBuiltState } from '@/components/data/empty-state';
17 +import { Metric, MetricGrid } from '@/components/data/metric';
18 +import { Section } from '@/components/data/section';
19 +import { TopicNav } from '@/components/data/topic-nav';
20 +
21 +export const revalidate = 900;
22 +
23 +type Params = { slug: string };
24 +
25 +async function loadCountry(slug: string): Promise<CountryResponse | 'not-built' | null> {
26 + try {
27 + return await api.country(slug);
28 + } catch (e) {
29 + if (isNotFound(e)) return null;
30 + if (isNotBuilt(e)) return 'not-built';
31 + throw e;
32 + }
33 +}
34 +
35 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
36 + const { slug } = await params;
37 + const data = await loadCountry(slug);
38 + if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } };
39 + const name = data.country.name ?? slug;
40 + const title = t('country.title', { name });
41 + const description = t('country.description', { name });
42 + const canonical = routes.country(data.country.slug ?? slug);
43 + return {
44 + title,
45 + description,
46 + alternates: { canonical },
47 + openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' },
48 + twitter: { card: 'summary_large_image', title, description },
49 + };
50 +}
51 +
52 +export default async function CountryPage({ params }: { params: Promise<Params> }) {
53 + const { slug } = await params;
54 + const data = await loadCountry(slug);
55 + if (data === null) notFound();
56 + if (data === 'not-built') return <NotBuiltState />;
57 +
58 + const c = data.country;
59 + const id = c.id;
60 + const name = c.name ?? id;
61 + const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag };
62 + // Optional panels in parallel; each tolerates failure independently.
63 + const [changes, similar, insights, dna, events] = await Promise.all([
64 + safe(api.countryChanges(id, 8)),
65 + safe(api.countrySimilar(id, 'overall', 8)),
66 + safe(api.countryInsights(id)),
67 + safe(api.countryDna(id)),
68 + safe(api.countryEvents(id, 30)),
69 + ]);
70 + const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data]));
71 + const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0);
72 +
73 + return (
74 + <>
75 + <CountryHeader data={data} />
76 + <TopicNav slug={c.slug ?? slug} counts={counts} />
77 +
78 + <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0">
79 + <MetricGrid>
80 + {data.headline.map((m) => {
81 + const topic = HEADLINE_TOPIC[m.indicator as (typeof HEADLINE_INDICATORS)[number]];
82 + return <Metric key={m.indicator} metric={m} country={countryRef} regionName={c.region_name} href={topic ? routes.countryIndicator(c.slug ?? slug, topic, m.indicator) : null} />;
83 + })}
84 + </MetricGrid>
85 + </Section>
86 +
87 + <div className="grid gap-x-10 lg:grid-cols-2">
88 + <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')}>
89 + <ChangeList items={changes?.items ?? []} />
90 + </Section>
91 + <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}>
92 + <SimilarPanel countryId={id} initial={similar} />
93 + </Section>
94 + </div>
95 +
96 + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
97 + <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}>
98 + <DnaRadial dna={dna} name={name} size={360} />
99 + {dna?.year_ref ? <p className="tnum mt-2 text-center text-2xs text-ink-3">{dna.year_ref}</p> : null}
100 + </Section>
101 + <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}>
102 + <KeyFacts items={insights?.items ?? []} country={countryRef} />
103 + {data.neighbours.length ? (
104 + <p className="mt-4 text-sm text-ink-2">
105 + <span className="text-ink-3">{t('country.borders')}: </span>
106 + {data.neighbours.map((n, i) => (
107 + <span key={n.id}>
108 + {i > 0 ? ', ' : ''}
109 + <Link href={routes.country(n.slug ?? n.id)} className="link-quiet text-ink hover:text-accent">
110 + <span aria-hidden>{n.flag} </span>
111 + {n.name}
112 + </Link>
113 + </span>
114 + ))}
115 + </p>
116 + ) : null}
117 + {c.languages?.length ? (
118 + <p className="mt-1 text-sm text-ink-2">
119 + <span className="text-ink-3">{t('country.languages')}: </span>
120 + {c.languages.join(', ')}
121 + </p>
122 + ) : null}
123 + </Section>
124 + </div>
125 +
126 + <Section id="timeline" title={t('country.timeline.title')} subtitle={t('country.timeline.sub')}>
127 + <Timeline items={events?.items ?? []} slug={c.slug ?? slug} />
128 + </Section>
129 +
130 + <Section id="topics" title={t('country.topics.title', { name })} subtitle={t('country.topics.sub', { n: data.topics.length, m: withData })}>
131 + <CountryTopicsGrid slug={c.slug ?? slug} topics={data.topics} />
132 + </Section>
133 + </>
134 + );
135 +}
added apps/web/src/app/error.tsx +20 −0
@@ -0,0 +1,20 @@
1 +'use client';
2 +import { useEffect } from 'react';
3 +import { t } from '@/i18n';
4 +
5 +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
6 + useEffect(() => {
7 + console.error(error);
8 + }, [error]);
9 + return (
10 + <div className="mx-auto max-w-prose py-20 text-center">
11 + <div className="eyebrow">{t('site.name')}</div>
12 + <h1 className="display mt-2 text-3xl text-ink">{t('common.errorTitle')}</h1>
13 + <p className="mt-3 text-ink-2">{t('common.errorHint')}</p>
14 + {error.digest ? <p className="tnum mt-2 text-2xs text-ink-3">{error.digest}</p> : null}
15 + <button type="button" onClick={reset} className="mt-6 inline-flex h-10 items-center rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink">
16 + {t('common.retry')}
17 + </button>
18 + </div>
19 + );
20 +}
added apps/web/src/app/icon.png +0 −0

Binary file not shown.

added apps/web/src/app/not-found.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { routes } from '@/lib/site';
4 +
5 +export default function NotFound() {
6 + return (
7 + <div className="mx-auto max-w-prose py-20 text-center">
8 + <div className="eyebrow">404</div>
9 + <h1 className="display mt-2 text-3xl text-ink">{t('common.notFound')}</h1>
10 + <p className="mt-3 text-ink-2">{t('common.notFoundHint')}</p>
11 + <div className="mt-6 flex flex-wrap justify-center gap-3 text-sm">
12 + <Link href={routes.countries()} className="inline-flex h-10 items-center rounded-sm bg-ink px-4 font-medium text-paper hover:bg-accent hover:text-accent-ink">
13 + {t('common.browseCountries')}
14 + </Link>
15 + <Link href={routes.home()} className="inline-flex h-10 items-center rounded-sm border border-rule px-4 text-ink hover:bg-surface-2">
16 + {t('common.goHome')}
17 + </Link>
18 + </div>
19 + </div>
20 + );
21 +}
added apps/web/src/app/og-shared.tsx +3 −0
@@ -0,0 +1,3 @@
1 +export * from '@/lib/og';
2 +export const OG_SIZE_W = 1200;
3 +export const OG_SIZE_H = 630;
added apps/web/src/app/opengraph-image.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { ImageResponse } from 'next/og';
2 +import { t } from '@/i18n';
3 +import { OG_INK2, OG_INK3, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from './og-shared';
4 +
5 +export const alt = 'CountryAtlas — Understand the world, one country at a time.';
6 +export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
7 +export const contentType = 'image/png';
8 +
9 +/** Default social image: dark editorial background, mark + wordmark, tagline, meridian motif. Static (built once). */
10 +export default function OpenGraphImage() {
11 + return new ImageResponse(
12 + (
13 + <OgFrame>
14 + <OgWordmark size={40} />
15 + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 'auto', maxWidth: 720 }}>
16 + <div style={{ fontSize: 62, lineHeight: 1.08, fontWeight: 600, letterSpacing: -1.5 }}>{t('home.hero.title')}</div>
17 + <div style={{ marginTop: 22, fontSize: 26, color: OG_INK2, lineHeight: 1.35 }}>{t('home.hero.sub')}</div>
18 + </div>
19 + <div style={{ position: 'absolute', left: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
20 + </OgFrame>
21 + ),
22 + { ...size },
23 + );
24 +}
added apps/web/src/app/robots.ts +10 −0
@@ -0,0 +1,10 @@
1 +import type { MetadataRoute } from 'next';
2 +import { SITE_URL } from '@/lib/site';
3 +
4 +export default function robots(): MetadataRoute.Robots {
5 + return {
6 + rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api/'] }],
7 + sitemap: `${SITE_URL}/sitemap.xml`,
8 + host: SITE_URL,
9 + };
10 +}
added apps/web/src/app/sitemap.ts +26 −0
@@ -0,0 +1,26 @@
1 +import type { MetadataRoute } from 'next';
2 +import { api, safe } from '@/lib/api';
3 +import { SITE_URL, routes } from '@/lib/site';
4 +import { TOPICS } from '@/lib/topics';
5 +
6 +/**
7 + * Countries + country topic pages from the API. The next agent adds indicators / rankings / regions entries
8 + * here (same pattern: fetch the list with `safe()`, map to URLs, tolerate an empty API).
9 + */
10 +export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
11 + const res = await safe(api.countries());
12 + const lastModified = res?.meta.built_at ? new Date(res.meta.built_at) : new Date();
13 + const staticEntries: MetadataRoute.Sitemap = [
14 + { url: SITE_URL, lastModified, changeFrequency: 'daily', priority: 1 },
15 + { url: `${SITE_URL}${routes.countries()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
16 + ];
17 + const countries = res?.items ?? [];
18 + const countryEntries: MetadataRoute.Sitemap = countries.flatMap((c) => {
19 + const slug = c.slug ?? c.id;
20 + return [
21 + { url: `${SITE_URL}${routes.country(slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.8 },
22 + ...TOPICS.map((tp) => ({ url: `${SITE_URL}${routes.countryTopic(slug, tp.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 })),
23 + ];
24 + });
25 + return [...staticEntries, ...countryEntries];
26 +}
added apps/web/src/app/twitter-image.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import OpenGraphImage, { alt as ogAlt, contentType as ogType, size as ogSize } from './opengraph-image';
2 +
3 +export const alt = ogAlt;
4 +export const size = ogSize;
5 +export const contentType = ogType;
6 +export default OpenGraphImage;
added apps/web/src/components/country/indicator-row.tsx +134 −0
@@ -0,0 +1,134 @@
1 +'use client';
2 +import { useEffect, useRef, useState } from 'react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { clientApi } from '@/lib/client-api';
6 +import { cn } from '@/lib/cn';
7 +import { formatPeriod, formatValue } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { MetricValue, SeriesResponse } from '@/lib/types';
10 +import { LineChart } from '@/components/charts/line-chart';
11 +import { pointsFromSeries, pointsFromSpark } from '@/components/charts/scales';
12 +import { ChangeChip } from '@/components/data/change-chip';
13 +import { EmptyState } from '@/components/data/empty-state';
14 +import { payloadFor, type MetricCountry } from '@/components/data/metric';
15 +import { useProvenance } from '@/components/data/provenance-context';
16 +import { RankBadge } from '@/components/data/rank-badge';
17 +
18 +const CHART_H = 220;
19 +
20 +/**
21 + * One indicator on a topic page: name, latest value + change + rank, full-history LineChart (dashed forecast),
22 + * source line, Compare / Ranking / Indicator links. `series` (server-fetched) renders immediately; otherwise
23 + * the chart mounts on scroll (IntersectionObserver) and fetches `/countries/{id}/series/{slug}` client-side.
24 + * The chart area reserves its height so nothing shifts.
25 + */
26 +export function IndicatorRow({ metric, country, regionName, series: initialSeries, eager = false }: { metric: MetricValue; country: MetricCountry; regionName?: string | null; series?: SeriesResponse | null; eager?: boolean }) {
27 + const { open } = useProvenance();
28 + const m = metric;
29 + const ref = useRef<HTMLDivElement>(null);
30 + const [series, setSeries] = useState<SeriesResponse | null | undefined>(initialSeries);
31 + const [visible, setVisible] = useState(eager || !!initialSeries);
32 + const [error, setError] = useState(false);
33 +
34 + useEffect(() => {
35 + if (visible || !ref.current) return;
36 + const el = ref.current;
37 + if (typeof IntersectionObserver === 'undefined') {
38 + setVisible(true);
39 + return;
40 + }
41 + const io = new IntersectionObserver(
42 + (entries) => {
43 + if (entries.some((e) => e.isIntersecting)) {
44 + setVisible(true);
45 + io.disconnect();
46 + }
47 + },
48 + { rootMargin: '400px 0px' },
49 + );
50 + io.observe(el);
51 + return () => io.disconnect();
52 + }, [visible]);
53 +
54 + useEffect(() => {
55 + if (!visible || series !== undefined || !m.has_data) return;
56 + const ctrl = new AbortController();
57 + clientApi
58 + .countrySeries(country.id, m.indicator, ctrl.signal)
59 + .then((r) => setSeries(r))
60 + .catch((e) => {
61 + if ((e as Error).name !== 'AbortError') {
62 + setError(true);
63 + setSeries(null);
64 + }
65 + });
66 + return () => ctrl.abort();
67 + }, [visible, series, m.has_data, m.indicator, country.id]);
68 +
69 + const name = m.indicator_name ?? m.indicator;
70 + const points = series ? pointsFromSeries(series.values) : pointsFromSpark(m.sparkline);
71 + const spec = { format: m.format, unit: m.unit, unit_short: m.unit_short, frequency: m.frequency, name, higher_is_better: m.higher_is_better, precision: series?.indicator.precision ?? null };
72 + const payload = payloadFor(m, country, { name: series?.indicator.name ?? name });
73 +
74 + return (
75 + <article id={m.indicator} ref={ref} className="scroll-mt-32 border-t border-rule py-5 md:py-6" aria-labelledby={`${m.indicator}-h`}>
76 + <div className="grid gap-x-8 gap-y-3 md:grid-cols-[minmax(0,17rem)_1fr] lg:grid-cols-[minmax(0,19rem)_1fr]">
77 + <div className="min-w-0">
78 + <h3 id={`${m.indicator}-h`} className="text-base font-semibold leading-snug text-ink">
79 + <Link href={routes.indicator(m.indicator)} className="link-quiet">
80 + {series?.indicator.name ?? name}
81 + </Link>
82 + </h3>
83 + {m.unit ? <p className="text-xs text-ink-3">{m.unit}</p> : null}
84 + {m.has_data ? (
85 + <button type="button" onClick={() => open(payload)} className="-mx-1 mt-2 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2" aria-label={t('common.openProvenance')}>
86 + <span className="pnum text-2xl font-semibold leading-none text-ink">{m.formatted ?? formatValue(m.value, spec)}</span>
87 + <span className="mt-1 flex flex-wrap items-baseline gap-x-2 text-xs text-ink-3">
88 + <span className="tnum">{formatPeriod(m.period, m.frequency)}</span>
89 + {m.is_estimate ? <span>{t('common.estimate')}</span> : null}
90 + <ChangeChip change={m.change} spec={spec} prevPeriod={m.prev?.period} />
91 + </span>
92 + </button>
93 + ) : null}
94 + <div className="mt-1 min-h-[1rem]">
95 + <RankBadge rank={m} regionName={regionName} />
96 + </div>
97 + <div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
98 + <Link href={routes.compare(country.slug ?? country.id)} className="inline-flex min-h-[32px] items-center text-accent hover:underline">
99 + {t('topic.compareLink')}
100 + </Link>
101 + <Link href={routes.ranking(m.indicator)} className="inline-flex min-h-[32px] items-center text-accent hover:underline">
102 + {t('topic.rankingLink')}
103 + </Link>
104 + <Link href={routes.indicator(m.indicator)} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent hover:underline">
105 + {t('topic.indicatorLink')}
106 + </Link>
107 + </div>
108 + </div>
109 + <div className="min-w-0" style={{ minHeight: CHART_H + 40 }}>
110 + {!m.has_data ? (
111 + <EmptyState compact title={t('empty.title', { indicator: name, country: country.name })} />
112 + ) : error ? (
113 + <EmptyState compact title={t('common.errorHint')} />
114 + ) : points.length >= 2 ? (
115 + <LineChart
116 + series={[{ id: m.indicator, name: country.name, points }]}
117 + spec={spec}
118 + subject={`${country.name}'s ${(series?.indicator.short_name ?? name).toLowerCase()}`}
119 + height={CHART_H}
120 + provenance={series?.provenance ?? m.provenance}
121 + payload={payload}
122 + defaultWidth={720}
123 + className={cn(!series && 'opacity-90')}
124 + />
125 + ) : (
126 + <div className="grid h-full place-items-center text-sm text-ink-3" style={{ minHeight: CHART_H }}>
127 + {t('common.loading')}
128 + </div>
129 + )}
130 + </div>
131 + </div>
132 + </article>
133 + );
134 +}
added apps/web/src/components/country/timeline.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { cn } from '@/lib/cn';
4 +import { severityLevel } from '@/lib/severity';
5 +import { routes } from '@/lib/site';
6 +import { topicById } from '@/lib/topics';
7 +import type { ChangeItem } from '@/lib/types';
8 +import { kindLabel } from '@/components/data/change-list';
9 +
10 +/** Events grouped by year, compact: a left year rail and one line per event. Server component. */
11 +export function Timeline({ items, slug, limit = 30 }: { items: ChangeItem[]; slug: string; limit?: number }) {
12 + if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('country.timeline.none')}</p>;
13 + const byYear = new Map<number, ChangeItem[]>();
14 + for (const it of items.slice(0, limit)) {
15 + const y = it.year ?? 0;
16 + if (!byYear.has(y)) byYear.set(y, []);
17 + byYear.get(y)!.push(it);
18 + }
19 + const years = Array.from(byYear.keys()).sort((a, b) => b - a);
20 + return (
21 + <ol className="divide-y divide-rule">
22 + {years.map((y) => (
23 + <li key={y} className="grid grid-cols-[3.25rem_1fr] gap-x-3 py-2.5">
24 + <span className="tnum display pt-0.5 text-lg leading-none text-ink-2">{y || '—'}</span>
25 + <ul className="space-y-1.5">
26 + {byYear.get(y)!.map((e, i) => {
27 + const ind = e.indicator;
28 + const indSlug = (ind as { slug?: string; id: string }).slug ?? ind.id;
29 + const topic = 'topic' in ind ? topicById(ind.topic ?? '')?.id : undefined;
30 + const href = topic ? routes.countryIndicator(slug, topic, indSlug) : routes.indicator(indSlug);
31 + const lvl = severityLevel(e.severity);
32 + return (
33 + <li key={e.id ?? i} className="text-sm leading-snug">
34 + <Link href={href} className="link-quiet">
35 + <span className={cn('mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle', lvl === 'high' ? 'bg-accent' : 'bg-rule-strong')} aria-hidden />
36 + <span className="mr-1.5 text-2xs uppercase tracking-wide text-ink-3">{kindLabel(e.kind, e.window_years)}</span>
37 + <span className="text-ink">{e.headline}</span>
38 + </Link>
39 + </li>
40 + );
41 + })}
42 + </ul>
43 + </li>
44 + ))}
45 + </ol>
46 + );
47 +}
added apps/web/src/components/country/topics-grid.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import { ChevronRight } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { routes } from '@/lib/site';
5 +import { TOPICS } from '@/lib/topics';
6 +import type { TopicSummary } from '@/lib/types';
7 +
8 +/** Navigation to the 19 topic pages with indicator counts (n with data / n total). */
9 +export function CountryTopicsGrid({ slug, topics }: { slug: string; topics: TopicSummary[] }) {
10 + const byId = new Map(topics.map((tp) => [tp.id, tp]));
11 + return (
12 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
13 + {TOPICS.map((def) => {
14 + const tp = byId.get(def.id);
15 + return (
16 + <li key={def.id} className="border-t border-rule">
17 + <Link href={routes.countryTopic(slug, def.id)} className="group flex min-h-[56px] items-center justify-between gap-3 py-2.5">
18 + <span className="min-w-0">
19 + <span className="block truncate text-sm font-medium text-ink group-hover:text-accent">{def.name}</span>
20 + <span className="tnum block text-xs text-ink-3">
21 + {tp ? `${t('country.topics.withData', { n: tp.n_with_data })} · ${t('country.topics.count', { n: tp.n_indicators })}` : def.blurb}
22 + </span>
23 + </span>
24 + <ChevronRight size={16} aria-hidden className="shrink-0 text-ink-3 group-hover:text-accent" />
25 + </Link>
26 + </li>
27 + );
28 + })}
29 + </ul>
30 + );
31 +}
modified apps/web/src/lib/fonts.ts +3 −2
@@ -1,8 +1,9 @@
1 1 /**
2 2 * Fonts via next/font/google (self-hosted at build). If the build machine has no network, swap the import in
3 3 * layout.tsx for `./fonts.system` (same exported names, system stack) — the build must never fail on fonts.
4 + * Newsreader is variable (optical size + weight axes); `axes` is only allowed without an explicit `weight`.
4 5 */
5 6 import { Inter, Newsreader } from 'next/font/google';
6 7
7 −export const fontUi = Inter({ variable: '--font-ui', subsets: ['latin'], display: 'swap', axes: ['opsz'] });
8 −export const fontDisplay = Newsreader({ variable: '--font-display', subsets: ['latin'], display: 'swap', weight: ['400', '500', '600'], style: ['normal', 'italic'], axes: ['opsz'] });
8 +export const fontUi = Inter({ variable: '--font-ui', subsets: ['latin'], display: 'swap' });
9 +export const fontDisplay = Newsreader({ variable: '--font-display', subsets: ['latin'], display: 'swap', style: ['normal', 'italic'], axes: ['opsz'] });
added apps/web/src/lib/og.tsx +64 −0
@@ -0,0 +1,64 @@
1 +import type { ReactNode } from 'react';
2 +
3 +/**
4 + * Shared building blocks for the OG/Twitter images (next/og ImageResponse → Satori). Satori supports a flex
5 + * subset of CSS and inline SVG; no CSS variables, so colours are literal. Dark editorial background, the logo
6 + * mark, a meridian motif. Fonts: Satori's bundled default sans (loading Google fonts at build would make the
7 + * build network-dependent — avoided on purpose).
8 + */
9 +export const OG_BG = '#151513';
10 +export const OG_INK = '#f2f0ea';
11 +export const OG_INK2 = '#c9c6bd';
12 +export const OG_INK3 = '#8a877f';
13 +export const OG_ACCENT = '#5598e7';
14 +export const OG_RULE = '#2c2b28';
15 +
16 +export function OgMark({ size = 96, color = OG_ACCENT }: { size?: number; color?: string }) {
17 + return (
18 + <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
19 + <circle cx="16" cy="16" r="13" />
20 + <path d="M4.1 19.5h23.8" />
21 + <path d="M9.6 27.2 16 6.8l6.4 20.4" />
22 + <path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55" />
23 + <path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55" />
24 + </svg>
25 + );
26 +}
27 +
28 +/** Large, faint globe with meridians and parallels, positioned at the right edge. */
29 +export function OgMeridians({ size = 760, x = 640, y = -80 }: { size?: number; x?: number; y?: number }) {
30 + return (
31 + <svg width={size} height={size} viewBox="0 0 200 200" fill="none" stroke={OG_ACCENT} strokeWidth={0.6} style={{ position: 'absolute', left: x, top: y, opacity: 0.35 }}>
32 + <circle cx="100" cy="100" r="96" />
33 + <ellipse cx="100" cy="100" rx="64" ry="96" />
34 + <ellipse cx="100" cy="100" rx="32" ry="96" />
35 + <line x1="100" y1="4" x2="100" y2="196" />
36 + <line x1="4" y1="100" x2="196" y2="100" />
37 + <ellipse cx="100" cy="100" rx="96" ry="48" />
38 + <ellipse cx="100" cy="100" rx="96" ry="80" />
39 + <path d="M12 60h176M12 140h176" opacity="0.7" />
40 + </svg>
41 + );
42 +}
43 +
44 +export function OgFrame({ children }: { children: ReactNode }) {
45 + return (
46 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: OG_BG, color: OG_INK, position: 'relative', overflow: 'hidden', padding: 64, fontFamily: 'sans-serif' }}>
47 + <OgMeridians />
48 + <div style={{ position: 'absolute', left: 64, right: 64, bottom: 56, height: 1, background: OG_RULE }} />
49 + {children}
50 + </div>
51 + );
52 +}
53 +
54 +export function OgWordmark({ size = 34 }: { size?: number }) {
55 + return (
56 + <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
57 + <OgMark size={size * 1.35} />
58 + <div style={{ display: 'flex', fontSize: size, letterSpacing: -0.5 }}>
59 + <span style={{ fontWeight: 400 }}>Country</span>
60 + <span style={{ fontWeight: 700 }}>Atlas</span>
61 + </div>
62 + </div>
63 + );
64 +}
modified docs/ARCHITECTURE.md +30 −11
@@ -63,9 +63,11 @@ observations(country_id, indicator_id, period DATE /* first day of period */, ye
63 63 value DOUBLE, unit, source_id, source_dataset, source_series_code,
64 64 is_estimate BOOL, is_forecast BOOL, revision INT, retrieved_at TIMESTAMP, source_updated_at TIMESTAMP,
65 65 status TEXT /* verified|imported|warning|stale|quarantined */, metadata JSON)
66 − -- PK (country_id, indicator_id, period, frequency). Exactly ONE source per (indicator,country,period) is kept in
67 − -- observations: the highest-priority source that has a value. Alternatives are kept in observations_alt.
68 −observations_alt(same columns) -- lower-priority sources, for provenance/inspection and fallbacks
66 + -- PK (country_id, indicator_id, period, frequency). Exactly ONE source per WHOLE SERIES (country, indicator,
67 + -- frequency): the highest-priority source having non-forecast data for that country, unless a lower-priority
68 + -- source is > 3 years fresher (metadata.merge_reason = "priority" | "fresher"). Sources are never spliced
69 + -- inside a series. Every other source's complete series is kept in observations_alt.
70 +observations_alt(same columns) -- complete alternative series (other sources), for provenance / alternative views
69 71 observation_revisions(country_id, indicator_id, period, frequency, old_value, new_value, old_source_id, new_source_id,
70 72 changed_at TIMESTAMP, run_id) -- never silently overwrite: carry forward from the previous snapshot
71 73 latest(country_id, indicator_id, period, year, value, prev_period, prev_value, change_abs, change_pct,
@@ -190,10 +192,17 @@ No table or column of `schema.sql` was changed. The following precisions/deviati
190 192 `staging/<connector>/<dataset>__<code>__<indicator>.parquet` — not one per dataset. Error isolation, quarantine and the
191 193 "keep the previous file" rule apply per spec. `import_runs` holds the **latest attempt per spec** (not the full history);
192 194 `import_runs.dataset` is `"<dataset>:<code>→<indicator>"`; `rows_raw` is the raw payload size in bytes.
195 +* **Series-level source selection** (`build.py::_merge_observations`): for each `(country_id, indicator_id, frequency)`
196 + the whole series comes from one source — the highest-priority source with any non-forecast data for that country; if a
197 + lower-priority source's latest non-forecast year is more than 3 years more recent, the freshest source wins instead.
198 + The decision is stored per row in `metadata.merge_reason` (`"priority"` | `"fresher"`); `meta`/build counts report
199 + `series_fresher_source`. Consequence: forecast rows appear in `observations` only when the chosen source itself publishes
200 + them (IMF-only indicators, or IMF chosen as fresher); the complete IMF series (history + forecasts) is always available in
201 + `observations_alt` for alternative views. Series with forecast-only data fall back to plain priority.
193 202 * **Quarantined rows stay in `observations`** (never deleted) but are excluded from every derived table (`latest`,
194 203 `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.
204 + a row is quarantined (the value is flagged, not replaced). `observations_alt` = every row of every non-chosen source,
205 + whatever its status.
197 206 * **Stale** is evaluated on the *end* of the period (annual 2024 → 2024-12-31) of each country's latest observation, not on
198 207 `source_updated_at` (the WDI vintage date says nothing about a country whose series stops in 2019); only that latest row is
199 208 flagged `stale`. With 800 days, an annual series ending in 2023 is stale in September 2026, one ending in 2024 is not.
@@ -210,12 +219,22 @@ No table or column of `schema.sql` was changed. The following precisions/deviati
210 219 `rankings` includes every year with ≥ 20 countries for `ranking_eligible` indicators.
211 220 * **Changes / events**: working scale = points for percent-like indicators, log-differences (reported as % change) for
212 221 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.
222 + floor (`change_floor` in the indicator's unit; default 5 % relative, or 2 % of the series range with a 0.5-point minimum
223 + for shares/rates). YoY severity = `0.3·min(1,|z|/4) + 0.7·min(1,|Δ|/(3·floor))` when the registry defines `change_floor`
224 + (real-world magnitude dominates), else `0.6·z-part + 0.4·min(1,|Δ|/(2·floor))`; records `0.5 + 0.25·min(1,n/100) +
225 + 0.25·(exceedance in floor units)`; N-year highs/lows 0.4–0.6; sign flips 0.7; acceleration 0.4. The stored severity is
226 + multiplied by an **importance weight** (headline indicators ×1.0, `featured` ×0.9, others ×0.7; `detail.weight`,
227 + `detail.raw_severity`). **`changes` are recent by construction**: a detection is kept only if the series' latest year is
228 + within 2 years of the indicator's max year in the snapshot AND within 3 years of today; older detections exist only in
229 + `events`. Record/N-year detectors are skipped for series that are monotone over their whole history, and indicators tagged
230 + `cumulative` (e.g. `cumulative-co2`) run no detector at all. `events` (whole history) contain YoY jumps/drops, records
231 + reached after a ≥ 5-year gap since the previous record and sign flips, at most 30 per series; `changes` add N-year
232 + highs/lows (N ∈ {10, 20, 30}) and acceleration/deceleration (3 consecutive increases/decreases of the difference) at the
233 + latest period only. `id = sha1("changes|"+country+indicator+kind+period)[:16]` (`"events|"` for events). Headlines are
234 + English templates.
235 +* **OWID freshness**: raw.githubusercontent.com sends no `Last-Modified`; `source_updated_at` for the co2/energy files is the
236 + date of the last GitHub commit touching the file (`api.github.com/repos/owid/<repo>/commits?path=…`), grapher charts use
237 + `lastUpdated` from their metadata.
219 238 * **Similarity**: features/weights/transforms in `registry/similarity.yaml`; z-scores from `latest` (mixed years allowed);
220 239 a pair needs ≥ 50 % of the mode's weight in common (distance rescaled to full weight); `d0` = median pairwise distance of
221 240 the mode; `contributions[feature].contribution` = share of the squared distance. `country_dna` dimensions are defined in
modified docs/PIPELINE.md +13 −6
@@ -86,9 +86,13 @@ country's differences, with a floor of 10 % (relative) or 2 % of the series rang
86 86
87 87 1. `schema.sql` → registry tables (`countries`, `groups`, `group_members`, `sources`, `indicators`,
88 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.
89 +2. All staging parquet files whose spec still exists in the registry → `staging_all` (joined with
90 + `indicator_sources.priority`); files of removed/renamed specs are **orphans**, ignored with a warning (delete them or
91 + re-map the spec; `ca validate` lists them too). Source selection is per
92 + **series** `(country, indicator, frequency)`: the highest-priority source with non-forecast data for that country wins the
93 + whole series, unless a lower-priority source is > 3 years fresher (`metadata.merge_reason` = `priority` | `fresher`).
94 + Sources are never spliced inside a series; every other source's complete series goes to `observations_alt`. Forecast
95 + rows of the chosen source are kept (dashed on charts) but never enter derived tables.
92 96 3. `observation_revisions`: the previous `atlas.duckdb` is attached read-only; every key whose value or source changed
93 97 is recorded with the new `run_id`; the previous revisions table is copied over.
94 98 4. `import_runs` (latest `run.json` per spec) and `validation_issues` (issues sidecars).
@@ -97,9 +101,12 @@ country's differences, with a floor of 10 % (relative) or 2 % of the series rang
97 101 countries), `coverage`, indicator/source coverage columns, `search_index`.
98 102 **Rank direction:** rank 1 = lowest value when `higher_is_better = false`, otherwise highest value ("best" when
99 103 `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)."*
104 +6. `changes` / `events` (`pipeline/changes.py`, numpy per series, ≈ 45 k series in ~3 s) — see ARCHITECTURE §7/§7.1 and
105 + the module docstring; headlines are English templates such as
106 + *"Inflation fell 3.4 points to 3.4 % in 2025 (largest drop since 2009)."* `changes` only keep detections at a series'
107 + latest period when that period is recent (≤ 2 years behind the indicator's max year and ≤ 3 years behind today);
108 + monotone series and `cumulative`-tagged indicators are silent; severity = detector score × importance weight
109 + (headline 1.0 / featured 0.9 / other 0.7).
103 110 7. `similarity` (5 modes, `registry/similarity.yaml`) and `country_dna` (9 percentile dimensions, `dna:` section of the
104 111 same file). 8. `insights` (`registry/insights.yaml`, 16 templates, all numbers computed).
105 112 9. `meta` (schema_version, build_run_id, built_at, counts, connectors, duration) → `CHECKPOINT`.
modified registry/indicators.yaml +31 −6
@@ -442,8 +442,8 @@ indicators:
442 442 sources:
443 443 - {connector: imf, dataset: WEO, code: GGXCNL_NGDP, priority: 1}
444 444 - slug: government-revenue-pct-gdp
445 − name: Government revenue, excluding grants (% of GDP)
446 − short_name: Revenue
445 + name: Central government revenue, excluding grants (% of GDP)
446 + short_name: Central gov. revenue
447 447 topic: government
448 448 subtopic: Revenue & spending
449 449 unit: "% of GDP"
@@ -452,10 +452,21 @@ indicators:
452 452 bounds: [0, 150]
453 453 sources:
454 454 - {connector: worldbank, dataset: WDI, code: GC.REV.XGRT.GD.ZS, priority: 1}
455 − - {connector: imf, dataset: WEO, code: GGR_NGDP, priority: 2}
455 +- slug: general-government-revenue-pct-gdp
456 + name: General government revenue (% of GDP)
457 + short_name: Gov. revenue
458 + topic: government
459 + subtopic: Revenue & spending
460 + unit: "% of GDP"
461 + unit_short: "% GDP"
462 + format: percent
463 + bounds: [0, 150]
464 + description: Revenue of the general government sector (all levels of government), IMF WEO, including projections.
465 + sources:
466 + - {connector: imf, dataset: WEO, code: GGR_NGDP, priority: 1}
456 467 - slug: government-expenditure-pct-gdp
457 − name: Government expenditure (% of GDP)
458 − short_name: Expenditure
468 + name: Central government expense (% of GDP)
469 + short_name: Central gov. expense
459 470 topic: government
460 471 subtopic: Revenue & spending
461 472 unit: "% of GDP"
@@ -464,7 +475,20 @@ indicators:
464 475 bounds: [0, 200]
465 476 sources:
466 477 - {connector: worldbank, dataset: WDI, code: GC.XPN.TOTL.GD.ZS, priority: 1}
467 − - {connector: imf, dataset: WEO, code: GGX_NGDP, priority: 2}
478 +- slug: general-government-expenditure-pct-gdp
479 + name: General government total expenditure (% of GDP)
480 + short_name: Gov. expenditure
481 + topic: government
482 + subtopic: Revenue & spending
483 + unit: "% of GDP"
484 + unit_short: "% GDP"
485 + format: percent
486 + featured: true
487 + bounds: [0, 200]
488 + change_floor: 3
489 + description: Total expenditure of the general government sector (all levels of government), IMF WEO, including projections.
490 + sources:
491 + - {connector: imf, dataset: WEO, code: GGX_NGDP, priority: 1}
468 492 - slug: tax-revenue-pct-gdp
469 493 name: Tax revenue (% of GDP)
470 494 short_name: Tax revenue
@@ -2361,6 +2385,7 @@ indicators:
2361 2385 precision: 0
2362 2386 aggregation: sum
2363 2387 bounds: [0, null]
2388 + tags: [cumulative] # monotone stock: change/event detectors are skipped (nothing is newsworthy)
2364 2389 sources:
2365 2390 - {connector: owid, dataset: co2, code: cumulative_co2, priority: 1}
2366 2391 - slug: share-global-co2
modified registry/similarity.yaml +3 −3
@@ -14,7 +14,7 @@ modes:
14 14 - {indicator: urban-population-share}
15 15 - {indicator: trade-pct-gdp}
16 16 - {indicator: fertility-rate}
17 − - {indicator: government-expenditure-pct-gdp}
17 + - {indicator: general-government-expenditure-pct-gdp}
18 18 - {indicator: life-expectancy}
19 19 - {indicator: internet-users}
20 20 - {indicator: co2-per-capita, transform: log1p}
@@ -31,7 +31,7 @@ modes:
31 31 - {indicator: agriculture-value-added-pct-gdp}
32 32 - {indicator: gross-capital-formation-pct-gdp}
33 33 - {indicator: unemployment-rate}
34 − - {indicator: government-expenditure-pct-gdp}
34 + - {indicator: general-government-expenditure-pct-gdp}
35 35 demographic:
36 36 features:
37 37 - {indicator: median-age, weight: 1.5}
@@ -85,4 +85,4 @@ dna:
85 85 - {indicator: tertiary-enrollment}
86 86 - {indicator: expected-years-of-schooling}
87 87 public_spending:
88 − - {indicator: government-expenditure-pct-gdp}
88 + - {indicator: general-government-expenditure-pct-gdp}
modified registry/topics.yaml +1 −1
@@ -32,7 +32,7 @@ topics:
32 32 order: 2
33 33 blurb: Public debt, deficits, revenue, spending and taxation.
34 34 indicators: [government-debt-pct-gdp, general-government-gross-debt-pct-gdp, fiscal-balance-pct-gdp, government-revenue-pct-gdp,
35 − government-expenditure-pct-gdp, tax-revenue-pct-gdp, social-expenditure-pct-gdp, military-expenditure-pct-gdp,
35 + government-expenditure-pct-gdp, general-government-revenue-pct-gdp, general-government-expenditure-pct-gdp, tax-revenue-pct-gdp, social-expenditure-pct-gdp, military-expenditure-pct-gdp,
36 36 military-expenditure, health-expenditure-pct-gdp, education-expenditure-pct-gdp, interest-payments-pct-revenue,
37 37 external-debt-pct-gni, government-effectiveness, control-of-corruption, rule-of-law]
38 38 - id: population
modified src/countryatlas/connectors/owid.py +15 −0
@@ -48,6 +48,7 @@ GRAPHER_CSV = "https://ourworldindata.org/grapher/{code}.csv?v=1&csvType=full&us
48 48 GRAPHER_META = "https://ourworldindata.org/grapher/{code}.metadata.json"
49 49 GRAPHER_PAGE = "https://ourworldindata.org/grapher/{code}"
50 50 FORECAST_HORIZON_YEARS = 6 # projected values are kept only up to current year + 6
51 +GITHUB_COMMITS = "https://api.github.com/repos/{repo}/commits?path={path}&per_page=1"
51 52
52 53
53 54 class OWIDConnector(Connector):
@@ -110,10 +111,24 @@ class OWIDConnector(Connector):
110 111 notes=f"{DATASET_NAMES[dataset]} — {DATASET_HOME[dataset]}",
111 112 )
112 113 p.content_type = "text/csv"
114 + if p.source_updated_at is None: # raw.githubusercontent.com sends no Last-Modified → last commit touching the file
115 + p.source_updated_at = self._last_commit_date(dataset)
113 116 with self._lock:
114 117 self._cache[dataset] = p
115 118 return p
116 119
120 + def _last_commit_date(self, dataset: str) -> datetime | None:
121 + repo = DATASET_HOME[dataset].removeprefix("https://github.com/")
122 + path = DATASET_URLS[dataset].rsplit("/", 1)[-1]
123 + try:
124 + r = self.get(GITHUB_COMMITS.format(repo=repo, path=path))
125 + doc = orjson.loads(r.content)
126 + iso = doc[0]["commit"]["committer"]["date"] if doc else None
127 + return parse_date_utc(iso)
128 + except Exception as e: # noqa: BLE001 — freshness metadata is best effort
129 + log.warning("owid %s: GitHub commit date unavailable: %s", dataset, e)
130 + return None
131 +
117 132 def _fetch_grapher(self, code: str) -> RawPayload:
118 133 with self._lock:
119 134 if f"grapher:{code}" in self._cache:
modified src/countryatlas/pipeline/build.py +83 −11
@@ -172,6 +172,17 @@ def _spec_meta() -> dict[tuple[str, str, str, str], dict[str, Any]]:
172 172 return out
173 173
174 174
175 +def _current_staging_files() -> tuple[list[Path], list[Path]]:
176 + """Staging parquet files that still correspond to a registry source spec, and the orphans (ignored by the build)."""
177 + from countryatlas.pipeline.staging import spec_stem
178 +
179 + valid = {(s.connector, spec_stem(s)) for s in registry.source_specs()}
180 + files, orphans = [], []
181 + for f in list_staging_files():
182 + (files if (f.parent.name, f.stem) in valid else orphans).append(f)
183 + return files, orphans
184 +
185 +
175 186 # ------------------------------------------------------------------------------------------------ observations
176 187 def _load_staging(con: duckdb.DuckDBPyConnection, files: list[Path]) -> int:
177 188 if not files:
@@ -197,23 +208,82 @@ def _load_staging(con: duckdb.DuckDBPyConnection, files: list[Path]) -> int:
197 208 return con.execute("SELECT count(*) FROM staging_all").fetchone()[0]
198 209
199 210
200 −def _merge_observations(con: duckdb.DuckDBPyConnection) -> tuple[int, int]:
211 +FRESHER_YEARS = 3 # a lower-priority source wins a series when its latest actual year is > 3 years more recent
212 +
213 +
214 +def _merge_observations(con: duckdb.DuckDBPyConnection) -> tuple[int, int, int]:
215 + """Series-level merge: for each (country, indicator, frequency) the WHOLE series comes from ONE source.
216 +
217 + Chosen source = highest priority among sources having non-forecast data for that country, unless a lower-priority
218 + source's latest non-forecast year is more than FRESHER_YEARS more recent (then the freshest wins;
219 + `metadata.merge_reason` = "priority" | "fresher"). Series with forecast-only data fall back to plain priority.
220 + Every row of every other source goes to observations_alt (complete alternative series).
221 + Returns (n_observations, n_alt, n_series_chosen_as_fresher).
222 + """
201 223 cols = ("country_id, indicator_id, period, year, frequency, value, unit, source_id, source_dataset, source_series_code, "
202 224 "is_estimate, is_forecast, revision, retrieved_at, source_updated_at, status, metadata")
203 225 con.execute(
204 226 f"""
227 + CREATE TEMP TABLE series_src AS
228 + SELECT country_id, indicator_id, frequency, source_id, source_dataset, source_series_code, min(priority) AS priority,
229 + max(CASE WHEN NOT coalesce(is_forecast, false) THEN year END) AS last_actual_year,
230 + count(CASE WHEN NOT coalesce(is_forecast, false) THEN 1 END) AS n_actual
231 + FROM staging_all GROUP BY ALL;
232 +
233 + CREATE TEMP TABLE chosen AS
234 + WITH actual AS (
235 + SELECT *,
236 + row_number() OVER (PARTITION BY country_id, indicator_id, frequency
237 + ORDER BY priority, source_id, source_dataset, source_series_code) AS prio_rank,
238 + first_value(last_actual_year) OVER (PARTITION BY country_id, indicator_id, frequency
239 + ORDER BY priority, source_id, source_dataset, source_series_code) AS prio_last_year
240 + FROM series_src WHERE n_actual > 0
241 + ), pick AS (
242 + SELECT *, (last_actual_year > prio_last_year + {FRESHER_YEARS}) AS fresher,
243 + row_number() OVER (PARTITION BY country_id, indicator_id, frequency
244 + ORDER BY (last_actual_year > prio_last_year + {FRESHER_YEARS}) DESC,
245 + CASE WHEN last_actual_year > prio_last_year + {FRESHER_YEARS} THEN -last_actual_year END,
246 + priority, source_id, source_dataset, source_series_code) AS rn
247 + FROM actual
248 + )
249 + SELECT country_id, indicator_id, frequency, source_id, source_dataset, source_series_code,
250 + CASE WHEN fresher THEN 'fresher' ELSE 'priority' END AS merge_reason
251 + FROM pick WHERE rn = 1
252 + UNION ALL
253 + -- forecast-only series (no source has actual data): plain priority
254 + SELECT country_id, indicator_id, frequency, source_id, source_dataset, source_series_code, 'priority'
255 + FROM (SELECT *, row_number() OVER (PARTITION BY country_id, indicator_id, frequency
256 + ORDER BY priority, source_id, source_dataset, source_series_code) AS rn
257 + FROM series_src s
258 + WHERE NOT EXISTS (SELECT 1 FROM series_src a WHERE a.country_id = s.country_id AND a.indicator_id = s.indicator_id
259 + AND a.frequency = s.frequency AND a.n_actual > 0))
260 + WHERE rn = 1;
261 +
205 262 CREATE TEMP TABLE ranked AS
206 − SELECT *, row_number() OVER (PARTITION BY country_id, indicator_id, period, frequency
207 − ORDER BY priority, source_id, source_dataset, source_series_code) AS rn
208 − FROM staging_all;
209 − INSERT INTO observations SELECT {cols.replace('metadata', 'metadata::JSON')} FROM ranked WHERE rn = 1;
210 − INSERT INTO observations_alt SELECT {cols.replace('metadata', 'metadata::JSON')} FROM ranked WHERE rn > 1;
263 + SELECT s.*, c.merge_reason,
264 + row_number() OVER (PARTITION BY s.country_id, s.indicator_id, s.period, s.frequency
265 + ORDER BY s.source_id, s.source_dataset, s.source_series_code) AS rn
266 + FROM staging_all s
267 + JOIN chosen c ON c.country_id = s.country_id AND c.indicator_id = s.indicator_id AND c.frequency = s.frequency
268 + AND c.source_id = s.source_id AND c.source_dataset = s.source_dataset
269 + AND c.source_series_code = s.source_series_code;
270 +
271 + INSERT INTO observations
272 + SELECT {cols.replace('metadata', "json_merge_patch(coalesce(metadata, '{}')::JSON, json_object('merge_reason', merge_reason))")}
273 + FROM ranked WHERE rn = 1;
274 +
275 + INSERT INTO observations_alt
276 + SELECT {cols.replace('metadata', 'metadata::JSON')} FROM staging_all s
277 + WHERE NOT EXISTS (SELECT 1 FROM ranked r WHERE r.country_id = s.country_id AND r.indicator_id = s.indicator_id
278 + AND r.period = s.period AND r.frequency = s.frequency AND r.source_id = s.source_id
279 + AND r.source_dataset = s.source_dataset AND r.source_series_code = s.source_series_code AND r.rn = 1);
211 280 DROP TABLE ranked;
212 281 """
213 282 )
214 283 n = con.execute("SELECT count(*) FROM observations").fetchone()[0]
215 284 n_alt = con.execute("SELECT count(*) FROM observations_alt").fetchone()[0]
216 − return n, n_alt
285 + n_fresher = con.execute("SELECT count(*) FROM chosen WHERE merge_reason = 'fresher'").fetchone()[0]
286 + return n, n_alt, n_fresher
217 287
218 288
219 289 def _carry_revisions(con: duckdb.DuckDBPyConnection, previous_db: Path, run_id: str) -> tuple[int, int]:
@@ -326,8 +396,10 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
326 396 build_path = settings.build_dir / f"atlas-{run_id}.duckdb"
327 397 for stale in settings.build_dir.glob("atlas-*.duckdb*"):
328 398 stale.unlink(missing_ok=True)
329 − files = list_staging_files()
330 − log.info("build %s: %d staging files → %s", run_id, len(files), build_path)
399 + files, orphans = _current_staging_files()
400 + for o in orphans: # spec removed/renamed in the registry since the file was staged → must not leak into the snapshot
401 + log.warning("ignoring orphan staging file (no matching source spec in the registry): %s", o)
402 + log.info("build %s: %d staging files (%d orphans ignored) → %s", run_id, len(files), len(orphans), build_path)
331 403 counts: dict[str, int] = {}
332 404 warnings: list[str] = []
333 405 con = duckdb.connect(str(build_path))
@@ -337,7 +409,7 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
337 409 _timer("registry tables", t0)
338 410
339 411 counts["staging_rows"] = _load_staging(con, files)
340 − counts["observations"], counts["observations_alt"] = _merge_observations(con)
412 + counts["observations"], counts["observations_alt"], counts["series_fresher_source"] = _merge_observations(con)
341 413 _timer("merge observations", t0)
342 414
343 415 counts["revisions_new"], counts["revisions_carried"] = _carry_revisions(con, settings.db_path, run_id)
@@ -357,7 +429,7 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
357 429 series = con.execute(
358 430 "SELECT country_id, indicator_id, period, year, value FROM obs_ok ORDER BY country_id, indicator_id, period"
359 431 ).pl()
360 − ch, ev = compute_changes_and_events(series, ind_by_id)
432 + ch, ev = compute_changes_and_events(series, ind_by_id, headline_ids=set(registry.topics()["headline"]))
361 433 con.register("df_changes", ch)
362 434 con.register("df_events", ev)
363 435 con.execute("INSERT INTO changes SELECT id, country_id, indicator_id, kind, period, year, value, ref_value, delta, "
modified src/countryatlas/pipeline/changes.py +83 −16
@@ -33,8 +33,11 @@ RECORD_MIN_POINTS = 10
33 33 N_YEAR_WINDOWS = (30, 20, 10)
34 34 DEFAULT_REL_FLOOR = 0.05 # 5 % for level series without change_floor
35 35 DEFAULT_RANGE_FLOOR = 0.02 # 2 % of the series range for other series without change_floor
36 +MIN_POINTS_FLOOR = 0.5 # percent-like indicators without change_floor: at least 0.5 point
36 37 MAX_EVENTS_PER_SERIES = 30
37 38 MIN_SCALE_RATIO = 0.25 # lower bound of the robust scale, as a fraction of the floor (see _scale)
39 +RECENT_YEARS_FROM_MAX = 2 # a change must be within 2 years of the indicator's latest year in the snapshot…
40 +RECENT_YEARS_FROM_NOW = 3 # …and within 3 years of today
38 41 RECORD_GAP_YEARS = 5
39 42 SIGN_FLIP_HINTS = ("growth", "balance", "net-migration", "inflation", "change")
40 43
@@ -72,7 +75,10 @@ def _floor(ind: Indicator, values: np.ndarray, mode: str) -> tuple[float, bool]:
72 75 if mode == "relative":
73 76 return DEFAULT_REL_FLOOR, False
74 77 rng = float(np.nanmax(values) - np.nanmin(values)) if len(values) else 0.0
75 − return (DEFAULT_RANGE_FLOOR * rng if rng > 0 else 0.0), False
78 + floor = DEFAULT_RANGE_FLOOR * rng if rng > 0 else 0.0
79 + if mode == "points": # a share/rate must move at least half a point to be news
80 + floor = max(floor, MIN_POINTS_FLOOR)
81 + return floor, False
76 82
77 83
78 84 def _robust(d: np.ndarray) -> tuple[float, float]:
@@ -128,10 +134,51 @@ def _row(
128 134 "window_years": window,
129 135 "severity": float(min(1.0, max(0.0, severity))),
130 136 "headline": headline,
131 − "detail": json.dumps(detail, default=str),
137 + "detail": detail, # dict here; serialised by _finalize once the importance weight is applied
132 138 }
133 139
134 140
141 +def _finalize(rows: list[dict[str, Any]], weight: float) -> list[dict[str, Any]]:
142 + """Apply the indicator importance weight (headline 1.0 / featured 0.9 / other 0.7) and serialise `detail`."""
143 + for r in rows:
144 + raw = r["severity"]
145 + r["detail"] = json.dumps({**r["detail"], "weight": weight, "raw_severity": round(raw, 4)}, default=str)
146 + r["severity"] = float(min(1.0, raw * weight))
147 + return rows
148 +
149 +
150 +def indicator_weight(ind: Indicator, headline_ids: set[str]) -> float:
151 + if ind.slug in headline_ids:
152 + return 1.0
153 + return 0.9 if ind.featured else 0.7
154 +
155 +
156 +def _is_monotone(v: np.ndarray) -> bool:
157 + """Whole-history monotone (non-decreasing or non-increasing): every point is a 'record' → nothing newsworthy."""
158 + dv = np.diff(v)
159 + dv = dv[np.isfinite(dv)]
160 + return len(dv) > 0 and (bool(np.all(dv >= 0)) or bool(np.all(dv <= 0)))
161 +
162 +
163 +def _yoy_severity(z: float, magnitude: float, floor: float, floor_abs: bool) -> float:
164 + """Blend of z-score and real-world magnitude; when the registry defines a change_floor the magnitude dominates."""
165 + z_part = min(1.0, abs(z) / 4.0)
166 + if floor_abs:
167 + return 0.3 * z_part + 0.7 * min(1.0, magnitude / (3.0 * floor))
168 + return 0.6 * z_part + 0.4 * min(1.0, magnitude / (2.0 * floor))
169 +
170 +
171 +def _record_severity(n: int, cur: float, prev_record: float, floor: float, floor_abs: bool, mode: str) -> float:
172 + """0.5 base + series length (≤ 0.25) + how far the record was beaten in floor units (≤ 0.25)."""
173 + if floor <= 0:
174 + exceed = 0.0
175 + elif floor_abs or mode != "relative":
176 + exceed = abs(cur - prev_record) / (2.0 * floor)
177 + else:
178 + exceed = abs(np.log(cur / prev_record)) / (2.0 * floor) if cur > 0 and prev_record > 0 else 0.0
179 + return 0.5 + 0.25 * min(1.0, n / 100.0) + 0.25 * min(1.0, exceed)
180 +
181 +
135 182 # ------------------------------------------------------------------------------------------------ changes (latest)
136 183 def detect_changes(s: Series, ind: Indicator) -> list[dict[str, Any]]:
137 184 n = len(s.values)
@@ -171,7 +218,7 @@ def detect_changes(s: Series, ind: Indicator) -> list[dict[str, Any]]:
171 218 since_txt = f"largest {'rise' if dl > 0 else 'drop'} since {since_year}"
172 219 else:
173 220 since_txt = f"largest {'rise' if dl > 0 else 'drop'} on record"
174 − sev = 0.6 * min(1.0, abs(z) / 4.0) + 0.4 * min(1.0, magnitude / (2 * floor))
221 + sev = _yoy_severity(z, magnitude, floor, floor_abs)
175 222 delta_txt = fmt_delta(cur - prev, _pct(cur, prev), ind)
176 223 headline = f"{name} {verb} {delta_txt} to {fmt_value(cur, ind)} in {year} ({since_txt})."
177 224 out.append(
@@ -180,20 +227,20 @@ def detect_changes(s: Series, ind: Indicator) -> list[dict[str, Any]]:
180 227 "n_points": n, "prev_year": int(s.years[i - 1])}, 1, "changes")
181 228 )
182 229
183 − # --- record high / low, N-year high / low -----------------------------------------------------------------
184 − if n >= RECORD_MIN_POINTS:
230 + # --- record high / low, N-year high / low (skipped for monotone series: every point would be a record) --------
231 + if n >= RECORD_MIN_POINTS and not _is_monotone(v):
185 232 past = v[:-1]
186 233 pmax, pmin = float(np.nanmax(past)), float(np.nanmin(past))
187 234 first_year = int(s.years[0])
188 235 if cur > pmax:
189 − sev = 0.6 + min(0.4, n / 150.0)
236 + sev = _record_severity(n, cur, pmax, floor, floor_abs, mode)
190 237 out.append(
191 238 _row(s, ind, "record_high", i, pmax, sev,
192 239 f"{name} reached a record high of {fmt_value(cur, ind)} in {year} (series since {first_year}).",
193 240 {"previous_max": pmax, "n_points": n, "first_year": first_year}, n, "changes")
194 241 )
195 242 elif cur < pmin:
196 − sev = 0.6 + min(0.4, n / 150.0)
243 + sev = _record_severity(n, cur, pmin, floor, floor_abs, mode)
197 244 out.append(
198 245 _row(s, ind, "record_low", i, pmin, sev,
199 246 f"{name} fell to a record low of {fmt_value(cur, ind)} in {year} (series since {first_year}).",
@@ -274,13 +321,13 @@ def detect_events(s: Series, ind: Indicator) -> list[dict[str, Any]]:
274 321 prev, cur = float(v[i - 1]), float(v[i])
275 322 kind = "yoy_jump" if d[j] > 0 else "yoy_drop"
276 323 verb = "rose" if d[j] > 0 else "fell"
277 − sev = 0.6 * min(1.0, abs(float(z[j])) / 4.0) + 0.4 * min(1.0, float(magnitude[j]) / (2 * floor))
324 + sev = _yoy_severity(float(z[j]), float(magnitude[j]), floor, floor_abs)
278 325 headline = f"{name} {verb} {fmt_delta(cur - prev, _pct(cur, prev), ind)} to {fmt_value(cur, ind)} in {int(s.years[i])}."
279 326 out.append(_row(s, ind, kind, i, prev, sev, headline,
280 327 {"z": round(float(z[j]), 2), "mode": mode, "prev_year": int(s.years[i - 1])}, 1, "events"))
281 328
282 329 # Records reached after a gap of ≥ RECORD_GAP_YEARS years since the previous record (monotone series stay quiet)
283 − if n >= RECORD_MIN_POINTS:
330 + if n >= RECORD_MIN_POINTS and not _is_monotone(v):
284 331 run_max = np.maximum.accumulate(v)
285 332 run_min = np.minimum.accumulate(v)
286 333 last_max_year = int(s.years[0])
@@ -338,23 +385,43 @@ def iter_series(df: pl.DataFrame):
338 385 yield Series(str(countries[a]), str(indicators[a]), periods[a:b], years[a:b], values[a:b])
339 386
340 387
341 −def compute_changes_and_events(df: pl.DataFrame, indicators: dict[str, Indicator]) -> tuple[pl.DataFrame, pl.DataFrame]:
342 − """df: non-forecast, non-quarantined observations (country_id, indicator_id, period, year, value)."""
388 +def compute_changes_and_events(
389 + df: pl.DataFrame, indicators: dict[str, Indicator], headline_ids: set[str] | None = None, now: datetime | None = None
390 +) -> tuple[pl.DataFrame, pl.DataFrame]:
391 + """df: non-forecast, non-quarantined observations (country_id, indicator_id, period, year, value).
392 +
393 + `changes` are RECENT by construction: a detection at the latest period of a series is kept only if that period is
394 + within RECENT_YEARS_FROM_MAX of the indicator's global max year in the snapshot AND within RECENT_YEARS_FROM_NOW of
395 + the current year (a series that stopped in 2008 produces events, not changes). Indicators tagged `cumulative` are
396 + skipped entirely. Severity is multiplied by the indicator importance weight (see indicator_weight).
397 + """
398 + headline_ids = headline_ids or set()
399 + now = now or datetime.now(UTC)
343 400 changes: list[dict[str, Any]] = []
344 401 events: list[dict[str, Any]] = []
345 − n_series = 0
402 + n_series = n_skipped = 0
403 + max_year: dict[str, int] = {}
404 + if not df.is_empty():
405 + max_year = {r[0]: int(r[1]) for r in df.group_by("indicator_id").agg(pl.col("year").max()).iter_rows()}
346 406 for s in iter_series(df):
347 407 ind = indicators.get(s.indicator_id)
348 408 if ind is None:
349 409 continue
410 + if "cumulative" in (ind.tags or []):
411 + n_skipped += 1
412 + continue
350 413 n_series += 1
414 + weight = indicator_weight(ind, headline_ids)
351 415 try:
352 − changes.extend(detect_changes(s, ind))
353 − events.extend(detect_events(s, ind))
416 + recent_from = max(max_year.get(s.indicator_id, 0) - RECENT_YEARS_FROM_MAX, now.year - RECENT_YEARS_FROM_NOW)
417 + if int(s.years[-1]) >= recent_from:
418 + changes.extend(_finalize(detect_changes(s, ind), weight))
419 + events.extend(_finalize(detect_events(s, ind), weight))
354 420 except Exception:
355 421 log.exception("detector failed for %s/%s", s.country_id, s.indicator_id)
356 − log.info("detectors: %d series → %d changes, %d events", n_series, len(changes), len(events))
357 − detected_at = datetime.now(UTC).replace(tzinfo=None)
422 + log.info("detectors: %d series (%d cumulative skipped) → %d changes, %d events", n_series, n_skipped, len(changes),
423 + len(events))
424 + detected_at = now.replace(tzinfo=None)
358 425 schema = {
359 426 "id": pl.Utf8, "country_id": pl.Utf8, "indicator_id": pl.Utf8, "kind": pl.Utf8, "period": pl.Date,
360 427 "year": pl.Int32, "value": pl.Float64, "ref_value": pl.Float64, "delta": pl.Float64, "delta_pct": pl.Float64,
modified tests/test_build.py +25 −7
@@ -16,11 +16,16 @@ COUNTRIES = ["CAN", "USA", "FRA", "DEU", "JPN", "BRA", "IND", "NGA", "AUS", "MEX
16 16 "ZAF", "EGY", "TUR", "ARG", "IDN", "SWE", "NOR", "CHL", "POL"]
17 17
18 18
19 −def _stage(spec: IndicatorSourceSpec, unit: str, base: float, growth: float, years: range, forecast_from: int | None = None) -> None:
19 +def _stage(spec: IndicatorSourceSpec, unit: str, base: float, growth: float, years: range, forecast_from: int | None = None,
20 + skip: tuple[str, ...] = (), stop_at: dict[str, int] | None = None) -> None:
20 21 now = datetime.now(UTC)
21 22 rows = []
22 23 for k, c in enumerate(COUNTRIES):
24 + if c in skip:
25 + continue
23 26 for y in years:
27 + if stop_at and c in stop_at and y > stop_at[c]:
28 + continue
24 29 v = base * (1 + 0.05 * k) * (growth ** (y - years.start))
25 30 rows.append(NormalizedObservation(country_id=c, indicator_id=spec.indicator_id, period=date(y, 1, 1), year=y,
26 31 frequency="A", value=v, unit=unit, source_id=spec.connector,
@@ -33,9 +38,10 @@ def _stage(spec: IndicatorSourceSpec, unit: str, base: float, growth: float, yea
33 38
34 39
35 40 def _tiny_staging() -> None:
41 + # WB: no data for POL (IMF takes the whole series); CHL stops in 2018 (IMF is > 3 years fresher → "fresher")
36 42 wb = IndicatorSourceSpec(indicator_id="gdp-per-capita", connector="worldbank", dataset="WDI", code="NY.GDP.PCAP.CD")
37 − _stage(wb, "current US$", 10_000, 1.03, range(2000, 2025))
38 − # a lower-priority alternative source for the same indicator → observations_alt
43 + _stage(wb, "current US$", 10_000, 1.03, range(2000, 2025), skip=("POL",), stop_at={"CHL": 2018})
44 + # a lower-priority alternative source for the same indicator (complete series → observations_alt where WB wins)
39 45 imf = IndicatorSourceSpec(indicator_id="gdp-per-capita", connector="imf", dataset="WEO", code="NGDPDPC", priority=2)
40 46 _stage(imf, "current US$", 10_100, 1.03, range(2000, 2027), forecast_from=2025)
41 47 pop = IndicatorSourceSpec(indicator_id="population", connector="worldbank", dataset="WDI", code="SP.POP.TOTL")
@@ -63,9 +69,19 @@ def test_build_tiny_staging_produces_all_tables() -> None:
63 69 assert t in tables, t
64 70 n_obs = con.execute("SELECT count(*) FROM observations").fetchone()[0]
65 71 n_alt = con.execute("SELECT count(*) FROM observations_alt").fetchone()[0]
66 − assert n_obs > 0 and n_alt > 0 # IMF rows lose the priority race where WB has a value
67 − # forecasts kept in observations but excluded from latest/rankings
68 − assert con.execute("SELECT count(*) FROM observations WHERE is_forecast").fetchone()[0] > 0
72 + assert n_obs > 0 and n_alt > 0 # IMF series lose the priority race where WB has data
73 + # one source per (country, indicator, frequency) series — never spliced
74 + assert con.execute("SELECT count(*) FROM (SELECT country_id, indicator_id, frequency FROM observations "
75 + "GROUP BY ALL HAVING count(DISTINCT source_id) > 1)").fetchone()[0] == 0
76 + assert con.execute("SELECT count(*) FROM observations_alt WHERE country_id='CAN' AND source_id='imf'").fetchone()[0] == 27
77 + src = dict(con.execute("SELECT country_id, source_id FROM observations WHERE indicator_id='gdp-per-capita' "
78 + "GROUP BY ALL").fetchall())
79 + assert src["CAN"] == "worldbank" and src["POL"] == "imf" and src["CHL"] == "imf"
80 + reasons = dict(con.execute("SELECT country_id, json_extract_string(metadata, '$.merge_reason') FROM observations "
81 + "WHERE indicator_id='gdp-per-capita' GROUP BY ALL").fetchall())
82 + assert reasons["CAN"] == "priority" and reasons["POL"] == "priority" and reasons["CHL"] == "fresher"
83 + # forecasts (from the chosen source) kept in observations but excluded from latest/rankings
84 + assert con.execute("SELECT count(*) FROM observations WHERE is_forecast").fetchone()[0] == 2 * 2
69 85 assert con.execute("SELECT count(*) FROM latest WHERE is_forecast").fetchone()[0] == 0
70 86 assert con.execute("SELECT max(year) FROM latest WHERE indicator_id='gdp-per-capita'").fetchone()[0] == 2024
71 87 lat = con.execute("SELECT rank_world, n_world, change_10y_pct FROM latest WHERE country_id='CAN' AND indicator_id='gdp-per-capita'").fetchone()
@@ -79,6 +95,7 @@ def test_build_tiny_staging_produces_all_tables() -> None:
79 95 assert con.execute("SELECT count(*) FROM insights").fetchone()[0] > 0
80 96 assert con.execute("SELECT count(*) FROM country_dna").fetchone()[0] > 0
81 97 assert con.execute("SELECT count(*) FROM import_runs").fetchone()[0] == 6
98 + assert r.counts["series_fresher_source"] == 1
82 99 meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
83 100 assert meta["schema_version"] == "1" and meta["build_run_id"] == "20260101T000000Z"
84 101 assert int(meta["observation_count"]) == n_obs
@@ -137,6 +154,7 @@ def test_export_helpers(tmp_path: Path) -> None:
137 154
138 155 p = export_indicator("gdp-per-capita", "json", tmp_path)
139 156 doc = json.loads(p.read_bytes())
140 − assert doc["meta"]["run_id"] == "20260101T000000Z" and len(doc["rows"]) == len(COUNTRIES) * 27
157 + # 22 WB series × 25 years + POL and CHL taken whole from IMF (27 rows each, incl. 2 forecasts)
158 + assert doc["meta"]["run_id"] == "20260101T000000Z" and len(doc["rows"]) == 22 * 25 + 2 * 27
141 159 c = export_country("CAN", "csv", tmp_path)
142 160 assert c.exists() and c.read_text().count("\n") > 50
modified tests/test_changes.py +34 −0
@@ -4,6 +4,7 @@ from datetime import date
4 4
5 5 import numpy as np
6 6 import polars as pl
7 +import pytest
7 8
8 9 from countryatlas.pipeline.changes import Series, compute_changes_and_events, detect_changes, detect_events
9 10 from countryatlas.registry import indicators_by_id
@@ -37,6 +38,7 @@ def test_floor_blocks_small_moves() -> None:
37 38 def test_record_high_and_relative_headline() -> None:
38 39 ind = indicators_by_id()["gdp-per-capita"]
39 40 vals = [1000 * 1.03**k for k in range(15)]
41 + vals[5] *= 0.9 # a dip: the series must not be monotone, otherwise records are (rightly) not newsworthy
40 42 vals[-1] = vals[-2] * 1.25
41 43 out = detect_changes(_series("gdp-per-capita", vals), ind)
42 44 kinds = {r["kind"] for r in out}
@@ -56,6 +58,38 @@ def test_sign_flip_and_events_history() -> None:
56 58 assert any(r["kind"] == "yoy_drop" and r["year"] == 2006 for r in ev)
57 59
58 60
61 +def test_monotone_and_cumulative_series_are_silent() -> None:
62 + inds = indicators_by_id()
63 + vals = [100.0 * 1.02**k for k in range(30)] # strictly increasing: every point is a "record"
64 + ch = detect_changes(_series("population", vals), inds["population"])
65 + assert not any(r["kind"] in ("record_high", "n_year_high") for r in ch)
66 + rows = [{"country_id": "CAN", "indicator_id": "cumulative-co2", "period": date(1990 + k, 1, 1), "year": 1990 + k, "value": v}
67 + for k, v in enumerate(vals)]
68 + ch2, ev2 = compute_changes_and_events(pl.DataFrame(rows), inds)
69 + assert ch2.height == 0 and ev2.height == 0
70 +
71 +
72 +def test_changes_are_recent_and_weighted() -> None:
73 + inds = indicators_by_id()
74 + old = [2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 2.1, 2.0, 6.8, 3.4] # series ends in 2008
75 + rows = [{"country_id": "CAN", "indicator_id": "inflation", "period": date(1999 + k, 1, 1), "year": 1999 + k, "value": v}
76 + for k, v in enumerate(old)]
77 + rows += [{"country_id": "FRA", "indicator_id": "inflation", "period": date(2016 + k, 1, 1), "year": 2016 + k, "value": v}
78 + for k, v in enumerate(old)]
79 + from datetime import UTC, datetime
80 +
81 + ch, ev = compute_changes_and_events(pl.DataFrame(rows), inds, headline_ids={"inflation"}, now=datetime(2026, 9, 1, tzinfo=UTC))
82 + assert set(ch["country_id"].to_list()) == {"FRA"} # Canada's 2008 drop is an event, not a change
83 + assert "CAN" in set(ev["country_id"].to_list())
84 + import json
85 +
86 + d = json.loads(ch["detail"][0])
87 + assert d["weight"] == 1.0 and "raw_severity" in d
88 + ch_w, _ = compute_changes_and_events(pl.DataFrame(rows), inds, headline_ids=set(), now=datetime(2026, 9, 1, tzinfo=UTC))
89 + assert json.loads(ch_w["detail"][0])["weight"] == 0.9 # inflation is featured but not headline here
90 + assert ch_w["severity"][0] == pytest.approx(min(1.0, d["raw_severity"] * 0.9), rel=1e-3)
91 +
92 +
59 93 def test_driver_over_frame() -> None:
60 94 inds = indicators_by_id()
61 95 rows = []
62 96