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%

Web app: foundation, brand, country pages, compare, rankings, indicators, regions, explore, changes, data, sources, methodology, API docs, admin

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

339 changed files +11,249 −329

added apps/web/README.md +219 −0
@@ -0,0 +1,219 @@
1 +# CountryAtlas — web app (`@countryatlas/web`)
2 +
3 +Next.js 16.3.4 · React 19.2.8 · TypeScript strict · Tailwind v4 · own SVG chart kit (d3-geo/scale/shape only for math).
4 +Mobile-first, editorial, provenance-first. The country is the primary unit of navigation.
5 +
6 +This README is the contract for whoever adds pages next (compare / rankings / indicators / regions / sources /
7 +methodology / api / admin). Reuse what is here; do not add a second design system.
8 +
9 +## Run
10 +
11 +```bash
12 +pnpm install # from the repo root (pnpm workspace: apps/*)
13 +cd apps/web
14 +pnpm dev # http://localhost:8290 — API_URL from .env.local (default http://127.0.0.1:8291)
15 +pnpm typecheck && pnpm build && pnpm start # production: next start -p 8290 -H 0.0.0.0
16 +```
17 +
18 +Env (see `.env.example`): `API_URL` (server-side base of the FastAPI service, loopback), `NEXT_PUBLIC_SITE_URL`
19 +(canonical/OG/sitemap). Browser code never talks to the API host directly: `next.config.ts` rewrites
20 +`/api/v1/:path*` → `${API_URL}/api/v1/:path*`.
21 +
22 +Without the real snapshot you can run the **fixture API** (synthetic, deterministic, every provenance says
23 +"Fixture (mock data)"): `cd <repo> && .venv/bin/python apps/web/qa/mock_api.py` (port 8299) and set
24 +`API_URL=http://127.0.0.1:8299` in `apps/web/.env.local`. Never point production at it.
25 +
26 +Real API once the DB exists: `cd <repo> && CA_DATA_DIR=~/countryatlas-data .venv/bin/python -m uvicorn countryatlas.api.main:app --port 8293`
27 +then `API_URL=http://127.0.0.1:8293`.
28 +
29 +## QA
30 +
31 +```bash
32 +node qa/screens.mjs [BASE_URL] # screenshots 320/360/375/390/414/430/1280/1440 → qa/screens/, report.json:
33 + # overflow (scrollWidth>clientWidth), tap targets <44px, tab-bar/footer overlap, CLS, console errors
34 +node qa/segments.mjs [BASE_URL] # viewport-sized segments (top + scroll offsets) → qa/screens/seg/ for visual review
35 +node qa/favicons.mjs # re-rasterise src/app/icon.svg → icon.png (512) + apple-icon.png (180)
36 +```
37 +
38 +Playwright comes from `~/Desktop/uqo-eval/node_modules` (symlinked into `node_modules/playwright[-core]`; ESM ignores
39 +`NODE_PATH`). Last run (2026-09-11, prod build, fixture API): 32 page×width renders, **0 overflow, 0 console errors,
40 +0 layout shift**, only the sr-only skip link below 44 px. Unknown country / topic return a **true HTTP 404**.
41 +
42 +**Soft-404 gotcha (fixed here, keep it that way):** a `loading.tsx` that wraps a segment which calls `notFound()`
43 +streams a 200 shell before the 404 is known. Loading files therefore live only in route groups that never contain
44 +dynamic segments: `app/(home)/loading.tsx`, `app/countries/(list)/loading.tsx`. Do **not** add `loading.tsx` at
45 +`app/`, `app/countries/` or inside `[slug]`. Same rule for your new dynamic routes.
46 +
47 +## Layout of `src/`
48 +
49 +```
50 +app/
51 + layout.tsx fonts, metadata template "%s — CountryAtlas", theme boot script, providers, header/footer/tab bar
52 + globals.css design tokens (@theme inline) + chart chrome classes
53 + (home)/page.tsx / countries/(list)/page.tsx /countries
54 + countries/[slug]/page.tsx + [topic]/page.tsx + opengraph-image.tsx (dynamic per-country OG)
55 + indicators/page.tsx (?topic=) indicators/[slug]/page.tsx + opengraph-image.tsx regions/page.tsx regions/[slug]/page.tsx
56 + explore/ changes/ data/ sources/ + sources/[id]/ methodology/ api/ (developer landing, live examples)
57 + admin/login/page.tsx + admin/actions.ts (server actions) + admin/(panel)/{layout,page,coverage,runs,issues,raw} (cookie gate)
58 + opengraph-image.tsx twitter-image.tsx og-shared.tsx default social image (static)
59 + icon.svg icon.png apple-icon.png manifest.ts robots.ts sitemap.ts not-found.tsx error.tsx
60 +lib/
61 + api.ts server-only typed fetch: api.home() api.countries() api.country(id) api.countryTopic() api.countrySeries()
62 + api.countryChanges/Events/Similar/Insights/Dna() api.indicatorMap() api.ranking() api.search() api.changes()
63 + request<T>(path, query, {revalidate}) for new endpoints · ApiError · safe() · isNotBuilt() · isNotFound()
64 + client-api.ts browser fetches to same-origin /api/v1 (search, series, similar) — keep small
65 + api-explore.ts / client-api-explore.ts / types-explore.ts indicators, trend, series bundle, regions, changes, sources,
66 + methodology, admin payloads (+ browser fetches for the map year slider, trend groups, compare, similar)
67 + admin-auth.ts CA_ADMIN_TOKEN gate: constant-time compare, httpOnly cookie `ca_admin` (sha256 of the token, path /admin)
68 + admin-api.ts X-Admin-Token fetches (no-store) to ADMIN_API_URL ?? API_URL — server components/actions only
69 + types.ts mirror of src/countryatlas/api/schemas.py (see "Types")
70 + format.ts formatValue / formatChange / formatPeriod / formatRank / formatDate / relativeFreshness / formatTick / compact
71 + site.ts SITE_URL, SITE_NAME, TAGLINE, routes.* (every internal href goes through routes)
72 + topics.ts static mirror of registry/topics.yaml (19 topics, HEADLINE_INDICATORS, HEADLINE_TOPIC)
73 + regions.ts WB_REGIONS (7) + INCOME_GROUPS (4) with slugs/short names
74 + map-geo.ts world-atlas 110m → Equal Earth paths (cached per process), classIndex(); iso-numeric.ts numeric→ISO3
75 + og.tsx Satori-safe building blocks for OG images (OgFrame, OgWordmark, OgMark, OgMeridians)
76 + fonts.ts Inter (--font-ui) + Newsreader (--font-display) via next/font/google; fonts.system.ts = offline fallback
77 + severity.ts 0–1 severity → high/medium/low + label
78 +i18n/
79 + en.ts the dictionary (flat dotted keys) · index.ts: t(key, params), tOpt(dynamicKey, fallback), LOCALE='en-US'
80 +components/
81 + brand/Logo.tsx Logo variant="full|mark|wordmark", LogoMark, Wordmark, logoMarkSvg()
82 + layout/ SiteHeader (PRIMARY_NAV), NavLinks, MobileTabBar, SiteFooter (+ FooterCredits), ThemeToggle,
83 + SearchProvider/useSearch/useOpenSearch, SearchTrigger (field|icon|hero), SearchDialog (lazy)
84 + charts/ see "Chart kit"
85 + data/ Section, Metric (+MetricGrid, payloadFor), ChangeChip, RankBadge, FreshnessBadge/StatusBadge,
86 + TopicNav, CountryChip, DataTable, EmptyState/NotBuiltState, BottomSheet, ChangeList,
87 + ProvenanceProvider/useProvenance/ProvenanceSheet
88 + home/ Hero, SnapshotStrip, RegionChips, CompareTeaser (COMPARE_PRESETS), TopicsGrid, IndicatorList
89 + countries/directory.tsx client filter/sort/A–Z directory (URL-synced)
90 + indicators/ IndicatorDirectory (topic nav + filter), IndicatorMap (year slider → /map?year=), IndicatorTrend
91 + (group selector → /trend), IndicatorCompare (≤ 5 countries → /series), map-geometry.ts (server)
92 + regions/ MembersTable (sortable), MemberRanking (indicator picker → /rankings?group=), MemberMap (static SVG)
93 + explore/ EntityPicker (typeahead country|indicator), EntityPickerNav, CopyButton/CodeBlock, PageHeader,
94 + DownloadPicker, SimilarityPlayground
95 + changes/changes-feed.tsx global feed: kind/indicator/region/severity filters (BottomSheet on phones), grouped by year
96 + methodology/toc.tsx sticky TOC with IntersectionObserver highlight
97 + admin/ AdminNav, admin-ui (AdminSection, Kv, AdminTable, Pill), CoverageTable (sortable)
98 + country/ CountryHeader, ShareButton, SimilarPanel, KeyFacts, Timeline, CountryTopicsGrid, IndicatorRow
99 +```
100 +
101 +## Conventions (follow these on new pages)
102 +
103 +* **Server components fetch; client components interact.** One `await` for the primary payload, then
104 + `Promise.all([safe(api.x()), …])` for optional panels — each tolerates failure (`null` → EmptyState). No client
105 + fetch waterfalls. `export const revalidate = 900` on pages.
106 +* **Errors:** `api.*` throws `ApiError`. Pattern used by every page:
107 + ```ts
108 + try { data = await api.thing(id) } catch (e) { if (isNotFound(e)) notFound(); if (isNotBuilt(e)) return <NotBuiltState/>; throw e }
109 + ```
110 + `isNotBuilt` covers 503 ("Data not built yet") **and** an unreachable API — the site renders a calm state, never crashes.
111 +* **Strings:** everything visible goes through `t('key')` in `src/i18n/en.ts`. Dynamic keys → `tOpt`.
112 +* **Numbers/dates:** `lib/format.ts` only, fixed `en-US` locale (Intl hydration bug prevention). Prefer the API's
113 + `formatted` string when present (`m.formatted ?? formatValue(m.value, m)`) so server and client agree.
114 + `tnum` utility on columns of numbers, `pnum` on hero figures.
115 +* **No cards.** `Section` (rule above, editorial heading, subtitle, right-side actions) + `divide-y divide-rule` lists.
116 + Grid children that contain long text need `min-w-0` (Section already has it).
117 +* **Never invent numbers.** Missing → `EmptyState` / "No data" (`t('common.noData')`).
118 +* **Metadata:** `generateMetadata` uses the API; titles follow `"{Name} Data & Statistics"`; canonical via `routes.*`.
119 +* **Provenance everywhere:** any value → `useProvenance().open(payload)`. Build payloads with `payloadFor(metricValue,
120 + country)` or hand-roll `{ indicator: {slug,name,format,unit,…}, value: {value, period, provenance}, country }`.
121 + Chart source lines take the same `payload` prop.
122 +* **Touch targets ≥ 44 px on mobile** (`tap` utility or `min-h-[44px] md:min-h-[32px]`), `h-11 md:h-9` for chips.
123 +* **Mobile:** bottom tab bar is fixed; `<body>` reserves `pb-[calc(56px+env(safe-area-inset-bottom))] md:pb-0`.
124 + Filters on phones → `BottomSheet side="center"`. Horizontal scrollers use `scrollbar-none -mx-4 px-4`.
125 +* **Reordering between breakpoints via CSS `order` is forbidden** for page sections (DOM order = visual order); it is
126 + acceptable only inside a single small widget row (SimilarPanel bar).
127 +
128 +## Design tokens (`globals.css`)
129 +
130 +Raw values on `:root` / `.dark` (class toggled by `ThemeToggle`, stored in `localStorage['ca-theme']`, system default
131 +applied before paint by `THEME_BOOT`). Tailwind reads them through `@theme inline`:
132 +
133 +| Token | Utility | Light / Dark |
134 +|---|---|---|
135 +| paper / surface / surface-2 | `bg-paper` `bg-surface` `bg-surface-2` | #fbfaf7 / #151513 · #fff / #1c1c1a · #f3f1ec / #24241f |
136 +| ink / ink-2 / ink-3 | `text-ink*` | #1a1917 / #f2f0ea · #5c5a55 / #c9c6bd · #8a877f |
137 +| rule / rule-strong | `border-rule` `divide-rule` | #e6e3dc / #2c2b28 · #c9c6bd / #3a3935 |
138 +| accent / accent-soft / accent-ink | `text-accent` `bg-accent-soft` | #1c5cab / #5598e7 · #e8f0fb / #17304f |
139 +| up / down / warn | `text-up` `text-down` | #006300 / #0ca30c · #b02a2a / #e66767 · #9a6a00 / #e0a100 |
140 +| series-1…8 | `bg-series-1`, `seriesVar(i)` | validated dataviz palette (blue, orange, aqua, yellow, magenta, green, violet, red) |
141 +| seq-1…7 | `seqVar(k)` | blue sequential ramp (choropleths); reversed in dark |
142 +| nodata | `no-data-hatch` | hatched grey |
143 +
144 +Palette validated with the dataviz skill's `validate_palette.js` on both surfaces (adjacent ΔE ≥ 8.4, all-pairs OK for
145 +the first 3 slots). Light-mode aqua/yellow/magenta are < 3:1 on paper → charts always ship direct labels + the table
146 +toggle (relief rule). Positive/negative colours are never used alone: `ChangeChip` pairs them with ↑/↓ glyphs.
147 +
148 +Type: `display` (Newsreader) for headings, Inter for UI, scale `text-2xs … text-5xl`; utilities `eyebrow`, `hairline`,
149 +`tap`, `tnum`, `pnum`, `scrollbar-none`, `safe-bottom`, `container-x`, `link-quiet`. Motion respects
150 +`prefers-reduced-motion`.
151 +
152 +## Chart kit (`components/charts/`)
153 +
154 +All charts: SVG, `role="img"` + `<title>/<desc>` with an auto summary (`summary.ts`), a `ChartFrame` with source line
155 +(`SourceLine`, clickable → provenance), accessible data-table toggle, legend only for ≥ 2 series. Colours via CSS vars
156 +(`palette.ts`), fixed slot order — colour follows the entity, never its rank; never more than 8 series (fold/facet).
157 +Marks per dataviz spec (`MARK`): 2 px lines, ≥ 8 px end dots with surface ring, bars ≤ 24 px with 4 px rounded end,
158 +hairline solid grid. Points type: `SeriesPoint {period, year, value, is_forecast}` — build with
159 +`pointsFromSpark(m.sparkline)` (API `[year,value]` pairs) or `pointsFromSeries(series.values)`.
160 +
161 +| Component | Kind | Notes |
162 +|---|---|---|
163 +| `LineChart` / `AreaChart` / `StackedArea` | client | multi-series, dashed `is_forecast`, `log`, crosshair + one tooltip for all series, end labels ≤ 4 series, auto left margin |
164 +| `Sparkline` | server | tiny, no axes, end dot coloured by `direction` |
165 +| `RankedBars` (+`rankedRowFromCountry`) | server, HTML | horizontal bars with flags, `highlightId`, values at the tip |
166 +| `Scatter` | client | bubble `size`, labels for highlighted + extremes only, 24 px hit radius |
167 +| `SlopeChart` | client | two periods, highlighted row in accent |
168 +| `SmallMultiples` | server | grid wrapper (1 → 2 → 3/4 cols) |
169 +| `PopulationPyramid` | server | 0–14 / 15–64 / 65+ stacked bar (ordinal ramp) |
170 +| `DnaRadial` | server | 9 dims 0–100 fingerprint |
171 +| `Choropleth` → `ChoroplethView` | server → client | Equal Earth 960×470, quantile classes from API `legend.breaks`, hatch for no data, hover / tap label / click → country |
172 +
173 +`useMeasure(defaultWidth)` gives responsive widths with a stable SSR width; reserve heights via `height`/`minHeight`.
174 +
175 +## Data components (`components/data/`)
176 +
177 +* `Metric` — label(link) + sparkline, big value (click → provenance), period, `ChangeChip`, `RankBadge`. `MetricGrid`
178 + = 1 col ≤ 360 px, 2 on phones, 3 md, 4 xl.
179 +* `ProvenanceSheet` is mounted once in the layout; open it from anywhere with `useProvenance().open(payload)`.
180 +* `BottomSheet` (native `<dialog>`): `side="drawer"` (right drawer on md+), `"center"`, `"full"`; drag-down to close.
181 +* `DataTable<T>` → definition list under `sm` (no horizontal overflow). `TopicNav` sticky under the header (52/56 px).
182 +* `ChangeList` (feed with kind icon + severity chip) and `Timeline` (events by year) both take `ChangeItem[]`.
183 +* `SiteFooter` renders `<FooterCredits/>` ("Made by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on MacLustr")
184 + — reuse `FooterCredits` on `/sources` and `/api`.
185 +
186 +## Types — `src/lib/types.ts` (extend this file)
187 +
188 +Aligned 2026-09-11 with `src/countryatlas/api/schemas.py`. Responses are flat with `meta`; collections are `items`
189 +(`/countries`, `/changes`, `/events`, `/insights`), `hits` (`/search`), `peers` (`/similar`), `rows` (rankings/home
190 +lists). Value objects: `MetricValue` (headline/topic), `SeriesValue` (series), `RankingRow`, `HomeListRow`; all carry
191 +`provenance`. Sparklines are `SparkPoint = [year, value]`. Pydantic models already defined but **not yet mirrored**
192 +(add them when you build the pages): `CompareResponse`, `CompareSnapshotResponse`, `RankingsListResponse`,
193 +`RankHistoryResponse`, `IndicatorsResponse`, `IndicatorResponse` (+`IndicatorSource`, `WorldLatest`, `RankedValue`),
194 +`TrendResponse`, `RegionsResponse`, `RegionResponse`, `SourcesResponse`, `SourceResponse`, methodology/admin payloads.
195 +Add the matching `api.*` methods in `lib/api.ts` with `request<T>()`, routes in `lib/site.ts`, strings in `i18n/en.ts`,
196 +and sitemap entries in `app/sitemap.ts`.
197 +
198 +## Brand
199 +
200 +`components/brand/Logo.tsx`: a globe ring whose equator is the crossbar of an "A" (two meridian-like legs to one apex).
201 +Strokes only, `currentColor`, monochrome-capable. `src/app/icon.svg` (rounded paper tile) → `icon.png` 512 /
202 +`apple-icon.png` 180 via `qa/favicons.mjs`; `manifest.ts` theme colour #1c5cab. Social images: `app/opengraph-image.tsx`
203 +(+ `twitter-image.tsx`) and `app/countries/[slug]/opengraph-image.tsx` (flag + name + 3 headline values). Satori
204 +gotchas: children of a multi-child `<div>` need `display:flex`; numeric children must be `String()`-ed.
205 +
206 +## Known issues / not in this round
207 +
208 +* `/admin` needs `CA_ADMIN_TOKEN` in the **web** process env (the mld manifest passes it); without it the login page says
209 + the panel is disabled. `ADMIN_API_URL` (optional) points admin fetches at another API instance. QA for the explore
210 + routes: `node qa/shots-explore.mjs` → `qa/screens/explore/` (+ report.json, touch test of the map year slider).
211 +* Sitemap ≈ 4.9k URLs (countries × topics + indicators + regions + sources + rankings) — switch to `generateSitemaps`
212 + before adding another per-country family.
213 +* Search hit types `country_topic` / `country_indicator` render with the Topic/Indicator chip; `/topics/*` URLs the API
214 + may emit for bare topics are mapped to `/indicators?topic=` client-side (`hrefFor`).
215 +* The API `CountrySummary` has no `iso_numeric`; the choropleth uses the static numeric→ISO3 table (`lib/iso-numeric.ts`)
216 + and will prefer `iso_numeric` automatically if the API adds it.
217 +* Topic page renders the first 4 charts with server-fetched history; the rest fetch on scroll (client). With a slow API
218 + the fold could show "Loading…" placeholders of reserved height (no CLS).
219 +* Fixture screenshots contain synthetic values by design; re-run `qa/screens.mjs` against the real API before launch.
added apps/web/qa/admin-explore.mjs +73 −0
@@ -0,0 +1,73 @@
1 +/**
2 + * Admin panel QA: wrong token → error; right token (deploy/.admin-token) → cookie → overview/coverage/runs/issues/raw
3 + * render; "Clear cache" server action round-trips; screenshots at 390 + 1440 → qa/screens/explore/admin_*.png.
4 + * Needs CA_ADMIN_TOKEN on the web process (and ADMIN_API_URL → an API instance with the same token).
5 + * Run: node qa/admin-explore.mjs [BASE_URL]
6 + */
7 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
8 +import { readFileSync } from 'node:fs';
9 +
10 +const BASE = process.argv[2] ?? 'http://localhost:8290';
11 +const OUT = new URL('./screens/explore/', import.meta.url).pathname;
12 +const TOKEN = readFileSync(new URL('../../../deploy/.admin-token', import.meta.url), 'utf8').trim();
13 +
14 +const browser = await chromium.launch();
15 +for (const width of [390, 1440]) {
16 + const mobile = width < 768;
17 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile });
18 + const page = await ctx.newPage();
19 + const errors = [];
20 + page.on('pageerror', (e) => errors.push(String(e)));
21 + page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
22 +
23 + // unauthenticated → redirect to login
24 + let res = await page.goto(BASE + '/admin/coverage', { waitUntil: 'networkidle' });
25 + console.log(width, 'gate:', page.url().endsWith('/admin/login') ? 'redirected to login OK' : `FAIL ${page.url()}`, res?.status());
26 +
27 + // wrong token
28 + await page.fill('input[name=token]', 'nope');
29 + await page.click('button[type=submit]');
30 + await page.waitForURL(/error=1/, { timeout: 60_000 }).catch(() => {});
31 + await page.waitForLoadState('networkidle');
32 + const err = await page.getByText('Invalid token.').count();
33 + const stillNoCookie = !(await ctx.cookies()).some((k) => k.name === 'ca_admin');
34 + console.log(width, 'wrong token:', err && stillNoCookie ? 'error shown, no cookie OK' : `FAIL url=${page.url()} err=${err} noCookie=${stillNoCookie}`);
35 +
36 + // right token
37 + await page.fill('input[name=token]', TOKEN);
38 + await page.click('button[type=submit]');
39 + await page.waitForURL(/\/admin(\?.*)?$/, { timeout: 60_000 });
40 + await page.waitForLoadState('networkidle');
41 + const cookies = await ctx.cookies();
42 + const c = cookies.find((k) => k.name === 'ca_admin');
43 + console.log(width, 'cookie:', c ? `httpOnly=${c.httpOnly} sameSite=${c.sameSite} path=${c.path} value≠token=${c.value !== TOKEN}` : 'MISSING');
44 + for (const p of ['/admin', '/admin/coverage', '/admin/runs', '/admin/issues', '/admin/raw']) {
45 + res = await page.goto(BASE + p, { waitUntil: 'networkidle', timeout: 120_000 });
46 + const h = await page.locator('h2').first().textContent().catch(() => '');
47 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
48 + await page.screenshot({ path: `${OUT}admin${p.replace(/\//g, '_')}-${width}.png`, fullPage: true });
49 + console.log(width, p.padEnd(16), res?.status(), `h2="${h?.trim()}"`, overflow > 0 ? `OVERFLOW +${overflow}` : 'ok');
50 + }
51 + // raw files for the latest run id from the runs page
52 + await page.goto(BASE + '/admin/runs', { waitUntil: 'networkidle' });
53 + const runId = await page.locator('a[href*="run_id="]').first().textContent();
54 + await page.goto(BASE + `/admin/raw?run_id=${runId?.trim()}`, { waitUntil: 'networkidle' });
55 + const files = await page.locator('table').count();
56 + console.log(width, 'raw run', runId?.trim(), files ? `${files} tables OK` : 'no table');
57 + // clear cache action
58 + await page.goto(BASE + '/admin', { waitUntil: 'networkidle' });
59 + await page.click('button:has-text("Clear cache")');
60 + await page.waitForURL(/[?&](ok|err)=/, { timeout: 60_000 });
61 + console.log(width, 'clear cache:', decodeURIComponent(page.url().split('?')[1] ?? ''));
62 + // refresh now (expected 409 → err=: no scheduler pid on this dev machine)
63 + await page.goto(BASE + '/admin', { waitUntil: 'networkidle' });
64 + await page.click('button:has-text("Refresh now")');
65 + await page.waitForURL(/[?&](ok|err)=/, { timeout: 60_000 });
66 + console.log(width, 'refresh now:', decodeURIComponent(page.url().split('?')[1] ?? '').slice(0, 120));
67 + // logout
68 + await page.click('button:has-text("Sign out")');
69 + await page.waitForURL(/\/admin\/login/, { timeout: 30_000 });
70 + console.log(width, 'logout OK', errors.length ? `console errors: ${errors.slice(0, 3).join(' | ')}` : 'no console errors');
71 + await ctx.close();
72 +}
73 +await browser.close();
added apps/web/qa/compare-rankings.mjs +87 −0
@@ -0,0 +1,87 @@
1 +/**
2 + * QA for the Compare + Rankings pages: screenshots at 320/375/390/430/1280/1440 → qa/screens/compare-rankings/,
3 + * plus checks: horizontal overflow, tap targets < 44 px (mobile), console errors, sticky controls height,
4 + * URL state round-trip (reload keeps state), 404s. Run: node qa/compare-rankings.mjs [BASE_URL]
5 + */
6 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
7 +import { mkdirSync, writeFileSync } from 'node:fs';
8 +
9 +const BASE = process.argv[2] ?? 'http://localhost:8290';
10 +const OUT = new URL('./screens/compare-rankings/', import.meta.url).pathname;
11 +mkdirSync(OUT, { recursive: true });
12 +
13 +const PAGES = [
14 + ['compare', '/compare'],
15 + ['cmp-snapshot', '/compare/canada/united-states/france'],
16 + ['cmp-economy', '/compare/canada/united-states/france?tab=economy'],
17 + ['cmp-hero', '/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100'],
18 + ['rankings', '/rankings'],
19 + ['rk-gdppc', '/rankings/gdp-per-capita'],
20 + ['rk-life-oecd', '/rankings/life-expectancy?group=oecd'],
21 +];
22 +const WIDTHS = [320, 375, 390, 430, 1280, 1440];
23 +const report = [];
24 +const browser = await chromium.launch();
25 +
26 +for (const width of WIDTHS) {
27 + const mobile = width < 768;
28 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'light' });
29 + const page = await ctx.newPage();
30 + const errors = [];
31 + page.on('pageerror', (e) => errors.push(String(e)));
32 + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
33 + for (const [name, path] of PAGES) {
34 + await page.goto(BASE + path, { waitUntil: 'networkidle' });
35 + await page.evaluate(() => document.fonts.ready);
36 + await page.waitForTimeout(700);
37 + const m = await page.evaluate(() => {
38 + const de = document.documentElement;
39 + const overflow = de.scrollWidth - de.clientWidth;
40 + const wide = [...document.querySelectorAll('body *')].filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0 && getComputedStyle(el).position !== 'fixed'; }).slice(0, 6).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
41 + 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)}`);
42 + 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)}`);
43 + const sticky = [...document.querySelectorAll('.sticky')].filter((el) => el.getBoundingClientRect().height > 0).map((el) => Math.round(el.getBoundingClientRect().height));
44 + return { overflow, wide, small: small.slice(0, 12), smallH: smallH.slice(0, 12), sticky, docH: de.scrollHeight };
45 + });
46 + await page.screenshot({ path: `${OUT}${name}-${width}.png`, fullPage: true });
47 + report.push({ name, path, width, ...m, errors: errors.splice(0) });
48 + }
49 + await ctx.close();
50 +}
51 +
52 +// URL state round-trip + 404s (desktop)
53 +{
54 + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
55 + const page = await ctx.newPage();
56 + const checks = [];
57 + await page.goto(`${BASE}/compare/canada/united-states/france?tab=economy`, { waitUntil: 'networkidle' });
58 + await page.getByRole('radio', { name: 'Index = 100' }).click();
59 + await page.waitForTimeout(1200);
60 + const url1 = page.url();
61 + await page.reload({ waitUntil: 'networkidle' });
62 + const modeChecked = await page.getByRole('radio', { name: 'Index = 100' }).getAttribute('aria-checked');
63 + checks.push({ check: 'compare mode round-trip', url: url1, ok: url1.includes('mode=index100') && modeChecked === 'true' });
64 + await page.getByRole('tab', { name: 'Population' }).click();
65 + await page.waitForTimeout(1200);
66 + checks.push({ check: 'tab switch updates URL', url: page.url(), ok: page.url().includes('tab=population') });
67 + await page.goto(`${BASE}/rankings/gdp-per-capita`, { waitUntil: 'networkidle' });
68 + await page.locator('select[aria-label="Year"]:visible').first().selectOption('2015');
69 + await page.waitForTimeout(1500);
70 + const url2 = page.url();
71 + await page.reload({ waitUntil: 'networkidle' });
72 + const yearVal = await page.locator('select[aria-label="Year"]:visible').first().inputValue();
73 + checks.push({ check: 'ranking year round-trip', url: url2, ok: url2.includes('year=2015') && yearVal === '2015' });
74 + for (const p of ['/compare/canada', '/rankings/not-a-thing', '/compare/canada/nowhere-land']) {
75 + const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' });
76 + checks.push({ check: `404 ${p}`, status: res?.status(), ok: res?.status() === 404 });
77 + }
78 + report.push({ checks });
79 + await ctx.close();
80 +}
81 +await browser.close();
82 +writeFileSync(`${OUT}report.json`, JSON.stringify(report, null, 2));
83 +for (const r of report) {
84 + if (r.checks) { for (const c of r.checks) console.log(c.ok ? 'OK ' : 'FAIL', c.check, c.url ?? c.status ?? ''); continue; }
85 + const flags = [r.overflow > 0 ? `OVERFLOW ${r.overflow}px ${r.wide.join(' | ')}` : '', r.width < 768 && r.small.length ? `small: ${r.small.join(' | ')}` : '', r.errors.length ? `errors: ${r.errors.join(' | ').slice(0, 300)}` : ''].filter(Boolean);
86 + console.log(`${r.name}@${r.width} h=${r.docH} sticky=[${r.sticky.join(',')}] ${flags.join(' ; ')}`);
87 +}
added apps/web/qa/cr-interact.mjs +21 −0
@@ -0,0 +1,21 @@
1 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
2 +const BASE = 'http://localhost:8290';
3 +const OUT = new URL('./screens/compare-rankings/seg/', import.meta.url).pathname;
4 +const b = await chromium.launch();
5 +const errs = [];
6 +async function ctx(w, dark=false){ const c = await b.newContext({ viewport: { width: w, height: w<768?844:900 }, isMobile: w<768, hasTouch: w<768, colorScheme: dark?'dark':'light' }); const p = await c.newPage(); p.on('pageerror',e=>errs.push(String(e))); p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); return [c,p]; }
7 +// mobile: options sheet on compare
8 +{ const [c,p] = await ctx(390); await p.goto(`${BASE}/compare/canada/united-states/france?tab=economy`, {waitUntil:'networkidle'}); await p.getByRole('button',{name:'Options'}).click(); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-options-390.png`}); await c.close(); }
9 +// mobile: add-country picker
10 +{ const [c,p] = await ctx(390); await p.goto(`${BASE}/compare/canada/united-states/france`, {waitUntil:'networkidle'}); await p.getByRole('button',{name:'Add a country'}).first().click(); await p.waitForTimeout(500); await p.locator('dialog input[type=search]').fill('ger'); await p.waitForTimeout(400); await p.screenshot({path:`${OUT}i-picker-390.png`}); await p.locator('dialog button', {hasText:'Germany'}).click(); await p.waitForURL(/germany/); await p.waitForTimeout(1200); await p.screenshot({path:`${OUT}i-added-390.png`}); console.log('after add', p.url()); await c.close(); }
11 +// desktop: custom tab + indicator picker
12 +{ const [c,p] = await ctx(1280); await p.goto(`${BASE}/compare/canada/united-states/france?tab=custom&indicators=gdp-per-capita,life-expectancy`, {waitUntil:'networkidle'}); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-custom-1280.png`}); await p.getByRole('button',{name:'Add an indicator'}).click(); await p.waitForTimeout(800); await p.locator('dialog input[type=search]').fill('unemploy'); await p.waitForTimeout(400); await p.screenshot({path:`${OUT}i-indpicker-1280.png`}); await p.locator('dialog li button').first().click(); await p.waitForTimeout(1500); console.log('custom url', p.url()); await c.close(); }
13 +// desktop: snapshot cell provenance + indicator link
14 +{ const [c,p] = await ctx(1280); await p.goto(`${BASE}/compare/canada/united-states/france`, {waitUntil:'networkidle'}); await p.getByRole('button',{name:'Show source and provenance'}).first().click(); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-prov-1280.png`}); await p.keyboard.press('Escape'); await p.getByRole('link',{name:'Chart Life expectancy'}).click(); await p.waitForURL(/tab=health/); await p.waitForTimeout(1500); console.log('chart link', p.url()); await p.screenshot({path:`${OUT}i-health-hero-1280.png`}); await c.close(); }
15 +// desktop: ranking highlight + group filter + dark mode
16 +{ const [c,p] = await ctx(1280, true); await p.goto(`${BASE}/rankings/gdp-per-capita?highlight=canada`, {waitUntil:'networkidle'}); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-rk-highlight-dark-1280.png`}); await p.locator('select[aria-label="Group"]:visible').selectOption('g20'); await p.waitForTimeout(1500); console.log('group url', p.url()); await p.screenshot({path:`${OUT}i-rk-g20-dark-1280.png`}); await c.close(); }
17 +// mobile: ranking filters sheet + history panel
18 +{ const [c,p] = await ctx(390); await p.goto(`${BASE}/rankings/life-expectancy?group=oecd&highlight=canada`, {waitUntil:'networkidle'}); await p.getByRole('button',{name:'Filters'}).click(); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-rk-filters-390.png`}); await p.keyboard.press('Escape'); await p.evaluate(()=>document.getElementById('history')?.scrollIntoView()); await p.waitForTimeout(600); await p.screenshot({path:`${OUT}i-rk-history-390.png`}); await c.close(); }
19 +// mobile dark compare economy
20 +{ const [c,p] = await ctx(390, true); await p.goto(`${BASE}/compare/canada/united-states/france?tab=economy`, {waitUntil:'networkidle'}); await p.evaluate(()=>window.scrollTo(0,700)); await p.waitForTimeout(700); await p.screenshot({path:`${OUT}i-eco-dark-390.png`}); await c.close(); }
21 +await b.close(); console.log('errors', errs.length, errs.slice(0,5));
added apps/web/qa/cr-segments.mjs +33 −0
@@ -0,0 +1,33 @@
1 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
2 +const BASE = process.argv[2] ?? 'http://localhost:8290';
3 +const OUT = new URL('./screens/compare-rankings/seg/', import.meta.url).pathname;
4 +import { mkdirSync } from 'node:fs'; mkdirSync(OUT, { recursive: true });
5 +const SHOTS = [
6 + ['cmp-snapshot', '/compare/canada/united-states/france', 390, [0, 700]],
7 + ['cmp-economy', '/compare/canada/united-states/france?tab=economy', 390, [0, 900, 1800]],
8 + ['cmp-hero', '/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100', 390, [0, 700]],
9 + ['cmp-economy', '/compare/canada/united-states/france?tab=economy', 1280, [0, 900, 1800]],
10 + ['cmp-hero', '/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100', 1280, [0]],
11 + ['rankings', '/rankings', 390, [0, 800]],
12 + ['rankings', '/rankings', 1280, [0, 900]],
13 + ['rk-gdppc', '/rankings/gdp-per-capita', 390, [0, 800, 2600, 3600]],
14 + ['rk-gdppc', '/rankings/gdp-per-capita', 1280, [900, 1800]],
15 + ['rk-life-oecd', '/rankings/life-expectancy?group=oecd', 390, [0, 1200]],
16 + ['rk-life-oecd', '/rankings/life-expectancy?group=oecd', 1280, [0, 1600]],
17 +];
18 +const b = await chromium.launch();
19 +for (const [name, path, w, offsets] of SHOTS) {
20 + const mobile = w < 768;
21 + const ctx = await b.newContext({ viewport: { width: w, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: 1 });
22 + const pg = await ctx.newPage();
23 + await pg.goto(BASE + path, { waitUntil: 'networkidle' });
24 + await pg.waitForTimeout(600);
25 + for (const y of offsets) {
26 + await pg.evaluate((yy) => window.scrollTo(0, yy), y);
27 + await pg.waitForTimeout(700);
28 + await pg.screenshot({ path: `${OUT}${name}-${w}-y${y}.png` });
29 + }
30 + await ctx.close();
31 +}
32 +await b.close();
33 +console.log('done');
added apps/web/qa/favicons.mjs +14 −0
@@ -0,0 +1,14 @@
1 +// Rasterise src/app/icon.svg → icon.png (512) + apple-icon.png (180). Run after editing the SVG.
2 +import { chromium } from 'playwright';
3 +import { readFileSync } from 'node:fs';
4 +const svg = readFileSync(new URL('../src/app/icon.svg', import.meta.url), 'utf8');
5 +const browser = await chromium.launch();
6 +for (const [size, file] of [[512, '../src/app/icon.png'], [180, '../src/app/apple-icon.png']]) {
7 + const ctx = await browser.newContext({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
8 + const page = await ctx.newPage();
9 + await page.setContent(`<html><body style="margin:0;background:transparent">${svg.replace(/width="\d+" height="\d+"/, `width="${size}" height="${size}"`)}</body></html>`);
10 + await page.screenshot({ path: new URL(file, import.meta.url).pathname, omitBackground: true, clip: { x: 0, y: 0, width: size, height: size } });
11 + await ctx.close();
12 +}
13 +await browser.close();
14 +console.log('favicons written');
added apps/web/qa/screens/compare-rankings/cmp-economy-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-economy-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-economy-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-economy-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-economy-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-economy-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-hero-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/cmp-snapshot-430.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

added apps/web/qa/screens/compare-rankings/report.json +1222 −0
@@ -0,0 +1,1222 @@
1 +[
2 + {
3 + "name": "compare",
4 + "path": "/compare",
5 + "width": 320,
6 + "overflow": 0,
7 + "wide": [],
8 + "small": [
9 + "a \"Skip to content\" 1x1"
10 + ],
11 + "smallH": [
12 + "a \"Skip to content\" h=1",
13 + "a \"contact@spboucher.ai\" h=15",
14 + "a \"MacLustr\" h=15"
15 + ],
16 + "sticky": [
17 + 53,
18 + 61
19 + ],
20 + "docH": 2569,
21 + "errors": []
22 + },
23 + {
24 + "name": "cmp-snapshot",
25 + "path": "/compare/canada/united-states/france",
26 + "width": 320,
27 + "overflow": 0,
28 + "wide": [
29 + "li.group/chip.shrink-0 right=329",
30 + "span.inline-flex.h-11.items-center right=329",
31 + "button.grid.h-11.w-9 right=328",
32 + "li.group/chip.shrink-0 right=465",
33 + "span.inline-flex.h-11.items-center right=465",
34 + "span.h-2.w-2.shrink-0 right=352"
35 + ],
36 + "small": [
37 + "a \"Skip to content\" 1x1"
38 + ],
39 + "smallH": [
40 + "a \"Skip to content\" h=1",
41 + "a \"contact@spboucher.ai\" h=15",
42 + "a \"MacLustr\" h=15"
43 + ],
44 + "sticky": [
45 + 53,
46 + 105
47 + ],
48 + "docH": 4083,
49 + "errors": []
50 + },
51 + {
52 + "name": "cmp-economy",
53 + "path": "/compare/canada/united-states/france?tab=economy",
54 + "width": 320,
55 + "overflow": 0,
56 + "wide": [
57 + "li.group/chip.shrink-0 right=329",
58 + "span.inline-flex.h-11.items-center right=329",
59 + "button.grid.h-11.w-9 right=328",
60 + "li.group/chip.shrink-0 right=465",
61 + "span.inline-flex.h-11.items-center right=465",
62 + "span.h-2.w-2.shrink-0 right=352"
63 + ],
64 + "small": [
65 + "a \"Skip to content\" 1x1"
66 + ],
67 + "smallH": [
68 + "a \"Skip to content\" h=1",
69 + "a \"GDP (current US$)\" h=17",
70 + "button \"Source for Canada, United Stat\" h=28",
71 + "button \"Source for Canada, United Stat\" h=28",
72 + "a \"GDP per capita (current US$)\" h=17",
73 + "button \"Source for Canada, United Stat\" h=28",
74 + "button \"Source for Canada, United Stat\" h=28",
75 + "a \"Real GDP growth\" h=17",
76 + "button \"Source for Canada, United Stat\" h=28",
77 + "a \"GDP per capita growth\" h=17",
78 + "button \"Source for Canada, United Stat\" h=28",
79 + "a \"GDP (constant 2015 US$)\" h=17"
80 + ],
81 + "sticky": [
82 + 53,
83 + 105
84 + ],
85 + "docH": 18787,
86 + "errors": []
87 + },
88 + {
89 + "name": "cmp-hero",
90 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
91 + "width": 320,
92 + "overflow": 0,
93 + "wide": [
94 + "li.group/chip.shrink-0 right=329",
95 + "span.inline-flex.h-11.items-center right=329",
96 + "button.grid.h-11.w-9 right=328",
97 + "li.group/chip.shrink-0 right=465",
98 + "span.inline-flex.h-11.items-center right=465",
99 + "span.h-2.w-2.shrink-0 right=352"
100 + ],
101 + "small": [
102 + "a \"Skip to content\" 1x1"
103 + ],
104 + "smallH": [
105 + "a \"Skip to content\" h=1",
106 + "button \"Source for Canada, United Stat\" h=28",
107 + "a \"GDP (current US$)\" h=17",
108 + "button \"Source for Canada, United Stat\" h=28",
109 + "button \"Source for Canada, United Stat\" h=28",
110 + "button \"Source for Canada, United Stat\" h=28",
111 + "a \"Real GDP growth\" h=17",
112 + "button \"Source for Canada, United Stat\" h=28",
113 + "a \"GDP per capita growth\" h=17",
114 + "button \"Source for Canada, United Stat\" h=28",
115 + "a \"GDP (constant 2015 US$)\" h=17",
116 + "button \"Source for Canada, United Stat\" h=28"
117 + ],
118 + "sticky": [
119 + 53,
120 + 105
121 + ],
122 + "docH": 19052,
123 + "errors": []
124 + },
125 + {
126 + "name": "rankings",
127 + "path": "/rankings",
128 + "width": 320,
129 + "overflow": 0,
130 + "wide": [],
131 + "small": [
132 + "a \"Skip to content\" 1x1"
133 + ],
134 + "smallH": [
135 + "a \"Skip to content\" h=1",
136 + "a \"contact@spboucher.ai\" h=15",
137 + "a \"MacLustr\" h=15"
138 + ],
139 + "sticky": [
140 + 53,
141 + 61
142 + ],
143 + "docH": 26234,
144 + "errors": []
145 + },
146 + {
147 + "name": "rk-gdppc",
148 + "path": "/rankings/gdp-per-capita",
149 + "width": 320,
150 + "overflow": 0,
151 + "wide": [],
152 + "small": [
153 + "a \"Skip to content\" 1x1"
154 + ],
155 + "smallH": [
156 + "a \"Skip to content\" h=1",
157 + "select \"Year\" h=19",
158 + "a \"contact@spboucher.ai\" h=15",
159 + "a \"MacLustr\" h=15"
160 + ],
161 + "sticky": [
162 + 53,
163 + 61
164 + ],
165 + "docH": 4759,
166 + "errors": []
167 + },
168 + {
169 + "name": "rk-life-oecd",
170 + "path": "/rankings/life-expectancy?group=oecd",
171 + "width": 320,
172 + "overflow": 0,
173 + "wide": [],
174 + "small": [
175 + "a \"Skip to content\" 1x1"
176 + ],
177 + "smallH": [
178 + "a \"Skip to content\" h=1",
179 + "select \"Year\" h=19",
180 + "a \"contact@spboucher.ai\" h=15",
181 + "a \"MacLustr\" h=15"
182 + ],
183 + "sticky": [
184 + 53,
185 + 61
186 + ],
187 + "docH": 4689,
188 + "errors": []
189 + },
190 + {
191 + "name": "compare",
192 + "path": "/compare",
193 + "width": 375,
194 + "overflow": 0,
195 + "wide": [],
196 + "small": [
197 + "a \"Skip to content\" 1x1"
198 + ],
199 + "smallH": [
200 + "a \"Skip to content\" h=1",
201 + "a \"contact@spboucher.ai\" h=15",
202 + "a \"MacLustr\" h=15"
203 + ],
204 + "sticky": [
205 + 53,
206 + 61
207 + ],
208 + "docH": 2508,
209 + "errors": []
210 + },
211 + {
212 + "name": "cmp-snapshot",
213 + "path": "/compare/canada/united-states/france",
214 + "width": 375,
215 + "overflow": 0,
216 + "wide": [
217 + "li.group/chip.shrink-0 right=465",
218 + "span.inline-flex.h-11.items-center right=465",
219 + "span.max-w-[8rem].truncate.text-ink right=424",
220 + "button.grid.h-11.w-9 right=464",
221 + "svg.[object.SVGAnimatedString] right=453",
222 + "path.[object.SVGAnimatedString] right=449"
223 + ],
224 + "small": [
225 + "a \"Skip to content\" 1x1"
226 + ],
227 + "smallH": [
228 + "a \"Skip to content\" h=1",
229 + "a \"contact@spboucher.ai\" h=15",
230 + "a \"MacLustr\" h=15"
231 + ],
232 + "sticky": [
233 + 53,
234 + 105
235 + ],
236 + "docH": 4049,
237 + "errors": []
238 + },
239 + {
240 + "name": "cmp-economy",
241 + "path": "/compare/canada/united-states/france?tab=economy",
242 + "width": 375,
243 + "overflow": 0,
244 + "wide": [
245 + "li.group/chip.shrink-0 right=465",
246 + "span.inline-flex.h-11.items-center right=465",
247 + "span.max-w-[8rem].truncate.text-ink right=424",
248 + "button.grid.h-11.w-9 right=464",
249 + "svg.[object.SVGAnimatedString] right=453",
250 + "path.[object.SVGAnimatedString] right=449"
251 + ],
252 + "small": [
253 + "a \"Skip to content\" 1x1"
254 + ],
255 + "smallH": [
256 + "a \"Skip to content\" h=1",
257 + "a \"GDP (current US$)\" h=17",
258 + "button \"Source for Canada, United Stat\" h=28",
259 + "a \"GDP, PPP (current internationa\" h=17",
260 + "button \"Source for Canada, United Stat\" h=28",
261 + "a \"GDP per capita (current US$)\" h=17",
262 + "button \"Source for Canada, United Stat\" h=28",
263 + "button \"Source for Canada, United Stat\" h=28",
264 + "a \"Real GDP growth\" h=17",
265 + "button \"Source for Canada, United Stat\" h=28",
266 + "a \"GDP per capita growth\" h=17",
267 + "button \"Source for Canada, United Stat\" h=28"
268 + ],
269 + "sticky": [
270 + 53,
271 + 105
272 + ],
273 + "docH": 18042,
274 + "errors": []
275 + },
276 + {
277 + "name": "cmp-hero",
278 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
279 + "width": 375,
280 + "overflow": 0,
281 + "wide": [
282 + "li.group/chip.shrink-0 right=465",
283 + "span.inline-flex.h-11.items-center right=465",
284 + "span.max-w-[8rem].truncate.text-ink right=424",
285 + "button.grid.h-11.w-9 right=464",
286 + "svg.[object.SVGAnimatedString] right=453",
287 + "path.[object.SVGAnimatedString] right=449"
288 + ],
289 + "small": [
290 + "a \"Skip to content\" 1x1"
291 + ],
292 + "smallH": [
293 + "a \"Skip to content\" h=1",
294 + "button \"Source for Canada, United Stat\" h=28",
295 + "a \"GDP (current US$)\" h=17",
296 + "button \"Source for Canada, United Stat\" h=28",
297 + "a \"GDP, PPP (current internationa\" h=17",
298 + "button \"Source for Canada, United Stat\" h=28",
299 + "button \"Source for Canada, United Stat\" h=28",
300 + "a \"Real GDP growth\" h=17",
301 + "button \"Source for Canada, United Stat\" h=28",
302 + "a \"GDP per capita growth\" h=17",
303 + "button \"Source for Canada, United Stat\" h=28",
304 + "a \"GDP (constant 2015 US$)\" h=17"
305 + ],
306 + "sticky": [
307 + 53,
308 + 105
309 + ],
310 + "docH": 18307,
311 + "errors": []
312 + },
313 + {
314 + "name": "rankings",
315 + "path": "/rankings",
316 + "width": 375,
317 + "overflow": 0,
318 + "wide": [],
319 + "small": [
320 + "a \"Skip to content\" 1x1"
321 + ],
322 + "smallH": [
323 + "a \"Skip to content\" h=1",
324 + "a \"contact@spboucher.ai\" h=15",
325 + "a \"MacLustr\" h=15"
326 + ],
327 + "sticky": [
328 + 53,
329 + 61
330 + ],
331 + "docH": 24751,
332 + "errors": []
333 + },
334 + {
335 + "name": "rk-gdppc",
336 + "path": "/rankings/gdp-per-capita",
337 + "width": 375,
338 + "overflow": 0,
339 + "wide": [],
340 + "small": [
341 + "a \"Skip to content\" 1x1"
342 + ],
343 + "smallH": [
344 + "a \"Skip to content\" h=1",
345 + "select \"Year\" h=19",
346 + "a \"contact@spboucher.ai\" h=15",
347 + "a \"MacLustr\" h=15"
348 + ],
349 + "sticky": [
350 + 53,
351 + 61
352 + ],
353 + "docH": 4695,
354 + "errors": []
355 + },
356 + {
357 + "name": "rk-life-oecd",
358 + "path": "/rankings/life-expectancy?group=oecd",
359 + "width": 375,
360 + "overflow": 0,
361 + "wide": [],
362 + "small": [
363 + "a \"Skip to content\" 1x1"
364 + ],
365 + "smallH": [
366 + "a \"Skip to content\" h=1",
367 + "select \"Year\" h=19",
368 + "a \"contact@spboucher.ai\" h=15",
369 + "a \"MacLustr\" h=15"
370 + ],
371 + "sticky": [
372 + 53,
373 + 61
374 + ],
375 + "docH": 4614,
376 + "errors": []
377 + },
378 + {
379 + "name": "compare",
380 + "path": "/compare",
381 + "width": 390,
382 + "overflow": 0,
383 + "wide": [],
384 + "small": [
385 + "a \"Skip to content\" 1x1"
386 + ],
387 + "smallH": [
388 + "a \"Skip to content\" h=1",
389 + "a \"contact@spboucher.ai\" h=15",
390 + "a \"MacLustr\" h=15"
391 + ],
392 + "sticky": [
393 + 53,
394 + 61
395 + ],
396 + "docH": 2491,
397 + "errors": []
398 + },
399 + {
400 + "name": "cmp-snapshot",
401 + "path": "/compare/canada/united-states/france",
402 + "width": 390,
403 + "overflow": 0,
404 + "wide": [
405 + "li.group/chip.shrink-0 right=465",
406 + "span.inline-flex.h-11.items-center right=465",
407 + "span.max-w-[8rem].truncate.text-ink right=424",
408 + "button.grid.h-11.w-9 right=464",
409 + "svg.[object.SVGAnimatedString] right=453",
410 + "path.[object.SVGAnimatedString] right=449"
411 + ],
412 + "small": [
413 + "a \"Skip to content\" 1x1"
414 + ],
415 + "smallH": [
416 + "a \"Skip to content\" h=1",
417 + "a \"contact@spboucher.ai\" h=15",
418 + "a \"MacLustr\" h=15"
419 + ],
420 + "sticky": [
421 + 53,
422 + 105
423 + ],
424 + "docH": 4031,
425 + "errors": []
426 + },
427 + {
428 + "name": "cmp-economy",
429 + "path": "/compare/canada/united-states/france?tab=economy",
430 + "width": 390,
431 + "overflow": 0,
432 + "wide": [
433 + "li.group/chip.shrink-0 right=465",
434 + "span.inline-flex.h-11.items-center right=465",
435 + "span.max-w-[8rem].truncate.text-ink right=424",
436 + "button.grid.h-11.w-9 right=464",
437 + "svg.[object.SVGAnimatedString] right=453",
438 + "path.[object.SVGAnimatedString] right=449"
439 + ],
440 + "small": [
441 + "a \"Skip to content\" 1x1"
442 + ],
443 + "smallH": [
444 + "a \"Skip to content\" h=1",
445 + "a \"GDP (current US$)\" h=17",
446 + "button \"Source for Canada, United Stat\" h=28",
447 + "a \"GDP, PPP (current internationa\" h=17",
448 + "button \"Source for Canada, United Stat\" h=28",
449 + "a \"GDP per capita (current US$)\" h=17",
450 + "button \"Source for Canada, United Stat\" h=28",
451 + "button \"Source for Canada, United Stat\" h=28",
452 + "a \"Real GDP growth\" h=17",
453 + "button \"Source for Canada, United Stat\" h=28",
454 + "a \"GDP per capita growth\" h=17",
455 + "button \"Source for Canada, United Stat\" h=28"
456 + ],
457 + "sticky": [
458 + 53,
459 + 105
460 + ],
461 + "docH": 17834,
462 + "errors": []
463 + },
464 + {
465 + "name": "cmp-hero",
466 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
467 + "width": 390,
468 + "overflow": 0,
469 + "wide": [
470 + "li.group/chip.shrink-0 right=465",
471 + "span.inline-flex.h-11.items-center right=465",
472 + "span.max-w-[8rem].truncate.text-ink right=424",
473 + "button.grid.h-11.w-9 right=464",
474 + "svg.[object.SVGAnimatedString] right=453",
475 + "path.[object.SVGAnimatedString] right=449"
476 + ],
477 + "small": [
478 + "a \"Skip to content\" 1x1"
479 + ],
480 + "smallH": [
481 + "a \"Skip to content\" h=1",
482 + "button \"Source for Canada, United Stat\" h=28",
483 + "a \"GDP (current US$)\" h=17",
484 + "button \"Source for Canada, United Stat\" h=28",
485 + "a \"GDP, PPP (current internationa\" h=17",
486 + "button \"Source for Canada, United Stat\" h=28",
487 + "button \"Source for Canada, United Stat\" h=28",
488 + "a \"Real GDP growth\" h=17",
489 + "button \"Source for Canada, United Stat\" h=28",
490 + "a \"GDP per capita growth\" h=17",
491 + "button \"Source for Canada, United Stat\" h=28",
492 + "a \"GDP (constant 2015 US$)\" h=17"
493 + ],
494 + "sticky": [
495 + 53,
496 + 105
497 + ],
498 + "docH": 18171,
499 + "errors": []
500 + },
501 + {
502 + "name": "rankings",
503 + "path": "/rankings",
504 + "width": 390,
505 + "overflow": 0,
506 + "wide": [],
507 + "small": [
508 + "a \"Skip to content\" 1x1"
509 + ],
510 + "smallH": [
511 + "a \"Skip to content\" h=1",
512 + "a \"contact@spboucher.ai\" h=15",
513 + "a \"MacLustr\" h=15"
514 + ],
515 + "sticky": [
516 + 53,
517 + 61
518 + ],
519 + "docH": 24327,
520 + "errors": []
521 + },
522 + {
523 + "name": "rk-gdppc",
524 + "path": "/rankings/gdp-per-capita",
525 + "width": 390,
526 + "overflow": 0,
527 + "wide": [],
528 + "small": [
529 + "a \"Skip to content\" 1x1"
530 + ],
531 + "smallH": [
532 + "a \"Skip to content\" h=1",
533 + "select \"Year\" h=19",
534 + "a \"contact@spboucher.ai\" h=15",
535 + "a \"MacLustr\" h=15"
536 + ],
537 + "sticky": [
538 + 53,
539 + 61
540 + ],
541 + "docH": 4623,
542 + "errors": []
543 + },
544 + {
545 + "name": "rk-life-oecd",
546 + "path": "/rankings/life-expectancy?group=oecd",
547 + "width": 390,
548 + "overflow": 0,
549 + "wide": [],
550 + "small": [
551 + "a \"Skip to content\" 1x1"
552 + ],
553 + "smallH": [
554 + "a \"Skip to content\" h=1",
555 + "select \"Year\" h=19",
556 + "a \"contact@spboucher.ai\" h=15",
557 + "a \"MacLustr\" h=15"
558 + ],
559 + "sticky": [
560 + 53,
561 + 61
562 + ],
563 + "docH": 4603,
564 + "errors": []
565 + },
566 + {
567 + "name": "compare",
568 + "path": "/compare",
569 + "width": 430,
570 + "overflow": 0,
571 + "wide": [],
572 + "small": [
573 + "a \"Skip to content\" 1x1"
574 + ],
575 + "smallH": [
576 + "a \"Skip to content\" h=1",
577 + "a \"contact@spboucher.ai\" h=15",
578 + "a \"MacLustr\" h=15"
579 + ],
580 + "sticky": [
581 + 53,
582 + 61
583 + ],
584 + "docH": 2454,
585 + "errors": []
586 + },
587 + {
588 + "name": "cmp-snapshot",
589 + "path": "/compare/canada/united-states/france",
590 + "width": 430,
591 + "overflow": 0,
592 + "wide": [
593 + "li.group/chip.shrink-0 right=465",
594 + "span.inline-flex.h-11.items-center right=465",
595 + "button.grid.h-11.w-9 right=464",
596 + "svg.[object.SVGAnimatedString] right=453",
597 + "path.[object.SVGAnimatedString] right=449",
598 + "path.[object.SVGAnimatedString] right=449"
599 + ],
600 + "small": [
601 + "a \"Skip to content\" 1x1"
602 + ],
603 + "smallH": [
604 + "a \"Skip to content\" h=1",
605 + "a \"contact@spboucher.ai\" h=15",
606 + "a \"MacLustr\" h=15"
607 + ],
608 + "sticky": [
609 + 53,
610 + 105
611 + ],
612 + "docH": 3998,
613 + "errors": []
614 + },
615 + {
616 + "name": "cmp-economy",
617 + "path": "/compare/canada/united-states/france?tab=economy",
618 + "width": 430,
619 + "overflow": 0,
620 + "wide": [
621 + "li.group/chip.shrink-0 right=465",
622 + "span.inline-flex.h-11.items-center right=465",
623 + "button.grid.h-11.w-9 right=464",
624 + "svg.[object.SVGAnimatedString] right=453",
625 + "path.[object.SVGAnimatedString] right=449",
626 + "path.[object.SVGAnimatedString] right=449"
627 + ],
628 + "small": [
629 + "a \"Skip to content\" 1x1"
630 + ],
631 + "smallH": [
632 + "a \"Skip to content\" h=1",
633 + "a \"GDP (current US$)\" h=17",
634 + "button \"Source for Canada, United Stat\" h=28",
635 + "a \"GDP, PPP (current internationa\" h=17",
636 + "button \"Source for Canada, United Stat\" h=28",
637 + "a \"GDP per capita (current US$)\" h=17",
638 + "button \"Source for Canada, United Stat\" h=28",
639 + "a \"GDP per capita, PPP (current i\" h=17",
640 + "button \"Source for Canada, United Stat\" h=28",
641 + "a \"Real GDP growth\" h=17",
642 + "button \"Source for Canada, United Stat\" h=28",
643 + "a \"GDP per capita growth\" h=17"
644 + ],
645 + "sticky": [
646 + 53,
647 + 105
648 + ],
649 + "docH": 16968,
650 + "errors": []
651 + },
652 + {
653 + "name": "cmp-hero",
654 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
655 + "width": 430,
656 + "overflow": 0,
657 + "wide": [
658 + "li.group/chip.shrink-0 right=465",
659 + "span.inline-flex.h-11.items-center right=465",
660 + "button.grid.h-11.w-9 right=464",
661 + "svg.[object.SVGAnimatedString] right=453",
662 + "path.[object.SVGAnimatedString] right=449",
663 + "path.[object.SVGAnimatedString] right=449"
664 + ],
665 + "small": [
666 + "a \"Skip to content\" 1x1"
667 + ],
668 + "smallH": [
669 + "a \"Skip to content\" h=1",
670 + "button \"Source for Canada, United Stat\" h=28",
671 + "a \"GDP (current US$)\" h=17",
672 + "button \"Source for Canada, United Stat\" h=28",
673 + "a \"GDP, PPP (current internationa\" h=17",
674 + "button \"Source for Canada, United Stat\" h=28",
675 + "a \"GDP per capita, PPP (current i\" h=17",
676 + "button \"Source for Canada, United Stat\" h=28",
677 + "a \"Real GDP growth\" h=17",
678 + "button \"Source for Canada, United Stat\" h=28",
679 + "a \"GDP per capita growth\" h=17",
680 + "button \"Source for Canada, United Stat\" h=28"
681 + ],
682 + "sticky": [
683 + 53,
684 + 105
685 + ],
686 + "docH": 17269,
687 + "errors": []
688 + },
689 + {
690 + "name": "rankings",
691 + "path": "/rankings",
692 + "width": 430,
693 + "overflow": 0,
694 + "wide": [],
695 + "small": [
696 + "a \"Skip to content\" 1x1"
697 + ],
698 + "smallH": [
699 + "a \"Skip to content\" h=1",
700 + "a \"contact@spboucher.ai\" h=15",
701 + "a \"MacLustr\" h=15"
702 + ],
703 + "sticky": [
704 + 53,
705 + 61
706 + ],
707 + "docH": 23937,
708 + "errors": []
709 + },
710 + {
711 + "name": "rk-gdppc",
712 + "path": "/rankings/gdp-per-capita",
713 + "width": 430,
714 + "overflow": 0,
715 + "wide": [],
716 + "small": [
717 + "a \"Skip to content\" 1x1"
718 + ],
719 + "smallH": [
720 + "a \"Skip to content\" h=1",
721 + "select \"Year\" h=19",
722 + "a \"contact@spboucher.ai\" h=15",
723 + "a \"MacLustr\" h=15"
724 + ],
725 + "sticky": [
726 + 53,
727 + 61
728 + ],
729 + "docH": 4583,
730 + "errors": []
731 + },
732 + {
733 + "name": "rk-life-oecd",
734 + "path": "/rankings/life-expectancy?group=oecd",
735 + "width": 430,
736 + "overflow": 0,
737 + "wide": [],
738 + "small": [
739 + "a \"Skip to content\" 1x1"
740 + ],
741 + "smallH": [
742 + "a \"Skip to content\" h=1",
743 + "select \"Year\" h=19",
744 + "a \"contact@spboucher.ai\" h=15",
745 + "a \"MacLustr\" h=15"
746 + ],
747 + "sticky": [
748 + 53,
749 + 61
750 + ],
751 + "docH": 4562,
752 + "errors": []
753 + },
754 + {
755 + "name": "compare",
756 + "path": "/compare",
757 + "width": 1280,
758 + "overflow": 0,
759 + "wide": [],
760 + "small": [
761 + "a \"Skip to content\" 1x1",
762 + "a \"API\" 22x32"
763 + ],
764 + "smallH": [
765 + "a \"Skip to content\" h=1",
766 + "a \"contact@spboucher.ai\" h=15",
767 + "a \"MacLustr\" h=15"
768 + ],
769 + "sticky": [
770 + 57,
771 + 40
772 + ],
773 + "docH": 1359,
774 + "errors": []
775 + },
776 + {
777 + "name": "cmp-snapshot",
778 + "path": "/compare/canada/united-states/france",
779 + "width": 1280,
780 + "overflow": 0,
781 + "wide": [],
782 + "small": [
783 + "a \"Skip to content\" 1x1",
784 + "button \"Move Canada up\" 24x36",
785 + "button \"Move Canada down\" 24x36",
786 + "button \"Remove Canada from the compari\" 28x36",
787 + "button \"Move United States up\" 24x36",
788 + "button \"Move United States down\" 24x36",
789 + "button \"Remove United States from the \" 28x36",
790 + "button \"Move France up\" 24x36",
791 + "button \"Move France down\" 24x36",
792 + "button \"Remove France from the compari\" 28x36",
793 + "a \"API\" 22x32"
794 + ],
795 + "smallH": [
796 + "a \"Skip to content\" h=1",
797 + "a \"🇨🇦Canada\" h=18",
798 + "a \"🇺🇸United States\" h=18",
799 + "a \"🇫🇷France\" h=18",
800 + "a \"contact@spboucher.ai\" h=15",
801 + "a \"MacLustr\" h=15"
802 + ],
803 + "sticky": [
804 + 57,
805 + 142
806 + ],
807 + "docH": 1759,
808 + "errors": []
809 + },
810 + {
811 + "name": "cmp-economy",
812 + "path": "/compare/canada/united-states/france?tab=economy",
813 + "width": 1280,
814 + "overflow": 0,
815 + "wide": [],
816 + "small": [
817 + "a \"Skip to content\" 1x1",
818 + "button \"Move Canada up\" 24x36",
819 + "button \"Move Canada down\" 24x36",
820 + "button \"Remove Canada from the compari\" 28x36",
821 + "button \"Move United States up\" 24x36",
822 + "button \"Move United States down\" 24x36",
823 + "button \"Remove United States from the \" 28x36",
824 + "button \"Move France up\" 24x36",
825 + "button \"Move France down\" 24x36",
826 + "button \"Remove France from the compari\" 28x36",
827 + "input \"\" 13x13",
828 + "a \"API\" 22x32"
829 + ],
830 + "smallH": [
831 + "a \"Skip to content\" h=1",
832 + "select \"From\" h=19",
833 + "select \"To\" h=19",
834 + "input \"\" h=13",
835 + "a \"GDP (current US$)\" h=17",
836 + "button \"Source for Canada, United Stat\" h=28",
837 + "a \"GDP, PPP (current internationa\" h=17",
838 + "button \"Source for Canada, United Stat\" h=28",
839 + "a \"GDP per capita (current US$)\" h=17",
840 + "button \"Source for Canada, United Stat\" h=28",
841 + "a \"GDP per capita, PPP (current i\" h=17",
842 + "button \"Source for Canada, United Stat\" h=28"
843 + ],
844 + "sticky": [
845 + 57,
846 + 142
847 + ],
848 + "docH": 7715,
849 + "errors": []
850 + },
851 + {
852 + "name": "cmp-hero",
853 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
854 + "width": 1280,
855 + "overflow": 0,
856 + "wide": [],
857 + "small": [
858 + "a \"Skip to content\" 1x1",
859 + "button \"Move Canada up\" 24x36",
860 + "button \"Move Canada down\" 24x36",
861 + "button \"Remove Canada from the compari\" 28x36",
862 + "button \"Move United States up\" 24x36",
863 + "button \"Move United States down\" 24x36",
864 + "button \"Remove United States from the \" 28x36",
865 + "button \"Move France up\" 24x36",
866 + "button \"Move France down\" 24x36",
867 + "button \"Remove France from the compari\" 28x36",
868 + "input \"\" 13x13",
869 + "button \"10 y\" 42x32"
870 + ],
871 + "smallH": [
872 + "a \"Skip to content\" h=1",
873 + "select \"From\" h=19",
874 + "select \"To\" h=19",
875 + "input \"\" h=13",
876 + "a \"GDP per capita (current US$)\" h=26",
877 + "button \"Source for Canada, United Stat\" h=28",
878 + "a \"GDP (current US$)\" h=17",
879 + "button \"Source for Canada, United Stat\" h=28",
880 + "a \"GDP, PPP (current internationa\" h=17",
881 + "button \"Source for Canada, United Stat\" h=28",
882 + "a \"GDP per capita, PPP (current i\" h=17",
883 + "button \"Source for Canada, United Stat\" h=28"
884 + ],
885 + "sticky": [
886 + 57,
887 + 142
888 + ],
889 + "docH": 8304,
890 + "errors": []
891 + },
892 + {
893 + "name": "rankings",
894 + "path": "/rankings",
895 + "width": 1280,
896 + "overflow": 0,
897 + "wide": [],
898 + "small": [
899 + "a \"Skip to content\" 1x1",
900 + "a \"API\" 22x32"
901 + ],
902 + "smallH": [
903 + "a \"Skip to content\" h=1",
904 + "a \"contact@spboucher.ai\" h=15",
905 + "a \"MacLustr\" h=15"
906 + ],
907 + "sticky": [
908 + 57,
909 + 64
910 + ],
911 + "docH": 10330,
912 + "errors": []
913 + },
914 + {
915 + "name": "rk-gdppc",
916 + "path": "/rankings/gdp-per-capita",
917 + "width": 1280,
918 + "overflow": 0,
919 + "wide": [],
920 + "small": [
921 + "a \"Skip to content\" 1x1",
922 + "button \"Remove Luxembourg\" 28x36",
923 + "button \"Remove Ireland\" 28x36",
924 + "button \"Remove Switzerland\" 28x36",
925 + "a \"API\" 22x32"
926 + ],
927 + "smallH": [
928 + "a \"Skip to content\" h=1",
929 + "a \"Rankings\" h=18",
930 + "a \"Economy\" h=18",
931 + "select \"Year\" h=19",
932 + "select \"Group\" h=19",
933 + "a \"contact@spboucher.ai\" h=15",
934 + "a \"MacLustr\" h=15"
935 + ],
936 + "sticky": [
937 + 57
938 + ],
939 + "docH": 2667,
940 + "errors": []
941 + },
942 + {
943 + "name": "rk-life-oecd",
944 + "path": "/rankings/life-expectancy?group=oecd",
945 + "width": 1280,
946 + "overflow": 0,
947 + "wide": [],
948 + "small": [
949 + "a \"Skip to content\" 1x1",
950 + "a \"Health\" 37x18",
951 + "button \"Remove Switzerland\" 28x36",
952 + "button \"Remove Sweden\" 28x36",
953 + "button \"Remove Japan\" 28x36",
954 + "a \"API\" 22x32"
955 + ],
956 + "smallH": [
957 + "a \"Skip to content\" h=1",
958 + "a \"Rankings\" h=18",
959 + "a \"Health\" h=18",
960 + "select \"Year\" h=19",
961 + "select \"Group\" h=19",
962 + "a \"contact@spboucher.ai\" h=15",
963 + "a \"MacLustr\" h=15"
964 + ],
965 + "sticky": [
966 + 57
967 + ],
968 + "docH": 2667,
969 + "errors": []
970 + },
971 + {
972 + "name": "compare",
973 + "path": "/compare",
974 + "width": 1440,
975 + "overflow": 0,
976 + "wide": [],
977 + "small": [
978 + "a \"Skip to content\" 1x1",
979 + "a \"API\" 22x32"
980 + ],
981 + "smallH": [
982 + "a \"Skip to content\" h=1",
983 + "a \"contact@spboucher.ai\" h=15",
984 + "a \"MacLustr\" h=15"
985 + ],
986 + "sticky": [
987 + 57,
988 + 40
989 + ],
990 + "docH": 1359,
991 + "errors": []
992 + },
993 + {
994 + "name": "cmp-snapshot",
995 + "path": "/compare/canada/united-states/france",
996 + "width": 1440,
997 + "overflow": 0,
998 + "wide": [],
999 + "small": [
1000 + "a \"Skip to content\" 1x1",
1001 + "button \"Move Canada up\" 24x36",
1002 + "button \"Move Canada down\" 24x36",
1003 + "button \"Remove Canada from the compari\" 28x36",
1004 + "button \"Move United States up\" 24x36",
1005 + "button \"Move United States down\" 24x36",
1006 + "button \"Remove United States from the \" 28x36",
1007 + "button \"Move France up\" 24x36",
1008 + "button \"Move France down\" 24x36",
1009 + "button \"Remove France from the compari\" 28x36",
1010 + "a \"API\" 22x32"
1011 + ],
1012 + "smallH": [
1013 + "a \"Skip to content\" h=1",
1014 + "a \"🇨🇦Canada\" h=18",
1015 + "a \"🇺🇸United States\" h=18",
1016 + "a \"🇫🇷France\" h=18",
1017 + "a \"contact@spboucher.ai\" h=15",
1018 + "a \"MacLustr\" h=15"
1019 + ],
1020 + "sticky": [
1021 + 57,
1022 + 142
1023 + ],
1024 + "docH": 1759,
1025 + "errors": []
1026 + },
1027 + {
1028 + "name": "cmp-economy",
1029 + "path": "/compare/canada/united-states/france?tab=economy",
1030 + "width": 1440,
1031 + "overflow": 0,
1032 + "wide": [],
1033 + "small": [
1034 + "a \"Skip to content\" 1x1",
1035 + "button \"Move Canada up\" 24x36",
1036 + "button \"Move Canada down\" 24x36",
1037 + "button \"Remove Canada from the compari\" 28x36",
1038 + "button \"Move United States up\" 24x36",
1039 + "button \"Move United States down\" 24x36",
1040 + "button \"Remove United States from the \" 28x36",
1041 + "button \"Move France up\" 24x36",
1042 + "button \"Move France down\" 24x36",
1043 + "button \"Remove France from the compari\" 28x36",
1044 + "input \"\" 13x13",
1045 + "a \"API\" 22x32"
1046 + ],
1047 + "smallH": [
1048 + "a \"Skip to content\" h=1",
1049 + "select \"From\" h=19",
1050 + "select \"To\" h=19",
1051 + "input \"\" h=13",
1052 + "a \"GDP (current US$)\" h=17",
1053 + "button \"Source for Canada, United Stat\" h=28",
1054 + "a \"GDP, PPP (current internationa\" h=17",
1055 + "button \"Source for Canada, United Stat\" h=28",
1056 + "a \"GDP per capita (current US$)\" h=17",
1057 + "button \"Source for Canada, United Stat\" h=28",
1058 + "a \"GDP per capita, PPP (current i\" h=17",
1059 + "button \"Source for Canada, United Stat\" h=28"
1060 + ],
1061 + "sticky": [
1062 + 57,
1063 + 142
1064 + ],
1065 + "docH": 7705,
1066 + "errors": []
1067 + },
1068 + {
1069 + "name": "cmp-hero",
1070 + "path": "/compare/canada/united-states/france?tab=economy&indicator=gdp-per-capita&from=1990&mode=index100",
1071 + "width": 1440,
1072 + "overflow": 0,
1073 + "wide": [],
1074 + "small": [
1075 + "a \"Skip to content\" 1x1",
1076 + "button \"Move Canada up\" 24x36",
1077 + "button \"Move Canada down\" 24x36",
1078 + "button \"Remove Canada from the compari\" 28x36",
1079 + "button \"Move United States up\" 24x36",
1080 + "button \"Move United States down\" 24x36",
1081 + "button \"Remove United States from the \" 28x36",
1082 + "button \"Move France up\" 24x36",
1083 + "button \"Move France down\" 24x36",
1084 + "button \"Remove France from the compari\" 28x36",
1085 + "input \"\" 13x13",
1086 + "button \"10 y\" 42x32"
1087 + ],
1088 + "smallH": [
1089 + "a \"Skip to content\" h=1",
1090 + "select \"From\" h=19",
1091 + "select \"To\" h=19",
1092 + "input \"\" h=13",
1093 + "a \"GDP per capita (current US$)\" h=26",
1094 + "button \"Source for Canada, United Stat\" h=28",
1095 + "a \"GDP (current US$)\" h=17",
1096 + "button \"Source for Canada, United Stat\" h=28",
1097 + "a \"GDP, PPP (current internationa\" h=17",
1098 + "button \"Source for Canada, United Stat\" h=28",
1099 + "a \"GDP per capita, PPP (current i\" h=17",
1100 + "button \"Source for Canada, United Stat\" h=28"
1101 + ],
1102 + "sticky": [
1103 + 57,
1104 + 142
1105 + ],
1106 + "docH": 8294,
1107 + "errors": []
1108 + },
1109 + {
1110 + "name": "rankings",
1111 + "path": "/rankings",
1112 + "width": 1440,
1113 + "overflow": 0,
1114 + "wide": [],
1115 + "small": [
1116 + "a \"Skip to content\" 1x1",
1117 + "a \"API\" 22x32"
1118 + ],
1119 + "smallH": [
1120 + "a \"Skip to content\" h=1",
1121 + "a \"contact@spboucher.ai\" h=15",
1122 + "a \"MacLustr\" h=15"
1123 + ],
1124 + "sticky": [
1125 + 57,
1126 + 64
1127 + ],
1128 + "docH": 10106,
1129 + "errors": []
1130 + },
1131 + {
1132 + "name": "rk-gdppc",
1133 + "path": "/rankings/gdp-per-capita",
1134 + "width": 1440,
1135 + "overflow": 0,
1136 + "wide": [],
1137 + "small": [
1138 + "a \"Skip to content\" 1x1",
1139 + "button \"Remove Luxembourg\" 28x36",
1140 + "button \"Remove Ireland\" 28x36",
1141 + "button \"Remove Switzerland\" 28x36",
1142 + "a \"API\" 22x32"
1143 + ],
1144 + "smallH": [
1145 + "a \"Skip to content\" h=1",
1146 + "a \"Rankings\" h=18",
1147 + "a \"Economy\" h=18",
1148 + "select \"Year\" h=19",
1149 + "select \"Group\" h=19",
1150 + "a \"contact@spboucher.ai\" h=15",
1151 + "a \"MacLustr\" h=15"
1152 + ],
1153 + "sticky": [
1154 + 57
1155 + ],
1156 + "docH": 2667,
1157 + "errors": []
1158 + },
1159 + {
1160 + "name": "rk-life-oecd",
1161 + "path": "/rankings/life-expectancy?group=oecd",
1162 + "width": 1440,
1163 + "overflow": 0,
1164 + "wide": [],
1165 + "small": [
1166 + "a \"Skip to content\" 1x1",
1167 + "a \"Health\" 37x18",
1168 + "button \"Remove Switzerland\" 28x36",
1169 + "button \"Remove Sweden\" 28x36",
1170 + "button \"Remove Japan\" 28x36",
1171 + "a \"API\" 22x32"
1172 + ],
1173 + "smallH": [
1174 + "a \"Skip to content\" h=1",
1175 + "a \"Rankings\" h=18",
1176 + "a \"Health\" h=18",
1177 + "select \"Year\" h=19",
1178 + "select \"Group\" h=19",
1179 + "a \"contact@spboucher.ai\" h=15",
1180 + "a \"MacLustr\" h=15"
1181 + ],
1182 + "sticky": [
1183 + 57
1184 + ],
1185 + "docH": 2667,
1186 + "errors": []
1187 + },
1188 + {
1189 + "checks": [
1190 + {
1191 + "check": "compare mode round-trip",
1192 + "url": "http://localhost:8290/compare/canada/united-states/france?tab=economy&mode=index100",
1193 + "ok": true
1194 + },
1195 + {
1196 + "check": "tab switch updates URL",
1197 + "url": "http://localhost:8290/compare/canada/united-states/france?tab=population&mode=index100",
1198 + "ok": true
1199 + },
1200 + {
1201 + "check": "ranking year round-trip",
1202 + "url": "http://localhost:8290/rankings/gdp-per-capita?year=2015",
1203 + "ok": true
1204 + },
1205 + {
1206 + "check": "404 /compare/canada",
1207 + "status": 404,
1208 + "ok": true
1209 + },
1210 + {
1211 + "check": "404 /rankings/not-a-thing",
1212 + "status": 404,
1213 + "ok": true
1214 + },
1215 + {
1216 + "check": "404 /compare/canada/nowhere-land",
1217 + "status": 404,
1218 + "ok": true
1219 + }
1220 + ]
1221 + }
1222 +]
\ No newline at end of file
added apps/web/qa/screens/compare-rankings/rk-gdppc-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-gdppc-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-gdppc-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-gdppc-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-gdppc-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-gdppc-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/rk-life-oecd-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-1280-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-1280-y1800.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-1280-y900.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-390-y1800.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-economy-390-y900.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-hero-1280-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-hero-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-hero-390-y700.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-snapshot-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/cmp-snapshot-390-y700.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-added-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-custom-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-eco-dark-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-health-hero-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-indpicker-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-options-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-picker-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-prov-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-rk-filters-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-rk-g20-dark-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-rk-highlight-dark-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/i-rk-history-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rankings-1280-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rankings-1280-y900.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rankings-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rankings-390-y800.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-1280-y1800.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-1280-y900.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-390-y2600.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-390-y3600.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-gdppc-390-y800.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-life-oecd-1280-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-life-oecd-1280-y1600.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-life-oecd-390-y0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/compare-rankings/seg/rk-life-oecd-390-y1200.png +0 −0

Binary file not shown.

modified apps/web/qa/screens/countries-1280.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-390-dark.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_gdp-per-capita-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-390-touch.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/explore/indicators_life-expectancy-430.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

added apps/web/qa/screens/explore/report.json +1141 −0
@@ -0,0 +1,1141 @@
1 +[
2 + {
3 + "path": "/indicators/nope",
4 + "width": 390,
5 + "status": 404,
6 + "check": "404"
7 + },
8 + {
9 + "path": "/regions/nope",
10 + "width": 390,
11 + "status": 404,
12 + "check": "404"
13 + },
14 + {
15 + "path": "/sources/nope",
16 + "width": 390,
17 + "status": 404,
18 + "check": "404"
19 + },
20 + {
21 + "path": "/indicators",
22 + "width": 320,
23 + "status": 200,
24 + "overflow": 0,
25 + "wide": [
26 + "li.shrink-0 right=373",
27 + "button.inline-flex.h-11.w-full right=373",
28 + "span.tnum.text-2xs.text-ink-3 right=361",
29 + "li.shrink-0 right=494",
30 + "button.inline-flex.h-11.w-full right=494",
31 + "span.tnum.text-2xs.text-ink-3 right=482"
32 + ],
33 + "small": [],
34 + "docH": 53477,
35 + "errors": [],
36 + "file": "indicators-320.png"
37 + },
38 + {
39 + "path": "/indicators?topic=health",
40 + "width": 320,
41 + "status": 200,
42 + "overflow": 0,
43 + "wide": [
44 + "li.shrink-0 right=409",
45 + "button.inline-flex.h-11.w-full right=409",
46 + "span.tnum.text-2xs.text-ink-3 right=397",
47 + "li.shrink-0 right=505",
48 + "button.inline-flex.h-11.w-full right=505",
49 + "span.tnum.text-2xs.text-ink-3 right=493"
50 + ],
51 + "small": [],
52 + "docH": 5758,
53 + "errors": [],
54 + "file": "indicators_topic_health-320.png"
55 + },
56 + {
57 + "path": "/indicators/life-expectancy",
58 + "width": 320,
59 + "status": 200,
60 + "overflow": 0,
61 + "wide": [
62 + "span. right=372",
63 + "li.shrink-0 right=330",
64 + "button.inline-flex.h-11.items-center right=330",
65 + "li.shrink-0 right=389",
66 + "button.inline-flex.h-11.items-center right=389",
67 + "span. right=372"
68 + ],
69 + "small": [],
70 + "docH": 6691,
71 + "errors": [],
72 + "file": "indicators_life-expectancy-320.png"
73 + },
74 + {
75 + "path": "/indicators/gdp-per-capita",
76 + "width": 320,
77 + "status": 200,
78 + "overflow": 0,
79 + "wide": [
80 + "span. right=380",
81 + "li.shrink-0 right=330",
82 + "button.inline-flex.h-11.items-center right=330",
83 + "li.shrink-0 right=389",
84 + "button.inline-flex.h-11.items-center right=389",
85 + "span. right=380"
86 + ],
87 + "small": [],
88 + "docH": 7135,
89 + "errors": [],
90 + "file": "indicators_gdp-per-capita-320.png"
91 + },
92 + {
93 + "path": "/regions",
94 + "width": 320,
95 + "status": 200,
96 + "overflow": 0,
97 + "wide": [],
98 + "small": [],
99 + "docH": 4569,
100 + "errors": [],
101 + "file": "regions-320.png"
102 + },
103 + {
104 + "path": "/regions/oecd",
105 + "width": 320,
106 + "status": 200,
107 + "overflow": 0,
108 + "wide": [],
109 + "small": [],
110 + "docH": 8547,
111 + "errors": [],
112 + "file": "regions_oecd-320.png"
113 + },
114 + {
115 + "path": "/explore",
116 + "width": 320,
117 + "status": 200,
118 + "overflow": 0,
119 + "wide": [
120 + "li. right=357",
121 + "a.inline-flex.h-11.items-center right=357",
122 + "li. right=501",
123 + "a.inline-flex.h-11.items-center right=501",
124 + "li. right=585",
125 + "a.inline-flex.h-11.items-center right=585"
126 + ],
127 + "small": [],
128 + "docH": 9365,
129 + "errors": [],
130 + "file": "explore-320.png"
131 + },
132 + {
133 + "path": "/changes",
134 + "width": 320,
135 + "status": 200,
136 + "overflow": 0,
137 + "wide": [],
138 + "small": [],
139 + "docH": 15227,
140 + "errors": [],
141 + "file": "changes-320.png"
142 + },
143 + {
144 + "path": "/data",
145 + "width": 320,
146 + "status": 200,
147 + "overflow": 0,
148 + "wide": [
149 + "code. right=723"
150 + ],
151 + "small": [],
152 + "docH": 4321,
153 + "errors": [],
154 + "file": "data-320.png"
155 + },
156 + {
157 + "path": "/sources",
158 + "width": 320,
159 + "status": 200,
160 + "overflow": 0,
161 + "wide": [],
162 + "small": [],
163 + "docH": 3846,
164 + "errors": [],
165 + "file": "sources-320.png"
166 + },
167 + {
168 + "path": "/sources/worldbank",
169 + "width": 320,
170 + "status": 200,
171 + "overflow": 0,
172 + "wide": [],
173 + "small": [],
174 + "docH": 42927,
175 + "errors": [],
176 + "file": "sources_worldbank-320.png"
177 + },
178 + {
179 + "path": "/methodology",
180 + "width": 320,
181 + "status": 200,
182 + "overflow": 0,
183 + "wide": [
184 + "li.shrink-0 right=430",
185 + "a.inline-flex.h-11.items-center right=430",
186 + "li.shrink-0 right=740",
187 + "a.inline-flex.h-11.items-center right=740",
188 + "li.shrink-0 right=958",
189 + "a.inline-flex.h-11.items-center right=958"
190 + ],
191 + "small": [],
192 + "docH": 13226,
193 + "errors": [],
194 + "file": "methodology-320.png"
195 + },
196 + {
197 + "path": "/api",
198 + "width": 320,
199 + "status": 200,
200 + "overflow": 0,
201 + "wide": [
202 + "code. right=426",
203 + "code. right=636",
204 + "code. right=535",
205 + "code. right=600",
206 + "code. right=520",
207 + "code. right=419"
208 + ],
209 + "small": [],
210 + "docH": 9343,
211 + "errors": [],
212 + "file": "api-320.png"
213 + },
214 + {
215 + "path": "/admin/login",
216 + "width": 320,
217 + "status": 200,
218 + "overflow": 0,
219 + "wide": [],
220 + "small": [],
221 + "docH": 1505,
222 + "errors": [],
223 + "file": "admin_login-320.png"
224 + },
225 + {
226 + "path": "/indicators",
227 + "width": 375,
228 + "status": 200,
229 + "overflow": 0,
230 + "wide": [
231 + "li.shrink-0 right=494",
232 + "button.inline-flex.h-11.w-full right=494",
233 + "span.tnum.text-2xs.text-ink-3 right=482",
234 + "li.shrink-0 right=582",
235 + "button.inline-flex.h-11.w-full right=582",
236 + "span.tnum.text-2xs.text-ink-3 right=570"
237 + ],
238 + "small": [],
239 + "docH": 49574,
240 + "errors": [],
241 + "file": "indicators-375.png"
242 + },
243 + {
244 + "path": "/indicators?topic=health",
245 + "width": 375,
246 + "status": 200,
247 + "overflow": 0,
248 + "wide": [
249 + "li.shrink-0 right=437",
250 + "button.inline-flex.h-11.w-full right=437",
251 + "span.tnum.text-2xs.text-ink-3 right=425",
252 + "li.shrink-0 right=533",
253 + "button.inline-flex.h-11.w-full right=533",
254 + "span.tnum.text-2xs.text-ink-3 right=521"
255 + ],
256 + "small": [],
257 + "docH": 5353,
258 + "errors": [],
259 + "file": "indicators_topic_health-375.png"
260 + },
261 + {
262 + "path": "/indicators/life-expectancy",
263 + "width": 375,
264 + "status": 200,
265 + "overflow": 0,
266 + "wide": [
267 + "li.shrink-0 right=389",
268 + "button.inline-flex.h-11.items-center right=389",
269 + "code. right=1373"
270 + ],
271 + "small": [],
272 + "docH": 6364,
273 + "errors": [],
274 + "file": "indicators_life-expectancy-375.png"
275 + },
276 + {
277 + "path": "/indicators/gdp-per-capita",
278 + "width": 375,
279 + "status": 200,
280 + "overflow": 0,
281 + "wide": [
282 + "span. right=380",
283 + "li.shrink-0 right=389",
284 + "button.inline-flex.h-11.items-center right=389",
285 + "span. right=380",
286 + "span. right=380",
287 + "code. right=1366"
288 + ],
289 + "small": [],
290 + "docH": 6733,
291 + "errors": [],
292 + "file": "indicators_gdp-per-capita-375.png"
293 + },
294 + {
295 + "path": "/regions",
296 + "width": 375,
297 + "status": 200,
298 + "overflow": 0,
299 + "wide": [],
300 + "small": [],
301 + "docH": 4507,
302 + "errors": [],
303 + "file": "regions-375.png"
304 + },
305 + {
306 + "path": "/regions/oecd",
307 + "width": 375,
308 + "status": 200,
309 + "overflow": 0,
310 + "wide": [],
311 + "small": [],
312 + "docH": 8219,
313 + "errors": [],
314 + "file": "regions_oecd-375.png"
315 + },
316 + {
317 + "path": "/explore",
318 + "width": 375,
319 + "status": 200,
320 + "overflow": 0,
321 + "wide": [
322 + "li. right=501",
323 + "a.inline-flex.h-11.items-center right=501",
324 + "li. right=585",
325 + "a.inline-flex.h-11.items-center right=585",
326 + "li. right=690",
327 + "a.inline-flex.h-11.items-center right=690"
328 + ],
329 + "small": [],
330 + "docH": 8690,
331 + "errors": [],
332 + "file": "explore-375.png"
333 + },
334 + {
335 + "path": "/changes",
336 + "width": 375,
337 + "status": 200,
338 + "overflow": 0,
339 + "wide": [],
340 + "small": [],
341 + "docH": 13884,
342 + "errors": [],
343 + "file": "changes-375.png"
344 + },
345 + {
346 + "path": "/data",
347 + "width": 375,
348 + "status": 200,
349 + "overflow": 0,
350 + "wide": [
351 + "code. right=723"
352 + ],
353 + "small": [],
354 + "docH": 4058,
355 + "errors": [],
356 + "file": "data-375.png"
357 + },
358 + {
359 + "path": "/sources",
360 + "width": 375,
361 + "status": 200,
362 + "overflow": 0,
363 + "wide": [],
364 + "small": [],
365 + "docH": 3615,
366 + "errors": [],
367 + "file": "sources-375.png"
368 + },
369 + {
370 + "path": "/sources/worldbank",
371 + "width": 375,
372 + "status": 200,
373 + "overflow": 0,
374 + "wide": [],
375 + "small": [],
376 + "docH": 42239,
377 + "errors": [],
378 + "file": "sources_worldbank-375.png"
379 + },
380 + {
381 + "path": "/methodology",
382 + "width": 375,
383 + "status": 200,
384 + "overflow": 0,
385 + "wide": [
386 + "li.shrink-0 right=430",
387 + "a.inline-flex.h-11.items-center right=430",
388 + "li.shrink-0 right=740",
389 + "a.inline-flex.h-11.items-center right=740",
390 + "li.shrink-0 right=958",
391 + "a.inline-flex.h-11.items-center right=958"
392 + ],
393 + "small": [],
394 + "docH": 11644,
395 + "errors": [],
396 + "file": "methodology-375.png"
397 + },
398 + {
399 + "path": "/api",
400 + "width": 375,
401 + "status": 200,
402 + "overflow": 0,
403 + "wide": [
404 + "code. right=426",
405 + "code. right=636",
406 + "code. right=535",
407 + "code. right=600",
408 + "code. right=520",
409 + "code. right=419"
410 + ],
411 + "small": [],
412 + "docH": 8870,
413 + "errors": [],
414 + "file": "api-375.png"
415 + },
416 + {
417 + "path": "/admin/login",
418 + "width": 375,
419 + "status": 200,
420 + "overflow": 0,
421 + "wide": [],
422 + "small": [],
423 + "docH": 1484,
424 + "errors": [],
425 + "file": "admin_login-375.png"
426 + },
427 + {
428 + "path": "/indicators",
429 + "width": 390,
430 + "status": 200,
431 + "overflow": 0,
432 + "wide": [
433 + "li.shrink-0 right=494",
434 + "button.inline-flex.h-11.w-full right=494",
435 + "span.tnum.text-2xs.text-ink-3 right=482",
436 + "li.shrink-0 right=582",
437 + "button.inline-flex.h-11.w-full right=582",
438 + "span.tnum.text-2xs.text-ink-3 right=570"
439 + ],
440 + "small": [],
441 + "docH": 48272,
442 + "errors": [],
443 + "file": "indicators-390.png"
444 + },
445 + {
446 + "path": "/indicators?topic=health",
447 + "width": 390,
448 + "status": 200,
449 + "overflow": 0,
450 + "wide": [
451 + "li.shrink-0 right=444",
452 + "button.inline-flex.h-11.w-full right=444",
453 + "span.tnum.text-2xs.text-ink-3 right=432",
454 + "li.shrink-0 right=540",
455 + "button.inline-flex.h-11.w-full right=540",
456 + "span.tnum.text-2xs.text-ink-3 right=528"
457 + ],
458 + "small": [],
459 + "docH": 5224,
460 + "errors": [],
461 + "file": "indicators_topic_health-390.png"
462 + },
463 + {
464 + "path": "/indicators/life-expectancy",
465 + "width": 390,
466 + "status": 200,
467 + "overflow": 0,
468 + "wide": [
469 + "code. right=1373"
470 + ],
471 + "small": [],
472 + "docH": 6289,
473 + "errors": [],
474 + "file": "indicators_life-expectancy-390.png"
475 + },
476 + {
477 + "path": "/indicators/gdp-per-capita",
478 + "width": 390,
479 + "status": 200,
480 + "overflow": 0,
481 + "wide": [
482 + "code. right=1366"
483 + ],
484 + "small": [],
485 + "docH": 6638,
486 + "errors": [],
487 + "file": "indicators_gdp-per-capita-390.png"
488 + },
489 + {
490 + "path": "/regions",
491 + "width": 390,
492 + "status": 200,
493 + "overflow": 0,
494 + "wide": [],
495 + "small": [],
496 + "docH": 4489,
497 + "errors": [],
498 + "file": "regions-390.png"
499 + },
500 + {
501 + "path": "/regions/oecd",
502 + "width": 390,
503 + "status": 200,
504 + "overflow": 0,
505 + "wide": [],
506 + "small": [],
507 + "docH": 8209,
508 + "errors": [],
509 + "file": "regions_oecd-390.png"
510 + },
511 + {
512 + "path": "/explore",
513 + "width": 390,
514 + "status": 200,
515 + "overflow": 0,
516 + "wide": [
517 + "li. right=501",
518 + "a.inline-flex.h-11.items-center right=501",
519 + "li. right=585",
520 + "a.inline-flex.h-11.items-center right=585",
521 + "li. right=690",
522 + "a.inline-flex.h-11.items-center right=690"
523 + ],
524 + "small": [],
525 + "docH": 8606,
526 + "errors": [],
527 + "file": "explore-390.png"
528 + },
529 + {
530 + "path": "/changes",
531 + "width": 390,
532 + "status": 200,
533 + "overflow": 0,
534 + "wide": [],
535 + "small": [],
536 + "docH": 13813,
537 + "errors": [],
538 + "file": "changes-390.png"
539 + },
540 + {
541 + "path": "/data",
542 + "width": 390,
543 + "status": 200,
544 + "overflow": 0,
545 + "wide": [
546 + "code. right=723"
547 + ],
548 + "small": [],
549 + "docH": 4020,
550 + "errors": [],
551 + "file": "data-390.png"
552 + },
553 + {
554 + "path": "/sources",
555 + "width": 390,
556 + "status": 200,
557 + "overflow": 0,
558 + "wide": [],
559 + "small": [],
560 + "docH": 3556,
561 + "errors": [],
562 + "file": "sources-390.png"
563 + },
564 + {
565 + "path": "/sources/worldbank",
566 + "width": 390,
567 + "status": 200,
568 + "overflow": 0,
569 + "wide": [],
570 + "small": [],
571 + "docH": 41927,
572 + "errors": [],
573 + "file": "sources_worldbank-390.png"
574 + },
575 + {
576 + "path": "/methodology",
577 + "width": 390,
578 + "status": 200,
579 + "overflow": 0,
580 + "wide": [
581 + "li.shrink-0 right=430",
582 + "a.inline-flex.h-11.items-center right=430",
583 + "li.shrink-0 right=740",
584 + "a.inline-flex.h-11.items-center right=740",
585 + "li.shrink-0 right=958",
586 + "a.inline-flex.h-11.items-center right=958"
587 + ],
588 + "small": [],
589 + "docH": 11263,
590 + "errors": [],
591 + "file": "methodology-390.png"
592 + },
593 + {
594 + "path": "/api",
595 + "width": 390,
596 + "status": 200,
597 + "overflow": 0,
598 + "wide": [
599 + "code. right=426",
600 + "code. right=636",
601 + "code. right=535",
602 + "code. right=600",
603 + "code. right=520",
604 + "code. right=419"
605 + ],
606 + "small": [],
607 + "docH": 8705,
608 + "errors": [],
609 + "file": "api-390.png"
610 + },
611 + {
612 + "path": "/admin/login",
613 + "width": 390,
614 + "status": 200,
615 + "overflow": 0,
616 + "wide": [],
617 + "small": [],
618 + "docH": 1467,
619 + "errors": [],
620 + "file": "admin_login-390.png"
621 + },
622 + {
623 + "path": "/indicators",
624 + "width": 430,
625 + "status": 200,
626 + "overflow": 0,
627 + "wide": [
628 + "li.shrink-0 right=494",
629 + "button.inline-flex.h-11.w-full right=494",
630 + "span.tnum.text-2xs.text-ink-3 right=482",
631 + "li.shrink-0 right=582",
632 + "button.inline-flex.h-11.w-full right=582",
633 + "span.tnum.text-2xs.text-ink-3 right=570"
634 + ],
635 + "small": [],
636 + "docH": 46881,
637 + "errors": [],
638 + "file": "indicators-430.png"
639 + },
640 + {
641 + "path": "/indicators?topic=health",
642 + "width": 430,
643 + "status": 200,
644 + "overflow": 0,
645 + "wide": [
646 + "li.shrink-0 right=464",
647 + "button.inline-flex.h-11.w-full right=464",
648 + "span.tnum.text-2xs.text-ink-3 right=452",
649 + "li.shrink-0 right=560",
650 + "button.inline-flex.h-11.w-full right=560",
651 + "span.tnum.text-2xs.text-ink-3 right=548"
652 + ],
653 + "small": [],
654 + "docH": 5122,
655 + "errors": [],
656 + "file": "indicators_topic_health-430.png"
657 + },
658 + {
659 + "path": "/indicators/life-expectancy",
660 + "width": 430,
661 + "status": 200,
662 + "overflow": 0,
663 + "wide": [
664 + "code. right=1373"
665 + ],
666 + "small": [],
667 + "docH": 6234,
668 + "errors": [],
669 + "file": "indicators_life-expectancy-430.png"
670 + },
671 + {
672 + "path": "/indicators/gdp-per-capita",
673 + "width": 430,
674 + "status": 200,
675 + "overflow": 0,
676 + "wide": [
677 + "code. right=1366"
678 + ],
679 + "small": [],
680 + "docH": 6467,
681 + "errors": [],
682 + "file": "indicators_gdp-per-capita-430.png"
683 + },
684 + {
685 + "path": "/regions",
686 + "width": 430,
687 + "status": 200,
688 + "overflow": 0,
689 + "wide": [],
690 + "small": [],
691 + "docH": 4470,
692 + "errors": [],
693 + "file": "regions-430.png"
694 + },
695 + {
696 + "path": "/regions/oecd",
697 + "width": 430,
698 + "status": 200,
699 + "overflow": 0,
700 + "wide": [],
701 + "small": [],
702 + "docH": 8167,
703 + "errors": [],
704 + "file": "regions_oecd-430.png"
705 + },
706 + {
707 + "path": "/explore",
708 + "width": 430,
709 + "status": 200,
710 + "overflow": 0,
711 + "wide": [
712 + "li. right=501",
713 + "a.inline-flex.h-11.items-center right=501",
714 + "li. right=585",
715 + "a.inline-flex.h-11.items-center right=585",
716 + "li. right=690",
717 + "a.inline-flex.h-11.items-center right=690"
718 + ],
719 + "small": [],
720 + "docH": 8469,
721 + "errors": [],
722 + "file": "explore-430.png"
723 + },
724 + {
725 + "path": "/changes",
726 + "width": 430,
727 + "status": 200,
728 + "overflow": 0,
729 + "wide": [],
730 + "small": [],
731 + "docH": 13707,
732 + "errors": [],
733 + "file": "changes-430.png"
734 + },
735 + {
736 + "path": "/data",
737 + "width": 430,
738 + "status": 200,
739 + "overflow": 0,
740 + "wide": [
741 + "code. right=723"
742 + ],
743 + "small": [],
744 + "docH": 3791,
745 + "errors": [],
746 + "file": "data-430.png"
747 + },
748 + {
749 + "path": "/sources",
750 + "width": 430,
751 + "status": 200,
752 + "overflow": 0,
753 + "wide": [],
754 + "small": [],
755 + "docH": 3421,
756 + "errors": [],
757 + "file": "sources-430.png"
758 + },
759 + {
760 + "path": "/sources/worldbank",
761 + "width": 430,
762 + "status": 200,
763 + "overflow": 0,
764 + "wide": [],
765 + "small": [],
766 + "docH": 41286,
767 + "errors": [],
768 + "file": "sources_worldbank-430.png"
769 + },
770 + {
771 + "path": "/methodology",
772 + "width": 430,
773 + "status": 200,
774 + "overflow": 0,
775 + "wide": [
776 + "li.shrink-0 right=740",
777 + "a.inline-flex.h-11.items-center right=740",
778 + "li.shrink-0 right=958",
779 + "a.inline-flex.h-11.items-center right=958",
780 + "li.shrink-0 right=1054",
781 + "a.inline-flex.h-11.items-center right=1054"
782 + ],
783 + "small": [],
784 + "docH": 10491,
785 + "errors": [],
786 + "file": "methodology-430.png"
787 + },
788 + {
789 + "path": "/api",
790 + "width": 430,
791 + "status": 200,
792 + "overflow": 0,
793 + "wide": [
794 + "code. right=636",
795 + "code. right=535",
796 + "code. right=600",
797 + "code. right=520",
798 + "code. right=593"
799 + ],
800 + "small": [],
801 + "docH": 8321,
802 + "errors": [],
803 + "file": "api-430.png"
804 + },
805 + {
806 + "path": "/admin/login",
807 + "width": 430,
808 + "status": 200,
809 + "overflow": 0,
810 + "wide": [],
811 + "small": [],
812 + "docH": 1447,
813 + "errors": [],
814 + "file": "admin_login-430.png"
815 + },
816 + {
817 + "path": "/indicators",
818 + "width": 1280,
819 + "status": 200,
820 + "overflow": 0,
821 + "wide": [],
822 + "small": [],
823 + "docH": 22031,
824 + "errors": [],
825 + "file": "indicators-1280.png"
826 + },
827 + {
828 + "path": "/indicators?topic=health",
829 + "width": 1280,
830 + "status": 200,
831 + "overflow": 0,
832 + "wide": [],
833 + "small": [],
834 + "docH": 2484,
835 + "errors": [],
836 + "file": "indicators_topic_health-1280.png"
837 + },
838 + {
839 + "path": "/indicators/life-expectancy",
840 + "width": 1280,
841 + "status": 200,
842 + "overflow": 0,
843 + "wide": [
844 + "code. right=2017"
845 + ],
846 + "small": [],
847 + "docH": 4620,
848 + "errors": [],
849 + "file": "indicators_life-expectancy-1280.png"
850 + },
851 + {
852 + "path": "/indicators/gdp-per-capita",
853 + "width": 1280,
854 + "status": 200,
855 + "overflow": 0,
856 + "wide": [
857 + "code. right=2010"
858 + ],
859 + "small": [],
860 + "docH": 4642,
861 + "errors": [],
862 + "file": "indicators_gdp-per-capita-1280.png"
863 + },
864 + {
865 + "path": "/regions",
866 + "width": 1280,
867 + "status": 200,
868 + "overflow": 0,
869 + "wide": [],
870 + "small": [],
871 + "docH": 2396,
872 + "errors": [],
873 + "file": "regions-1280.png"
874 + },
875 + {
876 + "path": "/regions/oecd",
877 + "width": 1280,
878 + "status": 200,
879 + "overflow": 0,
880 + "wide": [],
881 + "small": [],
882 + "docH": 4762,
883 + "errors": [],
884 + "file": "regions_oecd-1280.png"
885 + },
886 + {
887 + "path": "/explore",
888 + "width": 1280,
889 + "status": 200,
890 + "overflow": 0,
891 + "wide": [],
892 + "small": [],
893 + "docH": 5543,
894 + "errors": [],
895 + "file": "explore-1280.png"
896 + },
897 + {
898 + "path": "/changes",
899 + "width": 1280,
900 + "status": 200,
901 + "overflow": 0,
902 + "wide": [],
903 + "small": [],
904 + "docH": 11502,
905 + "errors": [],
906 + "file": "changes-1280.png"
907 + },
908 + {
909 + "path": "/data",
910 + "width": 1280,
911 + "status": 200,
912 + "overflow": 0,
913 + "wide": [],
914 + "small": [],
915 + "docH": 2744,
916 + "errors": [],
917 + "file": "data-1280.png"
918 + },
919 + {
920 + "path": "/sources",
921 + "width": 1280,
922 + "status": 200,
923 + "overflow": 0,
924 + "wide": [],
925 + "small": [],
926 + "docH": 2075,
927 + "errors": [],
928 + "file": "sources-1280.png"
929 + },
930 + {
931 + "path": "/sources/worldbank",
932 + "width": 1280,
933 + "status": 200,
934 + "overflow": 0,
935 + "wide": [],
936 + "small": [],
937 + "docH": 12474,
938 + "errors": [],
939 + "file": "sources_worldbank-1280.png"
940 + },
941 + {
942 + "path": "/methodology",
943 + "width": 1280,
944 + "status": 200,
945 + "overflow": 0,
946 + "wide": [],
947 + "small": [],
948 + "docH": 7624,
949 + "errors": [],
950 + "file": "methodology-1280.png"
951 + },
952 + {
953 + "path": "/api",
954 + "width": 1280,
955 + "status": 200,
956 + "overflow": 0,
957 + "wide": [],
958 + "small": [],
959 + "docH": 6512,
960 + "errors": [],
961 + "file": "api-1280.png"
962 + },
963 + {
964 + "path": "/admin/login",
965 + "width": 1280,
966 + "status": 200,
967 + "overflow": 0,
968 + "wide": [],
969 + "small": [],
970 + "docH": 900,
971 + "errors": [],
972 + "file": "admin_login-1280.png"
973 + },
974 + {
975 + "path": "/indicators",
976 + "width": 1440,
977 + "status": 200,
978 + "overflow": 0,
979 + "wide": [],
980 + "small": [],
981 + "docH": 22031,
982 + "errors": [],
983 + "file": "indicators-1440.png"
984 + },
985 + {
986 + "path": "/indicators?topic=health",
987 + "width": 1440,
988 + "status": 200,
989 + "overflow": 0,
990 + "wide": [],
991 + "small": [],
992 + "docH": 2484,
993 + "errors": [],
994 + "file": "indicators_topic_health-1440.png"
995 + },
996 + {
997 + "path": "/indicators/life-expectancy",
998 + "width": 1440,
999 + "status": 200,
1000 + "overflow": 0,
1001 + "wide": [
1002 + "code. right=2097"
1003 + ],
1004 + "small": [],
1005 + "docH": 4633,
1006 + "errors": [],
1007 + "file": "indicators_life-expectancy-1440.png"
1008 + },
1009 + {
1010 + "path": "/indicators/gdp-per-capita",
1011 + "width": 1440,
1012 + "status": 200,
1013 + "overflow": 0,
1014 + "wide": [
1015 + "code. right=2090"
1016 + ],
1017 + "small": [],
1018 + "docH": 4679,
1019 + "errors": [],
1020 + "file": "indicators_gdp-per-capita-1440.png"
1021 + },
1022 + {
1023 + "path": "/regions",
1024 + "width": 1440,
1025 + "status": 200,
1026 + "overflow": 0,
1027 + "wide": [],
1028 + "small": [],
1029 + "docH": 2375,
1030 + "errors": [],
1031 + "file": "regions-1440.png"
1032 + },
1033 + {
1034 + "path": "/regions/oecd",
1035 + "width": 1440,
1036 + "status": 200,
1037 + "overflow": 0,
1038 + "wide": [],
1039 + "small": [],
1040 + "docH": 4762,
1041 + "errors": [],
1042 + "file": "regions_oecd-1440.png"
1043 + },
1044 + {
1045 + "path": "/explore",
1046 + "width": 1440,
1047 + "status": 200,
1048 + "overflow": 0,
1049 + "wide": [],
1050 + "small": [],
1051 + "docH": 5525,
1052 + "errors": [],
1053 + "file": "explore-1440.png"
1054 + },
1055 + {
1056 + "path": "/changes",
1057 + "width": 1440,
1058 + "status": 200,
1059 + "overflow": 0,
1060 + "wide": [],
1061 + "small": [],
1062 + "docH": 11502,
1063 + "errors": [],
1064 + "file": "changes-1440.png"
1065 + },
1066 + {
1067 + "path": "/data",
1068 + "width": 1440,
1069 + "status": 200,
1070 + "overflow": 0,
1071 + "wide": [],
1072 + "small": [],
1073 + "docH": 2744,
1074 + "errors": [],
1075 + "file": "data-1440.png"
1076 + },
1077 + {
1078 + "path": "/sources",
1079 + "width": 1440,
1080 + "status": 200,
1081 + "overflow": 0,
1082 + "wide": [],
1083 + "small": [],
1084 + "docH": 2075,
1085 + "errors": [],
1086 + "file": "sources-1440.png"
1087 + },
1088 + {
1089 + "path": "/sources/worldbank",
1090 + "width": 1440,
1091 + "status": 200,
1092 + "overflow": 0,
1093 + "wide": [],
1094 + "small": [],
1095 + "docH": 12474,
1096 + "errors": [],
1097 + "file": "sources_worldbank-1440.png"
1098 + },
1099 + {
1100 + "path": "/methodology",
1101 + "width": 1440,
1102 + "status": 200,
1103 + "overflow": 0,
1104 + "wide": [],
1105 + "small": [],
1106 + "docH": 7624,
1107 + "errors": [],
1108 + "file": "methodology-1440.png"
1109 + },
1110 + {
1111 + "path": "/api",
1112 + "width": 1440,
1113 + "status": 200,
1114 + "overflow": 0,
1115 + "wide": [],
1116 + "small": [],
1117 + "docH": 6512,
1118 + "errors": [],
1119 + "file": "api-1440.png"
1120 + },
1121 + {
1122 + "path": "/admin/login",
1123 + "width": 1440,
1124 + "status": 200,
1125 + "overflow": 0,
1126 + "wide": [],
1127 + "small": [],
1128 + "docH": 900,
1129 + "errors": [],
1130 + "file": "admin_login-1440.png"
1131 + },
1132 + {
1133 + "path": "/indicators/life-expectancy",
1134 + "width": 390,
1135 + "check": "touch",
1136 + "sliderBefore": "2024",
1137 + "sliderAfter": "1981",
1138 + "legendTitle": "Life expectancy, 1981",
1139 + "label": "🇨🇦Canada75.5 yrsOpen Canada →"
1140 + }
1141 +]
\ No newline at end of file
added apps/web/qa/screens/explore/sources-1280.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

modified apps/web/qa/screens/report.json +227 −299
@@ -5,17 +5,16 @@
5 5 "overflow": 0,
6 6 "wide": [
7 7 "li. right=357",
8 − "a.inline-flex.h-9.items-center right=357",
8 + "a.inline-flex.h-11.items-center right=357",
9 9 "li. right=501",
10 − "a.inline-flex.h-9.items-center right=501",
10 + "a.inline-flex.h-11.items-center right=501",
11 11 "li. right=585",
12 − "a.inline-flex.h-9.items-center right=585",
12 + "a.inline-flex.h-11.items-center right=585",
13 13 "li. right=690",
14 − "a.inline-flex.h-9.items-center right=690"
14 + "a.inline-flex.h-11.items-center right=690"
15 15 ],
16 16 "small": [
17 − "a \"Skip to content\" 1x1",
18 − "a \"API\" 22x32"
17 + "a \"Skip to content\" 1x1"
19 18 ],
20 19 "smallH": [
21 20 "a \"Skip to content\" h=1",
@@ -43,8 +42,7 @@
43 42 "overflow": 0,
44 43 "wide": [],
45 44 "small": [
46 − "a \"Skip to content\" 1x1",
47 − "a \"API\" 22x32"
45 + "a \"Skip to content\" 1x1"
48 46 ],
49 47 "smallH": [
50 48 "a \"Skip to content\" h=1",
@@ -63,23 +61,29 @@
63 61 "width": 320,
64 62 "overflow": 0,
65 63 "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"
64 + "li.snap-start right=342",
65 + "a.inline-flex.h-11.items-center right=342",
66 + "span.tnum.text-2xs.text-ink-3 right=330",
67 + "li.snap-start right=460",
68 + "a.inline-flex.h-11.items-center right=460",
69 + "span.tnum.text-2xs.text-ink-3 right=448",
70 + "li.snap-start right=546",
71 + "a.inline-flex.h-11.items-center right=546"
74 72 ],
75 73 "small": [
76 74 "a \"Skip to content\" 1x1",
77 − "a \"API\" 22x32"
75 + "a \"Open GDP\" 40x32",
76 + "a \"🇱🇺Luxembourg\" 32x21",
77 + "a \"🇸🇪Sweden\" 32x21",
78 + "a \"🇳🇷Nauru\" 32x21",
79 + "a \"🇳🇴Norway\" 32x21",
80 + "a \"🇨🇾Cyprus\" 32x21",
81 + "a \"🇩🇪Germany\" 32x21",
82 + "a \"🇳🇨New Caledonia\" 32x21",
83 + "a \"🇬🇮Gibraltar\" 32x21"
78 84 ],
79 85 "smallH": [
80 86 "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 87 "a \"🇱🇺Luxembourg\" h=21",
84 88 "a \"🇸🇪Sweden\" h=21",
85 89 "a \"🇳🇷Nauru\" h=21",
@@ -88,7 +92,9 @@
88 92 "a \"🇩🇪Germany\" h=21",
89 93 "a \"🇳🇨New Caledonia\" h=21",
90 94 "a \"🇬🇮Gibraltar\" h=21",
91 − "a \"🇺🇸 United States\" h=17"
95 + "a \"🇺🇸 United States\" h=17",
96 + "a \"contact@spboucher.ai\" h=15",
97 + "a \"MacLustr\" h=15"
92 98 ],
93 99 "tabBar": 57,
94 100 "bodyPad": 56,
@@ -101,33 +107,24 @@
101 107 "width": 320,
102 108 "overflow": 0,
103 109 "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"
110 + "li.snap-start right=399",
111 + "a.inline-flex.h-11.items-center right=399",
112 + "li.snap-start right=465",
113 + "a.inline-flex.h-11.items-center right=465",
114 + "li.snap-start right=542",
115 + "a.inline-flex.h-11.items-center right=542",
116 + "li.snap-start right=625",
117 + "a.inline-flex.h-11.items-center right=625"
112 118 ],
113 119 "small": [
114 − "a \"Skip to content\" 1x1",
115 − "a \"REER\" 40x20",
116 − "a \"API\" 22x32"
120 + "a \"Skip to content\" 1x1"
117 121 ],
118 122 "smallH": [
119 123 "a \"Skip to content\" h=1",
120 124 "a \"Countries\" h=15",
121 125 "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"
126 + "a \"contact@spboucher.ai\" h=15",
127 + "a \"MacLustr\" h=15"
131 128 ],
132 129 "tabBar": 57,
133 130 "bodyPad": 56,
@@ -141,17 +138,16 @@
141 138 "overflow": 0,
142 139 "wide": [
143 140 "li. right=501",
144 − "a.inline-flex.h-9.items-center right=501",
141 + "a.inline-flex.h-11.items-center right=501",
145 142 "li. right=585",
146 − "a.inline-flex.h-9.items-center right=585",
143 + "a.inline-flex.h-11.items-center right=585",
147 144 "li. right=690",
148 − "a.inline-flex.h-9.items-center right=690",
145 + "a.inline-flex.h-11.items-center right=690",
149 146 "li. right=846",
150 − "a.inline-flex.h-9.items-center right=846"
147 + "a.inline-flex.h-11.items-center right=846"
151 148 ],
152 149 "small": [
153 − "a \"Skip to content\" 1x1",
154 − "a \"API\" 22x32"
150 + "a \"Skip to content\" 1x1"
155 151 ],
156 152 "smallH": [
157 153 "a \"Skip to content\" h=1",
@@ -179,8 +175,7 @@
179 175 "overflow": 0,
180 176 "wide": [],
181 177 "small": [
182 − "a \"Skip to content\" 1x1",
183 − "a \"API\" 22x32"
178 + "a \"Skip to content\" 1x1"
184 179 ],
185 180 "smallH": [
186 181 "a \"Skip to content\" h=1",
@@ -199,22 +194,21 @@
199 194 "width": 360,
200 195 "overflow": 0,
201 196 "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"
197 + "li.snap-start right=460",
198 + "a.inline-flex.h-11.items-center right=460",
199 + "span.tnum.text-2xs.text-ink-3 right=448",
200 + "li.snap-start right=546",
201 + "a.inline-flex.h-11.items-center right=546",
202 + "span.tnum.text-2xs.text-ink-3 right=534",
203 + "li.snap-start right=636",
204 + "a.inline-flex.h-11.items-center right=636"
210 205 ],
211 206 "small": [
212 207 "a \"Skip to content\" 1x1",
213 − "a \"API\" 22x32"
208 + "a \"Open GDP\" 40x32"
214 209 ],
215 210 "smallH": [
216 211 "a \"Skip to content\" h=1",
217 − "a \"Internet users at a 10-year hi\" h=19",
218 212 "a \"Learning poverty turned positi\" h=19",
219 213 "a \"🇱🇺Luxembourg\" h=21",
220 214 "a \"🇸🇪Sweden\" h=21",
@@ -224,7 +218,8 @@
224 218 "a \"🇩🇪Germany\" h=21",
225 219 "a \"🇳🇨New Caledonia\" h=21",
226 220 "a \"🇬🇮Gibraltar\" h=21",
227 − "a \"🇺🇸 United States\" h=17"
221 + "a \"🇺🇸 United States\" h=17",
222 + "a \"contact@spboucher.ai\" h=15"
228 223 ],
229 224 "tabBar": 57,
230 225 "bodyPad": 56,
@@ -237,33 +232,24 @@
237 232 "width": 360,
238 233 "overflow": 0,
239 234 "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"
235 + "li.snap-start right=399",
236 + "a.inline-flex.h-11.items-center right=399",
237 + "li.snap-start right=465",
238 + "a.inline-flex.h-11.items-center right=465",
239 + "li.snap-start right=542",
240 + "a.inline-flex.h-11.items-center right=542",
241 + "li.snap-start right=625",
242 + "a.inline-flex.h-11.items-center right=625"
248 243 ],
249 244 "small": [
250 − "a \"Skip to content\" 1x1",
251 − "a \"REER\" 40x20",
252 − "a \"API\" 22x32"
245 + "a \"Skip to content\" 1x1"
253 246 ],
254 247 "smallH": [
255 248 "a \"Skip to content\" h=1",
256 249 "a \"Countries\" h=15",
257 250 "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"
251 + "a \"contact@spboucher.ai\" h=15",
252 + "a \"MacLustr\" h=15"
267 253 ],
268 254 "tabBar": 57,
269 255 "bodyPad": 56,
@@ -277,17 +263,16 @@
277 263 "overflow": 0,
278 264 "wide": [
279 265 "li. right=501",
280 − "a.inline-flex.h-9.items-center right=501",
266 + "a.inline-flex.h-11.items-center right=501",
281 267 "li. right=585",
282 − "a.inline-flex.h-9.items-center right=585",
268 + "a.inline-flex.h-11.items-center right=585",
283 269 "li. right=690",
284 − "a.inline-flex.h-9.items-center right=690",
270 + "a.inline-flex.h-11.items-center right=690",
285 271 "li. right=846",
286 − "a.inline-flex.h-9.items-center right=846"
272 + "a.inline-flex.h-11.items-center right=846"
287 273 ],
288 274 "small": [
289 − "a \"Skip to content\" 1x1",
290 − "a \"API\" 22x32"
275 + "a \"Skip to content\" 1x1"
291 276 ],
292 277 "smallH": [
293 278 "a \"Skip to content\" h=1",
@@ -315,8 +300,7 @@
315 300 "overflow": 0,
316 301 "wide": [],
317 302 "small": [
318 − "a \"Skip to content\" 1x1",
319 − "a \"API\" 22x32"
303 + "a \"Skip to content\" 1x1"
320 304 ],
321 305 "smallH": [
322 306 "a \"Skip to content\" h=1",
@@ -335,22 +319,21 @@
335 319 "width": 375,
336 320 "overflow": 0,
337 321 "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"
322 + "li.snap-start right=460",
323 + "a.inline-flex.h-11.items-center right=460",
324 + "span.tnum.text-2xs.text-ink-3 right=448",
325 + "li.snap-start right=546",
326 + "a.inline-flex.h-11.items-center right=546",
327 + "span.tnum.text-2xs.text-ink-3 right=534",
328 + "li.snap-start right=636",
329 + "a.inline-flex.h-11.items-center right=636"
346 330 ],
347 331 "small": [
348 332 "a \"Skip to content\" 1x1",
349 − "a \"API\" 22x32"
333 + "a \"Open GDP\" 40x32"
350 334 ],
351 335 "smallH": [
352 336 "a \"Skip to content\" h=1",
353 − "a \"Internet users at a 10-year hi\" h=19",
354 337 "a \"Learning poverty turned positi\" h=19",
355 338 "a \"🇱🇺Luxembourg\" h=21",
356 339 "a \"🇸🇪Sweden\" h=21",
@@ -360,7 +343,8 @@
360 343 "a \"🇩🇪Germany\" h=21",
361 344 "a \"🇳🇨New Caledonia\" h=21",
362 345 "a \"🇬🇮Gibraltar\" h=21",
363 − "a \"🇺🇸 United States\" h=17"
346 + "a \"🇺🇸 United States\" h=17",
347 + "a \"contact@spboucher.ai\" h=15"
364 348 ],
365 349 "tabBar": 57,
366 350 "bodyPad": 56,
@@ -373,33 +357,24 @@
373 357 "width": 375,
374 358 "overflow": 0,
375 359 "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"
360 + "li.snap-start right=399",
361 + "a.inline-flex.h-11.items-center right=399",
362 + "li.snap-start right=465",
363 + "a.inline-flex.h-11.items-center right=465",
364 + "li.snap-start right=542",
365 + "a.inline-flex.h-11.items-center right=542",
366 + "li.snap-start right=625",
367 + "a.inline-flex.h-11.items-center right=625"
384 368 ],
385 369 "small": [
386 − "a \"Skip to content\" 1x1",
387 − "a \"REER\" 40x20",
388 − "a \"API\" 22x32"
370 + "a \"Skip to content\" 1x1"
389 371 ],
390 372 "smallH": [
391 373 "a \"Skip to content\" h=1",
392 374 "a \"Countries\" h=15",
393 375 "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"
376 + "a \"contact@spboucher.ai\" h=15",
377 + "a \"MacLustr\" h=15"
403 378 ],
404 379 "tabBar": 57,
405 380 "bodyPad": 56,
@@ -413,17 +388,16 @@
413 388 "overflow": 0,
414 389 "wide": [
415 390 "li. right=501",
416 − "a.inline-flex.h-9.items-center right=501",
391 + "a.inline-flex.h-11.items-center right=501",
417 392 "li. right=585",
418 − "a.inline-flex.h-9.items-center right=585",
393 + "a.inline-flex.h-11.items-center right=585",
419 394 "li. right=690",
420 − "a.inline-flex.h-9.items-center right=690",
395 + "a.inline-flex.h-11.items-center right=690",
421 396 "li. right=846",
422 − "a.inline-flex.h-9.items-center right=846"
397 + "a.inline-flex.h-11.items-center right=846"
423 398 ],
424 399 "small": [
425 − "a \"Skip to content\" 1x1",
426 − "a \"API\" 22x32"
400 + "a \"Skip to content\" 1x1"
427 401 ],
428 402 "smallH": [
429 403 "a \"Skip to content\" h=1",
@@ -451,8 +425,7 @@
451 425 "overflow": 0,
452 426 "wide": [],
453 427 "small": [
454 − "a \"Skip to content\" 1x1",
455 − "a \"API\" 22x32"
428 + "a \"Skip to content\" 1x1"
456 429 ],
457 430 "smallH": [
458 431 "a \"Skip to content\" h=1",
@@ -471,22 +444,21 @@
471 444 "width": 390,
472 445 "overflow": 0,
473 446 "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"
447 + "li.snap-start right=460",
448 + "a.inline-flex.h-11.items-center right=460",
449 + "span.tnum.text-2xs.text-ink-3 right=448",
450 + "li.snap-start right=546",
451 + "a.inline-flex.h-11.items-center right=546",
452 + "span.tnum.text-2xs.text-ink-3 right=534",
453 + "li.snap-start right=636",
454 + "a.inline-flex.h-11.items-center right=636"
482 455 ],
483 456 "small": [
484 457 "a \"Skip to content\" 1x1",
485 − "a \"API\" 22x32"
458 + "a \"Open GDP\" 40x32"
486 459 ],
487 460 "smallH": [
488 461 "a \"Skip to content\" h=1",
489 − "a \"Internet users at a 10-year hi\" h=19",
490 462 "a \"Learning poverty turned positi\" h=19",
491 463 "a \"🇱🇺Luxembourg\" h=21",
492 464 "a \"🇸🇪Sweden\" h=21",
@@ -496,7 +468,8 @@
496 468 "a \"🇩🇪Germany\" h=21",
497 469 "a \"🇳🇨New Caledonia\" h=21",
498 470 "a \"🇬🇮Gibraltar\" h=21",
499 − "a \"🇺🇸 United States\" h=17"
471 + "a \"🇺🇸 United States\" h=17",
472 + "a \"contact@spboucher.ai\" h=15"
500 473 ],
501 474 "tabBar": 57,
502 475 "bodyPad": 56,
@@ -509,33 +482,24 @@
509 482 "width": 390,
510 483 "overflow": 0,
511 484 "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"
485 + "li.snap-start right=399",
486 + "a.inline-flex.h-11.items-center right=399",
487 + "li.snap-start right=465",
488 + "a.inline-flex.h-11.items-center right=465",
489 + "li.snap-start right=542",
490 + "a.inline-flex.h-11.items-center right=542",
491 + "li.snap-start right=625",
492 + "a.inline-flex.h-11.items-center right=625"
520 493 ],
521 494 "small": [
522 − "a \"Skip to content\" 1x1",
523 − "a \"REER\" 40x20",
524 − "a \"API\" 22x32"
495 + "a \"Skip to content\" 1x1"
525 496 ],
526 497 "smallH": [
527 498 "a \"Skip to content\" h=1",
528 499 "a \"Countries\" h=15",
529 500 "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"
501 + "a \"contact@spboucher.ai\" h=15",
502 + "a \"MacLustr\" h=15"
539 503 ],
540 504 "tabBar": 57,
541 505 "bodyPad": 56,
@@ -549,17 +513,16 @@
549 513 "overflow": 0,
550 514 "wide": [
551 515 "li. right=501",
552 − "a.inline-flex.h-9.items-center right=501",
516 + "a.inline-flex.h-11.items-center right=501",
553 517 "li. right=585",
554 − "a.inline-flex.h-9.items-center right=585",
518 + "a.inline-flex.h-11.items-center right=585",
555 519 "li. right=690",
556 − "a.inline-flex.h-9.items-center right=690",
520 + "a.inline-flex.h-11.items-center right=690",
557 521 "li. right=846",
558 − "a.inline-flex.h-9.items-center right=846"
522 + "a.inline-flex.h-11.items-center right=846"
559 523 ],
560 524 "small": [
561 − "a \"Skip to content\" 1x1",
562 − "a \"API\" 22x32"
525 + "a \"Skip to content\" 1x1"
563 526 ],
564 527 "smallH": [
565 528 "a \"Skip to content\" h=1",
@@ -587,8 +550,7 @@
587 550 "overflow": 0,
588 551 "wide": [],
589 552 "small": [
590 − "a \"Skip to content\" 1x1",
591 − "a \"API\" 22x32"
553 + "a \"Skip to content\" 1x1"
592 554 ],
593 555 "smallH": [
594 556 "a \"Skip to content\" h=1",
@@ -607,18 +569,18 @@
607 569 "width": 414,
608 570 "overflow": 0,
609 571 "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"
572 + "li.snap-start right=460",
573 + "a.inline-flex.h-11.items-center right=460",
574 + "span.tnum.text-2xs.text-ink-3 right=448",
575 + "li.snap-start right=546",
576 + "a.inline-flex.h-11.items-center right=546",
577 + "span.tnum.text-2xs.text-ink-3 right=534",
578 + "li.snap-start right=636",
579 + "a.inline-flex.h-11.items-center right=636"
618 580 ],
619 581 "small": [
620 582 "a \"Skip to content\" 1x1",
621 − "a \"API\" 22x32"
583 + "a \"Open GDP\" 40x32"
622 584 ],
623 585 "smallH": [
624 586 "a \"Skip to content\" h=1",
@@ -645,33 +607,24 @@
645 607 "width": 414,
646 608 "overflow": 0,
647 609 "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"
610 + "li.snap-start right=465",
611 + "a.inline-flex.h-11.items-center right=465",
612 + "li.snap-start right=542",
613 + "a.inline-flex.h-11.items-center right=542",
614 + "li.snap-start right=625",
615 + "a.inline-flex.h-11.items-center right=625",
616 + "li.snap-start right=696",
617 + "a.inline-flex.h-11.items-center right=696"
656 618 ],
657 619 "small": [
658 − "a \"Skip to content\" 1x1",
659 − "a \"REER\" 40x20",
660 − "a \"API\" 22x32"
620 + "a \"Skip to content\" 1x1"
661 621 ],
662 622 "smallH": [
663 623 "a \"Skip to content\" h=1",
664 624 "a \"Countries\" h=15",
665 625 "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"
626 + "a \"contact@spboucher.ai\" h=15",
627 + "a \"MacLustr\" h=15"
675 628 ],
676 629 "tabBar": 57,
677 630 "bodyPad": 56,
@@ -685,17 +638,16 @@
685 638 "overflow": 0,
686 639 "wide": [
687 640 "li. right=501",
688 − "a.inline-flex.h-9.items-center right=501",
641 + "a.inline-flex.h-11.items-center right=501",
689 642 "li. right=585",
690 − "a.inline-flex.h-9.items-center right=585",
643 + "a.inline-flex.h-11.items-center right=585",
691 644 "li. right=690",
692 − "a.inline-flex.h-9.items-center right=690",
645 + "a.inline-flex.h-11.items-center right=690",
693 646 "li. right=846",
694 − "a.inline-flex.h-9.items-center right=846"
647 + "a.inline-flex.h-11.items-center right=846"
695 648 ],
696 649 "small": [
697 − "a \"Skip to content\" 1x1",
698 − "a \"API\" 22x32"
650 + "a \"Skip to content\" 1x1"
699 651 ],
700 652 "smallH": [
701 653 "a \"Skip to content\" h=1",
@@ -723,8 +675,7 @@
723 675 "overflow": 0,
724 676 "wide": [],
725 677 "small": [
726 − "a \"Skip to content\" 1x1",
727 − "a \"API\" 22x32"
678 + "a \"Skip to content\" 1x1"
728 679 ],
729 680 "smallH": [
730 681 "a \"Skip to content\" h=1",
@@ -743,18 +694,18 @@
743 694 "width": 430,
744 695 "overflow": 0,
745 696 "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"
697 + "li.snap-start right=460",
698 + "a.inline-flex.h-11.items-center right=460",
699 + "span.tnum.text-2xs.text-ink-3 right=448",
700 + "li.snap-start right=546",
701 + "a.inline-flex.h-11.items-center right=546",
702 + "span.tnum.text-2xs.text-ink-3 right=534",
703 + "li.snap-start right=636",
704 + "a.inline-flex.h-11.items-center right=636"
754 705 ],
755 706 "small": [
756 707 "a \"Skip to content\" 1x1",
757 − "a \"API\" 22x32"
708 + "a \"Open GDP\" 40x32"
758 709 ],
759 710 "smallH": [
760 711 "a \"Skip to content\" h=1",
@@ -781,33 +732,24 @@
781 732 "width": 430,
782 733 "overflow": 0,
783 734 "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"
735 + "li.snap-start right=465",
736 + "a.inline-flex.h-11.items-center right=465",
737 + "li.snap-start right=542",
738 + "a.inline-flex.h-11.items-center right=542",
739 + "li.snap-start right=625",
740 + "a.inline-flex.h-11.items-center right=625",
741 + "li.snap-start right=696",
742 + "a.inline-flex.h-11.items-center right=696"
792 743 ],
793 744 "small": [
794 − "a \"Skip to content\" 1x1",
795 − "a \"REER\" 40x20",
796 − "a \"API\" 22x32"
745 + "a \"Skip to content\" 1x1"
797 746 ],
798 747 "smallH": [
799 748 "a \"Skip to content\" h=1",
800 749 "a \"Countries\" h=15",
801 750 "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"
751 + "a \"contact@spboucher.ai\" h=15",
752 + "a \"MacLustr\" h=15"
811 753 ],
812 754 "tabBar": 57,
813 755 "bodyPad": 56,
@@ -853,27 +795,27 @@
853 795 "a \"Skip to content\" 1x1",
854 796 "button \"All\" 38x36",
855 797 "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"
798 + "a \"A\" 32x24",
799 + "a \"B\" 32x24",
800 + "a \"C\" 32x24",
801 + "a \"D\" 32x24",
802 + "a \"E\" 32x24",
803 + "a \"F\" 32x24",
804 + "a \"G\" 32x24"
863 805 ],
864 806 "smallH": [
865 807 "a \"Skip to content\" h=1",
866 808 "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"
809 + "a \"A\" h=24",
810 + "a \"B\" h=24",
811 + "a \"C\" h=24",
812 + "a \"D\" h=24",
813 + "a \"E\" h=24",
814 + "a \"F\" h=24",
815 + "a \"G\" h=24",
816 + "a \"H\" h=24",
817 + "a \"I\" h=24",
818 + "a \"J\" h=24"
877 819 ],
878 820 "tabBar": 0,
879 821 "bodyPad": 0,
@@ -887,16 +829,17 @@
887 829 "overflow": 0,
888 830 "wide": [
889 831 "li.snap-start right=1311",
890 − "a.inline-flex.h-9.items-center right=1311",
832 + "a.inline-flex.h-11.items-center right=1311",
891 833 "span.tnum.text-2xs.text-ink-3 right=1301",
892 834 "li.snap-start right=1438",
893 − "a.inline-flex.h-9.items-center right=1438",
835 + "a.inline-flex.h-11.items-center right=1438",
894 836 "span.tnum.text-2xs.text-ink-3 right=1428",
895 837 "li.snap-start right=1516",
896 − "a.inline-flex.h-9.items-center right=1516"
838 + "a.inline-flex.h-11.items-center right=1516"
897 839 ],
898 840 "small": [
899 841 "a \"Skip to content\" 1x1",
842 + "a \"Open GDP\" 40x32",
900 843 "a \"API\" 22x32"
901 844 ],
902 845 "smallH": [
@@ -925,32 +868,24 @@
925 868 "overflow": 0,
926 869 "wide": [
927 870 "li.snap-start right=1347",
928 − "a.inline-flex.h-9.items-center right=1347",
871 + "a.inline-flex.h-11.items-center right=1347",
929 872 "li.snap-start right=1444",
930 − "a.inline-flex.h-9.items-center right=1444",
873 + "a.inline-flex.h-11.items-center right=1444",
931 874 "li.snap-start right=1521",
932 − "a.inline-flex.h-9.items-center right=1521",
875 + "a.inline-flex.h-11.items-center right=1521",
933 876 "li.snap-start right=1600",
934 − "a.inline-flex.h-9.items-center right=1600"
877 + "a.inline-flex.h-11.items-center right=1600"
935 878 ],
936 879 "small": [
937 880 "a \"Skip to content\" 1x1",
938 − "a \"REER\" 40x20",
939 881 "a \"API\" 22x32"
940 882 ],
941 883 "smallH": [
942 884 "a \"Skip to content\" h=1",
943 885 "a \"Countries\" h=15",
944 886 "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"
887 + "a \"contact@spboucher.ai\" h=15",
888 + "a \"MacLustr\" h=15"
954 889 ],
955 890 "tabBar": 0,
956 891 "bodyPad": 0,
@@ -996,27 +931,27 @@
996 931 "a \"Skip to content\" 1x1",
997 932 "button \"All\" 38x36",
998 933 "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"
934 + "a \"A\" 32x24",
935 + "a \"B\" 32x24",
936 + "a \"C\" 32x24",
937 + "a \"D\" 32x24",
938 + "a \"E\" 32x24",
939 + "a \"F\" 32x24",
940 + "a \"G\" 32x24"
1006 941 ],
1007 942 "smallH": [
1008 943 "a \"Skip to content\" h=1",
1009 944 "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"
945 + "a \"A\" h=24",
946 + "a \"B\" h=24",
947 + "a \"C\" h=24",
948 + "a \"D\" h=24",
949 + "a \"E\" h=24",
950 + "a \"F\" h=24",
951 + "a \"G\" h=24",
952 + "a \"H\" h=24",
953 + "a \"I\" h=24",
954 + "a \"J\" h=24"
1020 955 ],
1021 956 "tabBar": 0,
1022 957 "bodyPad": 0,
@@ -1030,16 +965,17 @@
1030 965 "overflow": 0,
1031 966 "wide": [
1032 967 "li.snap-start right=1458",
1033 − "a.inline-flex.h-9.items-center right=1458",
968 + "a.inline-flex.h-11.items-center right=1458",
1034 969 "span.tnum.text-2xs.text-ink-3 right=1448",
1035 970 "li.snap-start right=1536",
1036 − "a.inline-flex.h-9.items-center right=1536",
971 + "a.inline-flex.h-11.items-center right=1536",
1037 972 "span.tnum.text-2xs.text-ink-3 right=1526",
1038 973 "li.snap-start right=1642",
1039 − "a.inline-flex.h-9.items-center right=1642"
974 + "a.inline-flex.h-11.items-center right=1642"
1040 975 ],
1041 976 "small": [
1042 977 "a \"Skip to content\" 1x1",
978 + "a \"Open GDP\" 40x32",
1043 979 "a \"API\" 22x32"
1044 980 ],
1045 981 "smallH": [
@@ -1068,32 +1004,24 @@
1068 1004 "overflow": 0,
1069 1005 "wide": [
1070 1006 "li.snap-start right=1464",
1071 − "a.inline-flex.h-9.items-center right=1464",
1007 + "a.inline-flex.h-11.items-center right=1464",
1072 1008 "li.snap-start right=1541",
1073 − "a.inline-flex.h-9.items-center right=1541",
1009 + "a.inline-flex.h-11.items-center right=1541",
1074 1010 "li.snap-start right=1620",
1075 − "a.inline-flex.h-9.items-center right=1620",
1011 + "a.inline-flex.h-11.items-center right=1620",
1076 1012 "li.snap-start right=1731",
1077 − "a.inline-flex.h-9.items-center right=1731"
1013 + "a.inline-flex.h-11.items-center right=1731"
1078 1014 ],
1079 1015 "small": [
1080 1016 "a \"Skip to content\" 1x1",
1081 − "a \"REER\" 40x20",
1082 1017 "a \"API\" 22x32"
1083 1018 ],
1084 1019 "smallH": [
1085 1020 "a \"Skip to content\" h=1",
1086 1021 "a \"Countries\" h=15",
1087 1022 "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"
1023 + "a \"contact@spboucher.ai\" h=15",
1024 + "a \"MacLustr\" h=15"
1097 1025 ],
1098 1026 "tabBar": 0,
1099 1027 "bodyPad": 0,
added apps/web/qa/screens/seg/canada-1440-dna.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/canada-320-similar.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

added apps/web/qa/shots-explore.mjs +120 −0
@@ -0,0 +1,120 @@
1 +/**
2 + * QA for the explore-agent routes (indicators / regions / explore / changes / data / sources / methodology / api /
3 + * admin): screenshots at 320/375/390/430/1280/1440 → qa/screens/explore/, plus automated checks per render:
4 + * - horizontal overflow (scrollWidth > clientWidth) + the offending elements
5 + * - tap targets < 44 px (both dimensions) on phone widths
6 + * - console errors / page errors
7 + * - 404 status for unknown slugs
8 + * - the indicator map year slider reacts to touch (390 px, hasTouch) and a country tap shows the label
9 + * Run: node qa/shots-explore.mjs [BASE_URL] (Playwright from ~/Desktop/uqo-eval via absolute import)
10 + */
11 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
12 +import { mkdirSync, writeFileSync } from 'node:fs';
13 +
14 +const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';
15 +const OUT = new URL('./screens/explore/', import.meta.url).pathname;
16 +mkdirSync(OUT, { recursive: true });
17 +
18 +const PAGES = ['/indicators', '/indicators?topic=health', '/indicators/life-expectancy', '/indicators/gdp-per-capita', '/regions', '/regions/oecd', '/explore', '/changes', '/data', '/sources', '/sources/worldbank', '/methodology', '/api', '/admin/login'];
19 +const NOT_FOUND = ['/indicators/nope', '/regions/nope', '/sources/nope'];
20 +const WIDTHS = (process.env.WIDTHS ?? '320,375,390,430,1280,1440').split(',').map(Number);
21 +const report = [];
22 +
23 +const browser = await chromium.launch();
24 +
25 +// --- 404s
26 +for (const p of NOT_FOUND) {
27 + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
28 + const page = await ctx.newPage();
29 + const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' });
30 + report.push({ path: p, width: 390, status: res?.status(), check: '404' });
31 + console.log(`${String(res?.status()).padStart(4)} ${p}`);
32 + await ctx.close();
33 +}
34 +
35 +// --- screenshots + checks
36 +for (const width of WIDTHS) {
37 + const mobile = width < 768;
38 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'light' });
39 + const page = await ctx.newPage();
40 + const errors = [];
41 + page.on('pageerror', (e) => errors.push(String(e)));
42 + page.on('console', (m) => {
43 + if (m.type() === 'error') errors.push(m.text());
44 + });
45 + for (const path of PAGES) {
46 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 180_000 });
47 + await page.evaluate(() => document.fonts.ready);
48 + await page.waitForTimeout(500);
49 + const metrics = await page.evaluate(() => {
50 + const de = document.documentElement;
51 + const overflow = de.scrollWidth - de.clientWidth;
52 + const wide = [...document.querySelectorAll('body *')]
53 + .filter((el) => {
54 + const r = el.getBoundingClientRect();
55 + return r.right > de.clientWidth + 1 && r.width > 0 && getComputedStyle(el).position !== 'fixed';
56 + })
57 + .slice(0, 6)
58 + .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
59 + const small = [...document.querySelectorAll('a,button,[role=button],input,select,summary')]
60 + .filter((el) => {
61 + const r = el.getBoundingClientRect();
62 + if (r.width === 0 || r.height === 0) return false;
63 + const cs = getComputedStyle(el);
64 + if (cs.visibility === 'hidden') return false;
65 + if (el.closest('.sr-only') || el.classList.contains('sr-only')) return false;
66 + return r.height < 44 && r.width < 44;
67 + })
68 + .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)}`);
69 + return { overflow, wide, small, docH: de.scrollHeight };
70 + });
71 + const name = `${path.slice(1).replace(/[/?=]/g, '_')}-${width}.png`;
72 + await page.screenshot({ path: OUT + name, fullPage: true });
73 + const r = { path, width, status: res?.status(), overflow: metrics.overflow, wide: metrics.wide, small: mobile ? metrics.small.slice(0, 10) : [], docH: metrics.docH, errors: errors.splice(0), file: name };
74 + report.push(r);
75 + const flags = [r.overflow > 0 ? `OVERFLOW +${r.overflow}px` : 'ok', r.small.length ? `${r.small.length} small targets` : '', r.errors.length ? `${r.errors.length} console errors` : ''].filter(Boolean).join(' · ');
76 + console.log(`${String(width).padStart(4)} ${path.padEnd(34)} ${flags}`);
77 + if (r.overflow > 0) console.log(' wide:', r.wide.join(' | '));
78 + if (r.small.length) console.log(' small:', r.small.slice(0, 6).join(' | '));
79 + if (r.errors.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));
80 + }
81 + await ctx.close();
82 +}
83 +
84 +// --- touch interaction: year slider + country tap on the indicator map (390 px)
85 +{
86 + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
87 + const page = await ctx.newPage();
88 + await page.goto(BASE + '/indicators/life-expectancy', { waitUntil: 'networkidle', timeout: 180_000 });
89 + const slider = page.locator('input[type=range]').first();
90 + const before = await slider.evaluate((el) => el.getAttribute('aria-valuetext'));
91 + const box = await slider.boundingBox();
92 + await page.touchscreen.tap(box.x + box.width * 0.35, box.y + box.height / 2);
93 + await page.waitForTimeout(900);
94 + const after = await slider.evaluate((el) => el.getAttribute('aria-valuetext'));
95 + const legendTitle = await page.locator('#map figure title').first().textContent();
96 + const mapOk = before !== after && legendTitle?.includes(after ?? '');
97 + console.log(`slider touch: ${before} → ${after} · legend "${legendTitle}" · ${mapOk ? 'OK' : 'FAIL'}`);
98 + // country tap → sticky label with "Open" link
99 + const can = page.locator('#map path[aria-label^="Canada"]').first();
100 + const cb = await can.boundingBox();
101 + await page.touchscreen.tap(cb.x + cb.width / 2, cb.y + cb.height / 2);
102 + await page.waitForTimeout(400);
103 + const label = await page.locator('#map .pointer-events-none').first().textContent().catch(() => null);
104 + console.log(`country tap label: ${label ? label.slice(0, 60) : 'none'} · ${label?.includes('Canada') ? 'OK' : 'FAIL'}`);
105 + await page.screenshot({ path: OUT + 'indicators_life-expectancy-390-touch.png', fullPage: false });
106 + report.push({ path: '/indicators/life-expectancy', width: 390, check: 'touch', sliderBefore: before, sliderAfter: after, legendTitle, label });
107 + await ctx.close();
108 +}
109 +
110 +// --- dark mode sample
111 +{
112 + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'dark', isMobile: true, hasTouch: true });
113 + const page = await ctx.newPage();
114 + await page.goto(BASE + '/indicators/gdp-per-capita', { waitUntil: 'networkidle', timeout: 180_000 });
115 + await page.screenshot({ path: OUT + 'indicators_gdp-per-capita-390-dark.png', fullPage: true });
116 + await ctx.close();
117 +}
118 +
119 +await browser.close();
120 +writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
added apps/web/qa/shots-real.mjs +8 −0
@@ -0,0 +1,8 @@
1 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
2 +const BASE='http://localhost:8290';
3 +const shots=[['/',390,'home-m'],['/',1440,'home-d'],['/countries/canada',390,'can-m'],['/countries/canada',1440,'can-d'],['/countries/canada/economy',390,'eco-m'],['/countries',390,'list-m']];
4 +const b=await chromium.launch();
5 +for(const [p,w,n] of shots){const ctx=await b.newContext({viewport:{width:w,height:w<768?844:900},isMobile:w<768,hasTouch:w<768});const pg=await ctx.newPage();
6 +await pg.goto(BASE+p,{waitUntil:'networkidle'});await pg.waitForTimeout(800);
7 +await pg.screenshot({path:`/tmp/ca-${n}.png`,fullPage:true});const h=await pg.evaluate(()=>document.documentElement.scrollHeight);console.log(n,h);await ctx.close();}
8 +await b.close();
added apps/web/src/app/admin/(panel)/coverage/page.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { t } from '@/i18n';
2 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
3 +import { grouped } from '@/lib/format';
4 +import { AdminSection } from '@/components/admin/admin-ui';
5 +import { CoverageTable } from '@/components/admin/coverage-table';
6 +
7 +export const dynamic = 'force-dynamic';
8 +
9 +export default async function AdminCoveragePage() {
10 + try {
11 + const c = await adminApi.coverage();
12 + return (
13 + <AdminSection title={t('admin.coverage.title')} sub={t('admin.coverage.sub', { n: grouped(c.n_indicators) })}>
14 + <CoverageTable indicators={c.indicators} countries={c.countries} />
15 + </AdminSection>
16 + );
17 + } catch (e) {
18 + return <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{adminErrorMessage(e)}</p>;
19 + }
20 +}
added apps/web/src/app/admin/(panel)/issues/page.tsx +73 −0
@@ -0,0 +1,73 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
4 +import { grouped } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import { AdminSection, AdminTable, Pill, statusTone } from '@/components/admin/admin-ui';
7 +
8 +export const dynamic = 'force-dynamic';
9 +const SEVERITIES = ['error', 'warning', 'info'];
10 +
11 +export default async function AdminIssuesPage({ searchParams }: { searchParams: Promise<{ severity?: string; connector?: string; indicator?: string; code?: string; run_id?: string }> }) {
12 + const sp = await searchParams;
13 + try {
14 + const r = await adminApi.issues({ severity: sp.severity, connector: sp.connector, indicator: sp.indicator, code: sp.code, run_id: sp.run_id, limit: 300 });
15 + const link = (patch: Record<string, string | undefined>) => {
16 + const p = new URLSearchParams();
17 + for (const [k, v] of Object.entries({ ...sp, ...patch })) if (v) p.set(k, v);
18 + const s = p.toString();
19 + return `${routes.admin('issues')}${s ? `?${s}` : ''}`;
20 + };
21 + return (
22 + <>
23 + <AdminSection
24 + title={t('admin.issues.title')}
25 + sub={t('admin.issues.sub', { n: grouped(r.n) })}
26 + actions={
27 + <div className="flex flex-wrap gap-1 text-xs">
28 + <Link href={link({ severity: undefined })} className={`inline-flex h-8 items-center rounded-sm px-2 ${!sp.severity ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2'}`}>
29 + {t('admin.issues.all')}
30 + </Link>
31 + {SEVERITIES.map((s) => (
32 + <Link key={s} href={link({ severity: s })} className={`inline-flex h-8 items-center rounded-sm px-2 ${sp.severity === s ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2'}`}>
33 + {s}
34 + </Link>
35 + ))}
36 + </div>
37 + }
38 + >
39 + {r.summary.length ? (
40 + <ul className="mb-3 flex flex-wrap gap-1.5 text-xs">
41 + {r.summary.map((s) => (
42 + <li key={`${s.severity}-${s.code}`}>
43 + <Link href={link({ severity: s.severity, code: s.code })} className="inline-flex h-8 items-center gap-1.5 rounded-sm border border-rule px-2 hover:bg-surface-2">
44 + <Pill tone={statusTone(s.severity)}>{s.severity}</Pill>
45 + <span className="font-mono">{s.code}</span>
46 + <span className="tnum text-ink-3">{grouped(s.n)}</span>
47 + </Link>
48 + </li>
49 + ))}
50 + </ul>
51 + ) : null}
52 + <AdminTable
53 + rows={r.items}
54 + rowKey={(x, i) => `${x.run_id}-${x.indicator_id}-${x.country_id}-${i}`}
55 + empty={t('admin.issues.none')}
56 + columns={[
57 + { key: 'sev', header: t('admin.issues.severity'), cell: (x) => <Pill tone={statusTone(x.severity)}>{x.severity ?? t('admin.na')}</Pill> },
58 + { key: 'code', header: t('admin.issues.code'), cell: (x) => <span className="font-mono">{x.code}</span> },
59 + { key: 'c', header: t('admin.issues.connector'), cell: (x) => x.connector },
60 + { key: 'i', header: t('admin.issues.indicator'), cell: (x) => (x.indicator_id ? <Link href={routes.indicator(x.indicator_id)} className="font-mono text-accent hover:underline">{x.indicator_id}</Link> : t('admin.na')) },
61 + { key: 'cty', header: t('admin.issues.country'), cell: (x) => x.country_id ?? t('admin.na') },
62 + { key: 'p', header: t('admin.issues.period'), cell: (x) => x.period ?? t('admin.na') },
63 + { key: 'v', header: t('admin.issues.value'), numeric: true, cell: (x) => (x.value != null ? String(x.value) : t('admin.na')) },
64 + { key: 'm', header: t('admin.issues.message'), cell: (x) => <span className="text-ink-2">{x.message ?? ''}</span>, className: 'max-w-[28rem]' },
65 + ]}
66 + />
67 + </AdminSection>
68 + </>
69 + );
70 + } catch (e) {
71 + return <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{adminErrorMessage(e)}</p>;
72 + }
73 +}
added apps/web/src/app/admin/(panel)/layout.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import type { Metadata } from 'next';
2 +import { redirect } from 'next/navigation';
3 +import { t } from '@/i18n';
4 +import { isAdminAuthed } from '@/lib/admin-auth';
5 +import { routes } from '@/lib/site';
6 +import { AdminNav } from '@/components/admin/admin-nav';
7 +import { logoutAction } from '../actions';
8 +
9 +export const metadata: Metadata = { title: t('admin.title'), robots: { index: false, follow: false } };
10 +export const dynamic = 'force-dynamic';
11 +
12 +/** Every page in this group requires the admin cookie; otherwise → /admin/login. */
13 +export default async function AdminLayout({ children }: { children: React.ReactNode }) {
14 + if (!(await isAdminAuthed())) redirect(routes.adminLogin());
15 + return (
16 + <div className="pb-10 pt-4 md:pt-6">
17 + <div className="flex flex-wrap items-center justify-between gap-3 border-b border-rule pb-2">
18 + <div className="flex min-w-0 items-center gap-4">
19 + <span className="eyebrow">{t('admin.title')}</span>
20 + <AdminNav />
21 + </div>
22 + <form action={logoutAction}>
23 + <button type="submit" className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-2.5 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink">
24 + {t('admin.logout')}
25 + </button>
26 + </form>
27 + </div>
28 + <div className="min-w-0 text-sm">{children}</div>
29 + </div>
30 + );
31 +}
added apps/web/src/app/admin/(panel)/page.tsx +135 −0
@@ -0,0 +1,135 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
4 +import { ApiError } from '@/lib/api';
5 +import { grouped } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { AdminOverview } from '@/lib/types-explore';
8 +import { AdminSection, AdminTable, Kv, Pill, bytes, statusTone, ts } from '@/components/admin/admin-ui';
9 +import { clearCacheAction, refreshAction } from '../actions';
10 +
11 +export const dynamic = 'force-dynamic';
12 +
13 +function hb(o: AdminOverview, key: string): string | null {
14 + const h = o.scheduler.heartbeat;
15 + if (!h) return null;
16 + const v = h[key];
17 + return v == null ? null : String(v);
18 +}
19 +
20 +export default async function AdminOverviewPage({ searchParams }: { searchParams: Promise<{ ok?: string; err?: string }> }) {
21 + const sp = await searchParams;
22 + let o: AdminOverview | null = null;
23 + let error: string | null = null;
24 + try {
25 + o = await adminApi.overview();
26 + } catch (e) {
27 + error = e instanceof ApiError ? t('admin.unavailable', { status: e.status || 'network' }) + ` ${adminErrorMessage(e)}` : adminErrorMessage(e);
28 + }
29 +
30 + const actions = (
31 + <>
32 + <form action={refreshAction}>
33 + <button type="submit" className="inline-flex min-h-[36px] items-center rounded-sm bg-ink px-3 text-xs font-medium text-paper hover:bg-accent hover:text-accent-ink" title={t('admin.actions.refreshHint')}>
34 + {t('admin.actions.refresh')}
35 + </button>
36 + </form>
37 + <form action={clearCacheAction}>
38 + <button type="submit" className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-3 text-xs text-ink hover:bg-surface-2">
39 + {t('admin.actions.clearCache')}
40 + </button>
41 + </form>
42 + </>
43 + );
44 +
45 + return (
46 + <>
47 + {sp.ok ? <p className="mt-3 rounded-sm border border-up/40 px-3 py-2 text-xs text-up">{t('admin.actions.done', { msg: sp.ok })}</p> : null}
48 + {sp.err ? <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{t('admin.actions.failed', { msg: sp.err })}</p> : null}
49 + {error ? <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{error}</p> : null}
50 +
51 + {o ? (
52 + <>
53 + <AdminSection title={t('admin.overview.title')} actions={actions}>
54 + <div className="grid gap-6 md:grid-cols-3">
55 + <div>
56 + <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.snapshot')}</h3>
57 + <Kv
58 + rows={[
59 + ['status', <Pill key="s" tone={o.status === 'ok' ? 'ok' : 'warn'}>{o.status}</Pill>],
60 + ['run_id', o.meta?.run_id ?? null],
61 + ['built_at', ts(o.meta?.built_at ?? null)],
62 + ['schema', o.meta?.schema_version != null ? String(o.meta.schema_version) : null],
63 + ['build_duration_s', o.meta?.build_duration_s != null ? String(o.meta.build_duration_s) : null],
64 + ]}
65 + />
66 + </div>
67 + <div>
68 + <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.db')}</h3>
69 + <Kv rows={[['path', o.db.path], [t('admin.db.size'), o.db.exists ? bytes(o.db.size_bytes) : t('admin.db.missing')], ['data_dir', o.settings.data_dir], ['api', `${o.settings.api_host}:${o.settings.api_port}`]]} />
70 + </div>
71 + <div>
72 + <h3 className="mb-1 text-xs font-semibold text-ink-2">{t('admin.scheduler')}</h3>
73 + <Kv
74 + rows={[
75 + ['state', o.scheduler.pid == null ? t('admin.scheduler.unknown') : o.scheduler.alive ? <Pill key="a" tone="ok">{t('admin.scheduler.alive', { pid: o.scheduler.pid })}</Pill> : <Pill key="d" tone="bad">{t('admin.scheduler.dead', { pid: o.scheduler.pid })}</Pill>],
76 + [t('admin.scheduler.last'), ts(hb(o, 'last_run_at') ?? hb(o, 'last_run') ?? hb(o, 'finished_at'))],
77 + [t('admin.scheduler.next'), ts(hb(o, 'next_run_at') ?? hb(o, 'next_run'))],
78 + ['heartbeat', ts(hb(o, 'heartbeat_at') ?? hb(o, 'updated_at') ?? hb(o, 'at'))],
79 + ['refresh', t('admin.scheduler.refreshAt', { time: o.settings.refresh_at })],
80 + ]}
81 + />
82 + </div>
83 + </div>
84 + </AdminSection>
85 +
86 + <div className="grid gap-x-8 md:grid-cols-3">
87 + <AdminSection title={t('admin.counts')}>
88 + <Kv rows={Object.entries(o.counts ?? {}).map(([k, v]) => [k, grouped(v)] as [string, string])} />
89 + </AdminSection>
90 + <AdminSection title={t('admin.statuses')}>
91 + <Kv rows={Object.entries(o.observation_statuses ?? {}).map(([k, v]) => [k, grouped(v)] as [string, string])} />
92 + </AdminSection>
93 + <AdminSection title={t('admin.cache')}>
94 + <Kv rows={Object.entries(o.cache ?? {}).map(([k, v]) => [k, String(v)] as [string, string])} />
95 + </AdminSection>
96 + </div>
97 +
98 + <AdminSection title={t('admin.connectors.title')} sub={t('admin.connectors.sub')}>
99 + <AdminTable
100 + rows={o.connectors ?? []}
101 + rowKey={(r) => r.connector}
102 + columns={[
103 + { key: 'c', header: t('admin.connectors.connector'), cell: (r) => <Link href={`${routes.admin('runs')}?connector=${r.connector}`} className="font-medium text-ink hover:text-accent">{r.connector}</Link> },
104 + { key: 'n', header: t('admin.connectors.runs'), numeric: true, cell: (r) => grouped(r.n_runs) },
105 + { key: 'last', header: t('admin.connectors.last'), cell: (r) => ts(r.last_finished_at) },
106 + { key: 's', header: t('admin.connectors.status'), cell: (r) => <Pill tone={statusTone(r.last_status)}>{r.last_status ?? t('admin.na')}</Pill> },
107 + { key: 'ok', header: t('admin.connectors.ok'), numeric: true, cell: (r) => grouped(r.n_ok) },
108 + { key: 'f', header: t('admin.connectors.failed'), numeric: true, cell: (r) => (r.n_failed ? <span className="text-down">{grouped(r.n_failed)}</span> : '0') },
109 + { key: 'rows', header: t('admin.connectors.rows'), numeric: true, cell: (r) => (r.rows_valid != null ? grouped(r.rows_valid) : t('admin.na')) },
110 + { key: 'w', header: t('admin.connectors.warnings'), numeric: true, cell: (r) => (r.warnings != null ? grouped(r.warnings) : t('admin.na')) },
111 + { key: 'e', header: t('admin.connectors.errors'), numeric: true, cell: (r) => (r.errors ? <span className="text-down">{grouped(r.errors)}</span> : '0') },
112 + ]}
113 + />
114 + </AdminSection>
115 +
116 + <AdminSection title={t('admin.stale.title')} sub={t('admin.stale.sub')}>
117 + <AdminTable
118 + rows={o.sources ?? []}
119 + rowKey={(r) => r.source_id}
120 + columns={[
121 + { key: 's', header: t('admin.stale.source'), cell: (r) => <Link href={routes.source(r.source_id)} className="font-medium text-ink hover:text-accent">{r.source_id}</Link> },
122 + { key: 'n', header: t('admin.stale.observations'), numeric: true, cell: (r) => grouped(r.n_observations) },
123 + { key: 'u', header: t('admin.stale.updated'), cell: (r) => ts(r.source_updated_at) },
124 + { key: 'r', header: t('admin.stale.retrieved'), cell: (r) => ts(r.retrieved_at) },
125 + { key: 'st', header: t('admin.stale.n'), numeric: true, cell: (r) => (r.n_stale ? <span className="text-warn">{grouped(r.n_stale)}</span> : '0') },
126 + ]}
127 + />
128 + </AdminSection>
129 + </>
130 + ) : !error ? (
131 + <p className="py-6 text-xs text-ink-3">{t('admin.empty')}</p>
132 + ) : null}
133 + </>
134 + );
135 +}
added apps/web/src/app/admin/(panel)/raw/page.tsx +65 −0
@@ -0,0 +1,65 @@
1 +import { t } from '@/i18n';
2 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
3 +import { grouped } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import { AdminSection, AdminTable, Pill, bytes, statusTone, ts } from '@/components/admin/admin-ui';
6 +
7 +export const dynamic = 'force-dynamic';
8 +
9 +export default async function AdminRawPage({ searchParams }: { searchParams: Promise<{ run_id?: string }> }) {
10 + const { run_id } = await searchParams;
11 + let body: React.ReactNode = <p className="py-3 text-xs text-ink-3">{t('admin.raw.pick')}</p>;
12 + if (run_id) {
13 + try {
14 + const r = await adminApi.raw(run_id);
15 + body = (
16 + <>
17 + <AdminTable
18 + rows={r.runs}
19 + rowKey={(x, i) => `${x.connector}-${x.dataset}-${i}`}
20 + className="mb-4"
21 + columns={[
22 + { key: 'c', header: t('admin.connectors.connector'), cell: (x) => x.connector },
23 + { key: 'd', header: t('source.runs.dataset'), cell: (x) => <span className="break-all font-mono">{x.dataset}</span> },
24 + { key: 's', header: t('source.runs.status'), cell: (x) => <Pill tone={statusTone(x.status)}>{x.status ?? t('admin.na')}</Pill> },
25 + { key: 'p', header: t('admin.raw.file'), cell: (x) => <span className="break-all font-mono text-ink-2">{x.raw_path}</span> },
26 + { key: 'r', header: 'rows_raw', numeric: true, cell: (x) => (x.rows_raw != null ? grouped(x.rows_raw) : t('admin.na')) },
27 + ]}
28 + />
29 + <AdminTable
30 + rows={r.files}
31 + rowKey={(f) => f.path}
32 + empty={t('admin.raw.none')}
33 + columns={[
34 + { key: 'c', header: t('admin.connectors.connector'), cell: (f) => f.connector },
35 + { key: 'p', header: t('admin.raw.file'), cell: (f) => <span className={`break-all font-mono ${f.missing ? 'text-down' : 'text-ink'}`}>{f.path}{f.missing ? ` (${t('admin.raw.missing')})` : ''}</span> },
36 + { key: 's', header: t('admin.raw.size'), numeric: true, cell: (f) => bytes(f.size) },
37 + { key: 'm', header: t('admin.raw.modified'), cell: (f) => ts(f.modified_at) },
38 + ]}
39 + />
40 + </>
41 + );
42 + } catch (e) {
43 + body = <p className="rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{adminErrorMessage(e)}</p>;
44 + }
45 + }
46 + return (
47 + <AdminSection
48 + title={t('admin.raw.title')}
49 + sub={t('admin.raw.sub')}
50 + actions={
51 + <form action={routes.admin('raw')} method="get" className="flex items-center gap-1.5">
52 + <label className="text-xs text-ink-3" htmlFor="run_id">
53 + {t('admin.raw.runId')}
54 + </label>
55 + <input id="run_id" name="run_id" defaultValue={run_id ?? ''} placeholder="20260911T072322Z" className="h-9 w-48 rounded-sm border border-rule bg-surface px-2 font-mono text-xs outline-none focus:border-accent" />
56 + <button type="submit" className="inline-flex h-9 items-center rounded-sm border border-rule px-2.5 text-xs text-ink hover:bg-surface-2">
57 + {t('admin.raw.load')}
58 + </button>
59 + </form>
60 + }
61 + >
62 + {body}
63 + </AdminSection>
64 + );
65 +}
added apps/web/src/app/admin/(panel)/runs/page.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import Link from 'next/link';
2 +import { t } from '@/i18n';
3 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
4 +import { grouped } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import { AdminSection, AdminTable, Pill, statusTone, ts } from '@/components/admin/admin-ui';
7 +
8 +export const dynamic = 'force-dynamic';
9 +
10 +export default async function AdminRunsPage({ searchParams }: { searchParams: Promise<{ connector?: string; status?: string }> }) {
11 + const sp = await searchParams;
12 + try {
13 + const r = await adminApi.runs({ connector: sp.connector, status: sp.status, limit: 300 });
14 + const connectors = Array.from(new Set(r.items.map((x) => x.connector).filter((x): x is string => !!x))).sort();
15 + return (
16 + <AdminSection
17 + title={t('admin.runs.title')}
18 + sub={t('admin.runs.sub', { n: grouped(r.n) })}
19 + actions={
20 + <div className="flex flex-wrap gap-1 text-xs">
21 + <Link href={routes.admin('runs')} className={`inline-flex h-8 items-center rounded-sm px-2 ${!sp.connector ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2'}`}>
22 + {t('common.all')}
23 + </Link>
24 + {connectors.map((c) => (
25 + <Link key={c} href={`${routes.admin('runs')}?connector=${c}`} className={`inline-flex h-8 items-center rounded-sm px-2 ${sp.connector === c ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2'}`}>
26 + {c}
27 + </Link>
28 + ))}
29 + </div>
30 + }
31 + >
32 + <AdminTable
33 + rows={r.items}
34 + rowKey={(x, i) => `${x.run_id}-${x.dataset}-${i}`}
35 + columns={[
36 + { key: 'run', header: t('admin.runs.run'), cell: (x) => (x.run_id ? <Link href={`${routes.admin('raw')}?run_id=${encodeURIComponent(x.run_id)}`} className="font-mono text-accent hover:underline">{x.run_id}</Link> : t('admin.na')) },
37 + { key: 'c', header: t('admin.connectors.connector'), cell: (x) => x.connector },
38 + { key: 'd', header: t('source.runs.dataset'), cell: (x) => <span className="break-all font-mono">{x.dataset}</span>, className: 'max-w-[22rem]' },
39 + { key: 's', header: t('source.runs.status'), cell: (x) => <Pill tone={statusTone(x.status)}>{x.status ?? t('admin.na')}</Pill> },
40 + { key: 'st', header: t('source.runs.started'), cell: (x) => ts(x.started_at) },
41 + { key: 'rows', header: t('source.runs.rows'), numeric: true, cell: (x) => (x.rows_valid != null ? grouped(x.rows_valid) : t('admin.na')) },
42 + { key: 'w', header: t('source.runs.warnings'), numeric: true, cell: (x) => (x.warnings != null ? grouped(x.warnings) : t('admin.na')) },
43 + { key: 'e', header: t('source.runs.errors'), numeric: true, cell: (x) => (x.errors ? <span className="text-down">{grouped(x.errors)}</span> : '0') },
44 + { key: 'm', header: t('admin.issues.message'), cell: (x) => <span className="text-ink-2">{x.message}</span>, className: 'max-w-[24rem]' },
45 + ]}
46 + />
47 + </AdminSection>
48 + );
49 + } catch (e) {
50 + return <p className="mt-3 rounded-sm border border-down/40 px-3 py-2 text-xs text-down">{adminErrorMessage(e)}</p>;
51 + }
52 +}
added apps/web/src/app/admin/actions.ts +61 −0
@@ -0,0 +1,61 @@
1 +'use server';
2 +import { revalidatePath } from 'next/cache';
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +import { ADMIN_COOKIE, ADMIN_COOKIE_MAX_AGE, adminToken, cookieValueFor, isAdminAuthed, verifyToken } from '@/lib/admin-auth';
6 +import { adminApi, adminErrorMessage } from '@/lib/admin-api';
7 +import { routes } from '@/lib/site';
8 +
9 +const secure = process.env.NODE_ENV === 'production' && (process.env.NEXT_PUBLIC_SITE_URL ?? '').startsWith('https://');
10 +
11 +export async function loginAction(formData: FormData): Promise<void> {
12 + const input = String(formData.get('token') ?? '');
13 + const next = String(formData.get('next') ?? routes.admin());
14 + const token = adminToken();
15 + if (!token || !verifyToken(input)) redirect(`${routes.adminLogin()}?error=1`);
16 + const store = await cookies();
17 + store.set(ADMIN_COOKIE, cookieValueFor(token!), { httpOnly: true, sameSite: 'lax', secure, path: '/admin', maxAge: ADMIN_COOKIE_MAX_AGE });
18 + redirect(next.startsWith('/admin') ? next : routes.admin());
19 +}
20 +
21 +export async function logoutAction(): Promise<void> {
22 + const store = await cookies();
23 + store.delete(ADMIN_COOKIE);
24 + redirect(routes.adminLogin());
25 +}
26 +
27 +async function guard(): Promise<void> {
28 + if (!(await isAdminAuthed())) redirect(routes.adminLogin());
29 +}
30 +
31 +export async function refreshAction(): Promise<void> {
32 + await guard();
33 + let msg: string;
34 + let ok = true;
35 + try {
36 + const r = await adminApi.refresh();
37 + msg = `${r.signal} → pid ${r.pid} at ${r.sent_at}`;
38 + } catch (e) {
39 + ok = false;
40 + msg = adminErrorMessage(e);
41 + }
42 + revalidatePath(routes.admin());
43 + redirect(`${routes.admin()}?${ok ? 'ok' : 'err'}=${encodeURIComponent(msg)}`);
44 +}
45 +
46 +export async function clearCacheAction(): Promise<void> {
47 + await guard();
48 + let msg: string;
49 + let ok = true;
50 + try {
51 + const r = await adminApi.clearCache();
52 + msg = `cache cleared (${Object.entries(r.before)
53 + .map(([k, v]) => `${k}=${v}`)
54 + .join(', ')})`;
55 + } catch (e) {
56 + ok = false;
57 + msg = adminErrorMessage(e);
58 + }
59 + revalidatePath(routes.admin());
60 + redirect(`${routes.admin()}?${ok ? 'ok' : 'err'}=${encodeURIComponent(msg)}`);
61 +}
added apps/web/src/app/admin/login/page.tsx +37 −0
@@ -0,0 +1,37 @@
1 +import type { Metadata } from 'next';
2 +import { redirect } from 'next/navigation';
3 +import { t } from '@/i18n';
4 +import { isAdminAuthed, isAdminConfigured } from '@/lib/admin-auth';
5 +import { routes } from '@/lib/site';
6 +import { loginAction } from '../actions';
7 +
8 +export const metadata: Metadata = { title: t('admin.login.title'), robots: { index: false, follow: false } };
9 +export const dynamic = 'force-dynamic';
10 +
11 +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
12 + const sp = await searchParams;
13 + if (await isAdminAuthed()) redirect(routes.admin());
14 + const configured = isAdminConfigured();
15 + return (
16 + <div className="mx-auto max-w-sm py-16">
17 + <div className="eyebrow">{t('admin.title')}</div>
18 + <h1 className="display mt-1 text-3xl text-ink">{t('admin.login.title')}</h1>
19 + <p className="mt-2 text-sm text-ink-2">{t('admin.login.sub')}</p>
20 + {!configured ? (
21 + <p className="mt-4 rounded-sm border border-warn/40 px-3 py-2 text-sm text-warn">{t('admin.login.notConfigured')}</p>
22 + ) : (
23 + <form action={loginAction} className="mt-6 space-y-3">
24 + <input type="hidden" name="next" value={sp.next ?? routes.admin()} />
25 + <label className="block text-sm">
26 + <span className="mb-1 block text-xs font-medium text-ink-2">{t('admin.login.token')}</span>
27 + <input name="token" type="password" autoComplete="current-password" required autoFocus className="h-11 w-full rounded-sm border border-rule bg-surface px-3 font-mono text-sm outline-none focus:border-accent" />
28 + </label>
29 + {sp.error ? <p className="text-sm text-down">{t('admin.login.error')}</p> : null}
30 + <button type="submit" className="inline-flex h-11 w-full items-center justify-center rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink">
31 + {t('admin.login.submit')}
32 + </button>
33 + </form>
34 + )}
35 + </div>
36 + );
37 +}
added apps/web/src/app/api/page.tsx +253 −0
@@ -0,0 +1,253 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { API_URL } from '@/lib/api';
5 +import { SITE_URL, routes } from '@/lib/site';
6 +import { Section } from '@/components/data/section';
7 +import { CodeBlock } from '@/components/explore/copy-button';
8 +import { PageHeader } from '@/components/explore/page-header';
9 +import { FooterCredits } from '@/components/layout/site-footer';
10 +
11 +export const revalidate = 3600;
12 +
13 +export const metadata: Metadata = {
14 + title: t('apiPage.title'),
15 + description: t('apiPage.sub'),
16 + alternates: { canonical: routes.api() },
17 +};
18 +
19 +const BASE = `${SITE_URL}/api/v1`;
20 +
21 +const ENDPOINTS: Array<[string, string]> = [
22 + ['GET /health', t('apiPage.ep.health')],
23 + ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries')],
24 + ['GET /countries/{id}', t('apiPage.ep.country')],
25 + ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic')],
26 + ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries')],
27 + ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges')],
28 + ['GET /countries/{id}/events', t('apiPage.ep.countryEvents')],
29 + ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar')],
30 + ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights')],
31 + ['GET /countries/{id}/dna', t('apiPage.ep.countryDna')],
32 + ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload')],
33 + ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators')],
34 + ['GET /indicators/{slug}', t('apiPage.ep.indicator')],
35 + ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap')],
36 + ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend')],
37 + ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload')],
38 + ['GET /series?country=&indicator=', t('apiPage.ep.series')],
39 + ['GET /rankings?topic=', t('apiPage.ep.rankings')],
40 + ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking')],
41 + ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory')],
42 + ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare')],
43 + ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot')],
44 + ['GET /regions?kind=', t('apiPage.ep.regions')],
45 + ['GET /regions/{slug}?indicator=', t('apiPage.ep.region')],
46 + ['GET /search?q=&type=', t('apiPage.ep.search')],
47 + ['GET /home', t('apiPage.ep.home')],
48 + ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes')],
49 + ['GET /sources', t('apiPage.ep.sources')],
50 + ['GET /sources/{id}', t('apiPage.ep.source')],
51 + ['GET /methodology', t('apiPage.ep.methodology')],
52 +];
53 +
54 +const PROVENANCE_FIELDS: Array<[string, string]> = [
55 + ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'],
56 + ['dataset, series_code', 'Dataset and series inside the source (WDI NY.GDP.PCAP.CD, WEO NGDPDPC, OWID co2_per_capita…).'],
57 + ['retrieved_at', 'When the pipeline fetched the payload (UTC).'],
58 + ['source_updated_at', 'Last update advertised by the source; null when it publishes none.'],
59 + ['url', 'Deep link to the series at the source, with the country when the portal supports it.'],
60 + ['transform', 'Expression applied at normalisation (e.g. x*1e9), null if none.'],
61 + ['licence', 'Licence of the source for this series.'],
62 +];
63 +
64 +type Example = { key: string; path: string; pick: (j: unknown) => unknown };
65 +const EXAMPLES: Example[] = [
66 + {
67 + key: 'country',
68 + path: '/countries/canada',
69 + pick: (j) => {
70 + const d = j as { country: Record<string, unknown>; headline: Array<Record<string, unknown>> };
71 + const c = d.country;
72 + return { country: { id: c.id, name: c.name, capital: c.capital, region_name: c.region_name, income_name: c.income_name }, headline: d.headline.slice(0, 2).map((m) => ({ indicator: m.indicator, formatted: m.formatted, year: m.year, rank_world: m.rank_world, n_world: m.n_world, provenance: m.provenance })), '…': `${d.headline.length} headline metrics` };
73 + },
74 + },
75 + {
76 + key: 'series',
77 + path: '/countries/CAN/series/gdp-per-capita?from=2022',
78 + pick: (j) => {
79 + const d = j as { indicator: Record<string, unknown>; unit: unknown; values: Array<Record<string, unknown>>; stats: unknown; provenance: unknown };
80 + return { indicator: { slug: d.indicator.slug, name: d.indicator.name }, unit: d.unit, values: d.values.slice(0, 3).map((v) => ({ period: v.period, year: v.year, value: v.value, is_forecast: v.is_forecast, status: v.status })), '…': `${d.values.length} values`, stats: d.stats, provenance: d.provenance };
81 + },
82 + },
83 + {
84 + key: 'ranking',
85 + path: '/rankings/gdp-per-capita?limit=3',
86 + pick: (j) => {
87 + const d = j as { indicator: Record<string, unknown>; year_used: unknown; n: unknown; rows: Array<Record<string, unknown>> };
88 + return { indicator: d.indicator.slug, year_used: d.year_used, n: d.n, rows: d.rows.map((r) => ({ rank: r.rank, country: (r.country as Record<string, unknown>).name, formatted: r.formatted, change_1y: (r.change_1y as Record<string, unknown> | null)?.formatted ?? null })) };
89 + },
90 + },
91 + {
92 + key: 'map',
93 + path: '/indicators/life-expectancy/map?year=2023',
94 + pick: (j) => {
95 + const d = j as { year_used: unknown; n: unknown; legend: unknown; values: Record<string, number>; provenance: unknown };
96 + return { year_used: d.year_used, n: d.n, legend: d.legend, values: { CAN: d.values.CAN, JPN: d.values.JPN, NGA: d.values.NGA, '…': `${Object.keys(d.values).length} countries` }, provenance: d.provenance };
97 + },
98 + },
99 + {
100 + key: 'search',
101 + path: '/search?q=canada%20gdp&limit=3',
102 + pick: (j) => {
103 + const d = j as { q: unknown; n: unknown; hits: Array<Record<string, unknown>> };
104 + return { q: d.q, n: d.n, hits: d.hits.slice(0, 3).map((h) => ({ type: h.type, name: h.name, hint: h.hint, url: h.url })) };
105 + },
106 + },
107 + {
108 + key: 'changes',
109 + path: '/changes?limit=2',
110 + pick: (j) => {
111 + const d = j as { n: unknown; items: Array<Record<string, unknown>> };
112 + return { n: d.n, items: d.items.slice(0, 2).map((c) => ({ country: (c.country as Record<string, unknown> | null)?.name, indicator: (c.indicator as Record<string, unknown>).slug, kind: c.kind, year: c.year, severity: c.severity, headline: c.headline })) };
113 + },
114 + },
115 +];
116 +
117 +async function fetchExample(ex: Example): Promise<string | null> {
118 + try {
119 + const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1${ex.path}`, { headers: { accept: 'application/json' }, next: { revalidate: 3600 } });
120 + if (!res.ok) return null;
121 + return JSON.stringify(ex.pick(await res.json()), null, 2);
122 + } catch {
123 + return null;
124 + }
125 +}
126 +
127 +export default async function ApiPage() {
128 + const bodies = await Promise.all(EXAMPLES.map(fetchExample));
129 + return (
130 + <>
131 + <PageHeader title={t('apiPage.title')} lede={t('apiPage.sub')} />
132 + <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>
133 + <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3">
134 + <div>
135 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.base')}</dt>
136 + <dd className="mt-1 break-all font-mono text-ink">{BASE}</dd>
137 + </div>
138 + <div>
139 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.swagger')}</dt>
140 + <dd className="mt-1">
141 + <a href={routes.apiSwagger()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">
142 + {routes.apiSwagger()}
143 + </a>
144 + </dd>
145 + </div>
146 + <div>
147 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('apiPage.openapi')}</dt>
148 + <dd className="mt-1">
149 + <a href={routes.apiOpenapi()} className="inline-flex min-h-[32px] items-center font-mono text-accent hover:underline">
150 + {routes.apiOpenapi()}
151 + </a>
152 + </dd>
153 + </div>
154 + </dl>
155 +
156 + <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')} className="border-t-0">
157 + <table className="w-full border-collapse text-sm">
158 + <thead>
159 + <tr className="border-b border-rule text-left text-xs text-ink-3">
160 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.endpoint')}</th>
161 + <th scope="col" className="py-1.5 font-medium">{t('apiPage.describes')}</th>
162 + </tr>
163 + </thead>
164 + <tbody className="divide-y divide-rule">
165 + {ENDPOINTS.map(([ep, desc]) => (
166 + <tr key={ep}>
167 + <td className="py-2 pr-3 align-top">
168 + <code className="break-all font-mono text-xs text-ink">{ep}</code>
169 + </td>
170 + <td className="py-2 align-top text-ink-2">{desc}</td>
171 + </tr>
172 + ))}
173 + </tbody>
174 + </table>
175 + </Section>
176 +
177 + <Section id="examples" title={t('apiPage.examples.title')} subtitle={t('apiPage.examples.sub')}>
178 + <div className="space-y-8">
179 + {EXAMPLES.map((ex, i) => (
180 + <div key={ex.key} className="min-w-0">
181 + <h3 className="mb-2 text-sm font-semibold text-ink">{t(`apiPage.ex.${ex.key}` as 'apiPage.ex.country')}</h3>
182 + <CodeBlock code={`curl -s "${BASE}${ex.path}"`} lang="bash" />
183 + <div className="mt-2 text-2xs text-ink-3">{t('apiPage.examples.live')}</div>
184 + {bodies[i] ? <pre className="mt-1 max-h-80 overflow-auto rounded-sm border border-rule bg-surface-2/60 p-3 font-mono text-xs leading-relaxed text-ink-2">{bodies[i]}</pre> : <p className="mt-1 text-xs text-ink-3">{t('apiPage.examples.unavailable')}</p>}
185 + </div>
186 + ))}
187 + </div>
188 + </Section>
189 +
190 + <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>
191 + <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.provenance.text')}</p>
192 + <CodeBlock
193 + className="mt-3 max-w-3xl"
194 + lang="json"
195 + code={JSON.stringify(
196 + {
197 + value: 55697.66,
198 + period: '2025-01-01',
199 + year: 2025,
200 + formatted: '55.7k',
201 + status: 'imported',
202 + provenance: { source: 'worldbank', source_name: 'World Bank', dataset: 'WDI', series_code: 'NY.GDP.PCAP.CD', retrieved_at: '2026-09-11T06:49:30Z', source_updated_at: '2026-07-13T00:00:00Z', url: 'https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA', transform: null, licence: 'CC BY 4.0' },
203 + },
204 + null,
205 + 2,
206 + )}
207 + />
208 + <table className="mt-4 w-full max-w-3xl border-collapse text-sm">
209 + <thead>
210 + <tr className="border-b border-rule text-left text-xs text-ink-3">
211 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.field')}</th>
212 + <th scope="col" className="py-1.5 font-medium">{t('apiPage.meaning')}</th>
213 + </tr>
214 + </thead>
215 + <tbody className="divide-y divide-rule">
216 + {PROVENANCE_FIELDS.map(([f, d]) => (
217 + <tr key={f}>
218 + <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{f}</td>
219 + <td className="py-2 align-top text-ink-2">{d}</td>
220 + </tr>
221 + ))}
222 + </tbody>
223 + </table>
224 + </Section>
225 +
226 + <div className="grid gap-x-10 lg:grid-cols-2">
227 + <Section id="limits" title={t('apiPage.limits.title')}>
228 + <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.limits.text')}</p>
229 + </Section>
230 + <Section id="errors" title={t('apiPage.errors.title')}>
231 + <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.errors.text')}</p>
232 + </Section>
233 + </div>
234 +
235 + <Section id="licence" title={t('apiPage.licence.title')}>
236 + <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('apiPage.licence.text')}</p>
237 + <p className="mt-2 text-sm">
238 + <Link href={routes.sources()} className="text-accent hover:underline">
239 + {t('sources.title')} →
240 + </Link>
241 + <span className="mx-2 text-ink-3">·</span>
242 + <Link href={routes.methodology()} className="text-accent hover:underline">
243 + {t('method.title')} →
244 + </Link>
245 + </p>
246 + <div className="mt-6 text-sm text-ink-2">
247 + <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span>
248 + <FooterCredits className="mt-1 inline text-xs" />
249 + </div>
250 + </Section>
251 + </>
252 + );
253 +}
modified apps/web/src/app/apple-icon.png +0 −0

Binary file not shown.

added apps/web/src/app/changes/page.tsx +51 −0
@@ -0,0 +1,51 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { t } from '@/i18n';
4 +import { isNotBuilt } from '@/lib/api';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { formatDate } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { ChangesFeedResponse } from '@/lib/types-explore';
9 +import { ChangesFeed } from '@/components/changes/changes-feed';
10 +import { NotBuiltState } from '@/components/data/empty-state';
11 +import { PageHeader } from '@/components/explore/page-header';
12 +
13 +export const revalidate = 900;
14 +
15 +export const metadata: Metadata = {
16 + title: t('changes.title'),
17 + description: t('changes.sub'),
18 + alternates: { canonical: routes.changes() },
19 +};
20 +
21 +export default async function ChangesPage() {
22 + let data: ChangesFeedResponse | null = null;
23 + try {
24 + data = await apiExplore.changes({ limit: 100 });
25 + } catch (e) {
26 + if (!isNotBuilt(e)) throw e;
27 + }
28 + return (
29 + <>
30 + <PageHeader title={t('changes.title')} lede={t('changes.sub')} meta={data?.meta.built_at ? t('site.footer.refreshed', { date: formatDate(data.meta.built_at) }) : undefined} />
31 + {!data ? (
32 + <NotBuiltState />
33 + ) : (
34 + <>
35 + <details className="mb-2 border-y border-rule py-3 text-sm">
36 + <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink">
37 + <span aria-hidden>›</span> {t('changes.methodology')}
38 + </summary>
39 + <p className="mt-2 max-w-prose leading-relaxed text-ink-2">{t('changes.how')}</p>
40 + <p className="mt-2 text-xs">
41 + <Link href={`${routes.methodology()}#changes`} className="text-accent hover:underline">
42 + {t('site.footer.methodology')} →
43 + </Link>
44 + </p>
45 + </details>
46 + <ChangesFeed initial={data.items} kinds={data.kinds ?? []} limit={100} />
47 + </>
48 + )}
49 + </>
50 + );
51 +}
added apps/web/src/app/compare/[...slugs]/page.tsx +197 −0
@@ -0,0 +1,197 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { t } from '@/i18n';
4 +import { api, isNotBuilt, safe } from '@/lib/api';
5 +import { apiCompare } from '@/lib/api-compare';
6 +import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitCompareSlugs, type CompareState } from '@/lib/compare-state';
7 +import { routes } from '@/lib/site';
8 +import { topicById } from '@/lib/topics';
9 +import type { CountrySummary, IndicatorCard } from '@/lib/types';
10 +import { toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare';
11 +import { CompareChart } from '@/components/compare/compare-chart';
12 +import { CompareControls } from '@/components/compare/compare-controls';
13 +import { CustomPanel } from '@/components/compare/custom-panel';
14 +import { SnapshotTable } from '@/components/compare/snapshot-table';
15 +import { NotBuiltState } from '@/components/data/empty-state';
16 +import { Section } from '@/components/data/section';
17 +
18 +export const revalidate = 900;
19 +
20 +type Params = { slugs: string[] };
21 +type SP = Record<string, string | string[] | undefined>;
22 +const EAGER = 4; // charts rendered with server-fetched series; the rest fetch on scroll
23 +
24 +/** Resolve path segments (slugs or ISO3) against the country list, preserving order; null when the API is not built. */
25 +async function resolve(segments: string[]): Promise<{ countries: CountrySummary[]; all: CountrySummary[] } | 'not-built'> {
26 + let list;
27 + try {
28 + list = await api.countries();
29 + } catch (e) {
30 + if (isNotBuilt(e)) return 'not-built';
31 + throw e;
32 + }
33 + const bySlug = new Map<string, CountrySummary>();
34 + for (const c of list.items) {
35 + if (c.slug) bySlug.set(c.slug.toLowerCase(), c);
36 + bySlug.set(c.id.toLowerCase(), c);
37 + }
38 + const seen = new Set<string>();
39 + const countries: CountrySummary[] = [];
40 + for (const s of splitCompareSlugs(segments)) {
41 + const c = bySlug.get(s);
42 + if (c && !seen.has(c.id)) {
43 + seen.add(c.id);
44 + countries.push(c);
45 + }
46 + }
47 + return { countries, all: list.items };
48 +}
49 +
50 +function namesOf(cs: CountrySummary[]): string {
51 + return cs.map((c) => c.name ?? c.id).join(` ${t('compare.vs')} `);
52 +}
53 +
54 +export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {
55 + const [{ slugs }, sp] = await Promise.all([params, searchParams]);
56 + const r = await resolve(slugs);
57 + if (r === 'not-built' || r.countries.length < MIN_COMPARE_COUNTRIES) return { title: t('compare.notFound'), robots: { index: false } };
58 + const state = parseCompareState(sp);
59 + const names = namesOf(r.countries);
60 + const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab;
61 + const title = tabName ? `${names} — ${tabName} — ${t('compare.title')}` : t('compare.pageTitle', { names });
62 + const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' });
63 + const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`;
64 + return {
65 + title,
66 + description,
67 + alternates: { canonical },
68 + robots: state.tab === 'custom' ? { index: false, follow: true } : undefined,
69 + openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article', images: [{ url: routes.compareOg(r.countries.map((c) => c.slug ?? c.id)), width: 1200, height: 630, alt: names }] },
70 + twitter: { card: 'summary_large_image', title, description, images: [routes.compareOg(r.countries.map((c) => c.slug ?? c.id))] },
71 + };
72 +}
73 +
74 +export default async function CompareViewPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {
75 + const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]);
76 + const r = await resolve(segments);
77 + if (r === 'not-built') return <NotBuiltState />;
78 + if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound();
79 +
80 + let state: CompareState = parseCompareState(sp);
81 + // `?indicator=` without a tab → custom tab with that single indicator (the snapshot table always sets a tab).
82 + if (state.indicator && state.tab === 'snapshot') state = { ...state, tab: 'custom', indicators: Array.from(new Set([state.indicator, ...state.indicators])).slice(0, 6) };
83 +
84 + const countries: CountryLite[] = r.countries.map(toCountryLite);
85 + const all: CountryLite[] = r.all.filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));
86 + const ids = countries.map((c) => c.id);
87 + const slugs = countries.map((c) => c.slug);
88 + const names = namesOf(r.countries);
89 +
90 + // Data for the active tab
91 + const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null;
92 + const customSlugs = state.tab === 'custom' ? state.indicators : [];
93 + const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic }));
94 + const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: state.mode })) : Promise.resolve(null);
95 + const [snapshot, hero] = await Promise.all([snapshotP, heroP]);
96 +
97 + const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data));
98 + const maxYear = Math.max(new Date().getUTCFullYear(), ...rows.flatMap((row) => Object.values(row.values).map((v) => v.year ?? 0)));
99 +
100 + // Eager series for the first charts of a chart tab (one request, ≤ 8 indicators).
101 + let eager: Map<string, CompareSeries[]> = new Map();
102 + if (topic || state.tab === 'custom') {
103 + const eagerSlugs = rows
104 + .map((row) => row.indicator.slug)
105 + .filter((s) => s !== state.indicator)
106 + .slice(0, state.tab === 'custom' ? 6 : EAGER);
107 + if (eagerSlugs.length) {
108 + const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: state.mode }));
109 + if (bundle) {
110 + eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)]));
111 + }
112 + }
113 + }
114 +
115 + const heroCard: IndicatorCard | null = hero?.indicators[0] ?? rows.find((row) => row.indicator.slug === state.indicator)?.indicator ?? null;
116 + const heroRow = rows.find((row) => row.indicator.slug === state.indicator) ?? null;
117 + const gridRows = rows.filter((row) => row.indicator.slug !== state.indicator);
118 + const downloadIndicators = rows.map((row) => row.indicator.slug).slice(0, 40);
119 + const downloadHref = downloadIndicators.length ? routes.compareDownload(ids, downloadIndicators, { from: state.from, to: state.to }) : null;
120 + const topicDef = topic ? topicById(topic) : null;
121 + const tabLabel = state.tab === 'snapshot' ? t('compare.tab.snapshot') : state.tab === 'custom' ? t('compare.tab.custom') : (topicDef?.name ?? state.tab);
122 +
123 + return (
124 + <>
125 + <header className="pb-3 pt-5 md:pt-8">
126 + <div className="eyebrow">{t('compare.title')}</div>
127 + <h1 className="display mt-1 text-2xl leading-tight text-ink md:text-4xl">
128 + {r.countries.map((c, i) => (
129 + <span key={c.id}>
130 + {i > 0 ? <span className="mx-1.5 text-ink-3">{t('compare.vs')}</span> : null}
131 + <span aria-hidden>{c.flag} </span>
132 + {c.name}
133 + </span>
134 + ))}
135 + </h1>
136 + <p className="sr-only">{t('compare.headingTab', { names, tab: tabLabel })}</p>
137 + <p className="mt-2 text-xs text-ink-3">{t('compare.colourNote')}</p>
138 + </header>
139 +
140 + <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} />
141 +
142 + {state.tab === 'snapshot' ? (
143 + <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className="border-t-0">
144 + <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />
145 + </Section>
146 + ) : null}
147 +
148 + {state.tab !== 'snapshot' ? (
149 + <>
150 + {state.tab === 'custom' ? (
151 + <Section id="custom" title={t('compare.custom.title')} subtitle={t('compare.custom.sub', { max: 6 })} className="border-t-0 pb-4 md:pb-6">
152 + <CustomPanel selected={rows.map((row) => row.indicator)} state={state} />
153 + </Section>
154 + ) : null}
155 +
156 + {state.indicator && heroCard ? (
157 + <div className={state.tab === 'custom' ? 'pb-6' : 'pb-6 pt-6 md:pt-8'}>
158 + <CompareChart indicator={heroCard} countries={countries} state={state} initial={hero?.series ?? null} snapshot={heroRow?.values ?? null} hero maxYear={maxYear} />
159 + </div>
160 + ) : null}
161 +
162 + {topic ? (
163 + <Section id={`charts-${topic}`} title={t('compare.charts.title', { topic: topicDef?.name ?? topic })} subtitle={gridRows.length ? t('compare.charts.sub', { n: rows.length }) : undefined} className={state.indicator ? undefined : 'border-t-0'}>
164 + {gridRows.length === 0 ? (
165 + <p className="py-6 text-sm text-ink-3">{t('compare.charts.none', { topic: (topicDef?.short ?? topic).toLowerCase() })}</p>
166 + ) : (
167 + <ChartGrid rows={gridRows} countries={countries} state={state} eager={eager} maxYear={maxYear} />
168 + )}
169 + </Section>
170 + ) : null}
171 +
172 + {state.tab === 'custom' && gridRows.length ? (
173 + <div className="pb-10">
174 + <ChartGrid rows={gridRows} countries={countries} state={state} eager={eager} maxYear={maxYear} />
175 + </div>
176 + ) : null}
177 +
178 + {rows.length ? (
179 + <Section id="snapshot-tab" title={t('compare.snapshot.title')} subtitle={topicDef ? t('compare.snapshot.subTopic', { topic: topicDef.name }) : t('compare.snapshot.sub')} level={3}>
180 + <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />
181 + </Section>
182 + ) : null}
183 + </>
184 + ) : null}
185 + </>
186 + );
187 +}
188 +
189 +function ChartGrid({ rows, countries, state, eager, maxYear }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; state: CompareState; eager: Map<string, CompareSeries[]>; maxYear: number }) {
190 + return (
191 + <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2">
192 + {rows.map((row) => (
193 + <CompareChart key={row.indicator.slug} indicator={row.indicator} countries={countries} state={state} initial={eager.get(row.indicator.slug)} snapshot={row.values} maxYear={maxYear} />
194 + ))}
195 + </div>
196 + );
197 +}
added apps/web/src/app/compare/og/route.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import { ImageResponse } from 'next/og';
2 +import type { NextRequest } from 'next/server';
3 +import { t } from '@/i18n';
4 +import { api, safe } from '@/lib/api';
5 +import { splitCompareSlugs } from '@/lib/compare-state';
6 +import { OG_INK2, OG_INK3, OG_RULE, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from '../../og-shared';
7 +
8 +export const revalidate = 3600;
9 +
10 +/**
11 + * Social image for /compare/a/b/c — `GET /compare/og?slugs=canada,united-states,france`.
12 + * (A file-convention `opengraph-image` cannot live under a catch-all segment, so the page's metadata points here.)
13 + */
14 +export async function GET(req: NextRequest) {
15 + const raw = req.nextUrl.searchParams.get('slugs') ?? '';
16 + const list = await safe(api.countries());
17 + const bySlug = new Map((list?.items ?? []).flatMap((c) => [[c.slug ?? '', c] as const, [c.id.toLowerCase(), c] as const]));
18 + const wanted = splitCompareSlugs(raw.split('/'));
19 + const countries = wanted.map((s) => bySlug.get(s)).filter((c): c is NonNullable<typeof c> => !!c);
20 + const names = countries.length ? countries.map((c) => c.name ?? c.id) : wanted.map((s) => s.replace(/-/g, ' '));
21 + const flagSize = countries.length > 5 ? 84 : countries.length > 3 ? 108 : 132;
22 + const nameSize = names.join('').length > 40 ? 40 : 56;
23 + return new ImageResponse(
24 + (
25 + <OgFrame>
26 + <OgWordmark size={28} />
27 + <div style={{ display: 'flex', alignItems: 'center', gap: 20, marginTop: 44, flexWrap: 'wrap' }}>
28 + {countries.map((c) => (
29 + <div key={c.id} style={{ fontSize: flagSize, lineHeight: 1, display: 'flex' }}>
30 + {c.flag ?? ''}
31 + </div>
32 + ))}
33 + </div>
34 + <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', marginTop: 28, fontSize: nameSize, fontWeight: 600, letterSpacing: -1, lineHeight: 1.1 }}>
35 + {names.map((n, i) => (
36 + <div key={n} style={{ display: 'flex', alignItems: 'baseline' }}>
37 + {i > 0 ? <span style={{ color: OG_INK3, fontSize: nameSize * 0.6, fontWeight: 400, margin: '0 14px' }}>{t('compare.vs')}</span> : null}
38 + <span>{n}</span>
39 + </div>
40 + ))}
41 + </div>
42 + <div style={{ display: 'flex', marginTop: 'auto', paddingTop: 20, borderTop: `1px solid ${OG_RULE}`, fontSize: 24, color: OG_INK2 }}>
43 + {t('compare.title')} · {t('home.compare.sub')}
44 + </div>
45 + <div style={{ position: 'absolute', right: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
46 + </OgFrame>
47 + ),
48 + { width: OG_SIZE_W, height: OG_SIZE_H },
49 + );
50 +}
added apps/web/src/app/compare/page.tsx +45 −0
@@ -0,0 +1,45 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { api, isNotBuilt, safe } from '@/lib/api';
4 +import { routes } from '@/lib/site';
5 +import { toCountryLite, type CountryLite } from '@/lib/types-compare';
6 +import { CompareBuilder } from '@/components/compare/compare-builder';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +
9 +export const revalidate = 900;
10 +
11 +export const metadata: Metadata = {
12 + title: t('compare.metaTitle'),
13 + description: t('compare.description'),
14 + alternates: { canonical: routes.compare() },
15 + openGraph: { title: `${t('compare.metaTitle')} — ${t('site.name')}`, description: t('compare.description'), url: routes.compare(), type: 'website' },
16 +};
17 +
18 +/** /compare — the builder: typeahead, ordered chips, presets, "Compare" → /compare/a/b/c. */
19 +export default async function ComparePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
20 + const sp = await searchParams;
21 + let list;
22 + try {
23 + list = await api.countries();
24 + } catch (e) {
25 + if (isNotBuilt(e)) return <NotBuiltState />;
26 + throw e;
27 + }
28 + const countries: CountryLite[] = list.items.filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));
29 + const similar = await safe(api.countrySimilar('CAN', 'overall', 6));
30 + const peers = (similar?.peers ?? []).map((p) => toCountryLite(p.country));
31 + const initial = String(Array.isArray(sp.c) ? sp.c[0] : sp.c ?? '')
32 + .split(',')
33 + .map((s) => s.trim().toLowerCase())
34 + .filter(Boolean);
35 +
36 + return (
37 + <>
38 + <header className="pb-4 pt-6 md:pt-10">
39 + <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('compare.title')}</h1>
40 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('compare.sub')}</p>
41 + </header>
42 + <CompareBuilder countries={countries} peers={peers} initial={initial} />
43 + </>
44 + );
45 +}
modified apps/web/src/app/countries/[slug]/[topic]/page.tsx +4 −3
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
4 4 import { t } from '@/i18n';
5 5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6 6 import { routes } from '@/lib/site';
7 +import { regionShort } from '@/lib/regions';
7 8 import { TOPICS, isTopicId, topicById } from '@/lib/topics';
8 9 import type { CountryTopicResponse, MetricValue, SeriesResponse } from '@/lib/types';
9 10 import { IndicatorRow } from '@/components/country/indicator-row';
@@ -91,10 +92,10 @@ export default async function CountryTopicPage({ params }: { params: Promise<Par
91 92 const rows = block.indicators.filter((m) => m.has_data);
92 93 if (rows.length === 0) return null;
93 94 return (
94 − <Section key={block.subtopic} id={`sub-${block.subtopic.toLowerCase().replace(/\W+/g, '-')}`} title={block.subtopic} level={3} tight className="pb-2">
95 + <Section key={block.subtopic} id={`sub-${block.subtopic.toLowerCase().replace(/\W+/g, '-')}`} title={block.subtopic} level={3} tight className="pb-2 pt-6">
95 96 <div>
96 97 {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 + <IndicatorRow key={m.indicator} metric={m} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} series={seriesById.get(m.indicator)} eager={seriesById.has(m.indicator)} />
98 99 ))}
99 100 </div>
100 101 </Section>
@@ -125,7 +126,7 @@ export default async function CountryTopicPage({ params }: { params: Promise<Par
125 126 <ul className="flex flex-wrap gap-1.5">
126 127 {others.map((tp) => (
127 128 <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 + <Link href={routes.countryTopic(countryRef.slug, tp.id)} className="inline-flex h-11 items-center rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5">
129 130 {tp.short}
130 131 </Link>
131 132 </li>
modified apps/web/src/app/countries/[slug]/page.tsx +2 −1
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
4 4 import { t } from '@/i18n';
5 5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6 6 import { routes } from '@/lib/site';
7 +import { regionShort } from '@/lib/regions';
7 8 import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';
8 9 import type { CountryResponse } from '@/lib/types';
9 10 import { DnaRadial } from '@/components/charts/dna-radial';
@@ -79,7 +80,7 @@ export default async function CountryPage({ params }: { params: Promise<Params>
79 80 <MetricGrid>
80 81 {data.headline.map((m) => {
81 82 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 + return <Metric key={m.indicator} metric={m} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} href={topic ? routes.countryIndicator(c.slug ?? slug, topic, m.indicator) : null} />;
83 84 })}
84 85 </MetricGrid>
85 86 </Section>
added apps/web/src/app/explore/page.tsx +159 −0
@@ -0,0 +1,159 @@
1 +import { ArrowRight } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { api, isNotBuilt, safe } from '@/lib/api';
6 +import { apiExplore } from '@/lib/api-explore';
7 +import { formatValue, grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import { TOPICS } from '@/lib/topics';
10 +import type { RankingResponse } from '@/lib/types';
11 +import type { RegionResponse } from '@/lib/types-explore';
12 +import { ChangesFeed } from '@/components/changes/changes-feed';
13 +import { NotBuiltState } from '@/components/data/empty-state';
14 +import { Section } from '@/components/data/section';
15 +import { EntityPickerNav } from '@/components/explore/entity-picker-nav';
16 +import { PageHeader } from '@/components/explore/page-header';
17 +import { SimilarityPlayground } from '@/components/explore/similarity-playground';
18 +import { RegionChips } from '@/components/home/region-chips';
19 +
20 +export const revalidate = 900;
21 +
22 +export const metadata: Metadata = {
23 + title: t('explore.title'),
24 + description: t('explore.sub'),
25 + alternates: { canonical: routes.explore() },
26 +};
27 +
28 +type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' };
29 +type GroupQ = { kind: 'compare'; key: string; group: string };
30 +const QUESTIONS: Array<RankQ | GroupQ> = [
31 + { kind: 'ranking', key: 'age', indicator: 'median-age' },
32 + { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' },
33 + { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' },
34 + { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' },
35 + { kind: 'ranking', key: 'life', indicator: 'life-expectancy' },
36 + { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' },
37 + { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' },
38 + { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' },
39 + { kind: 'ranking', key: 'internet', indicator: 'internet-users' },
40 + { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' },
41 + { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' },
42 + { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' },
43 + { kind: 'compare', key: 'g7', group: 'g7' },
44 + { kind: 'compare', key: 'brics', group: 'brics' },
45 + { kind: 'compare', key: 'nordic', group: 'nordic-countries' },
46 +];
47 +
48 +export default async function ExplorePage() {
49 + let notBuilt = false;
50 + let indicatorsCount = 260;
51 + try {
52 + indicatorsCount = (await apiExplore.indicators()).n;
53 + } catch (e) {
54 + if (isNotBuilt(e)) notBuilt = true;
55 + else throw e;
56 + }
57 + if (notBuilt) {
58 + return (
59 + <>
60 + <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
61 + <NotBuiltState />
62 + </>
63 + );
64 + }
65 +
66 + const [changes, ...answers] = await Promise.all([
67 + safe(apiExplore.changes({ limit: 30 })),
68 + ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))),
69 + ]);
70 +
71 + return (
72 + <>
73 + <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
74 +
75 + <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0">
76 + <div className="max-w-xl">
77 + <EntityPickerNav type="country" placeholder={t('explore.country.search')} />
78 + </div>
79 + <div className="mt-4">
80 + <RegionChips />
81 + </div>
82 + </Section>
83 +
84 + <Section id="topic" title={t('explore.topic.title')} subtitle={t('explore.topic.sub', { n: grouped(indicatorsCount) })}>
85 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
86 + {TOPICS.map((tp) => (
87 + <li key={tp.id} className="border-t border-rule">
88 + <Link href={routes.indicators(tp.id)} className="group flex min-h-[56px] flex-col justify-center py-2.5">
89 + <span className="text-sm font-semibold text-ink group-hover:text-accent">{tp.name}</span>
90 + <span className="mt-0.5 text-xs text-ink-2">{tp.blurb}</span>
91 + </Link>
92 + </li>
93 + ))}
94 + </ul>
95 + </Section>
96 +
97 + <Section id="question" title={t('explore.question.title')} subtitle={t('explore.question.sub')}>
98 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
99 + {QUESTIONS.map((q, i) => {
100 + const a = answers[i] ?? null;
101 + const title = t(`explore.q.${q.key}` as 'explore.q.age');
102 + const hint = t(`explore.q.${q.key}.hint` as 'explore.q.age.hint');
103 + if (q.kind === 'ranking') {
104 + const r = a as RankingResponse | null;
105 + const top = r?.rows[0];
106 + if (!top) return null; // no live figure → no static filler
107 + return (
108 + <li key={q.key} className="border-t border-rule">
109 + <Link href={routes.ranking(q.indicator)} className="group flex min-h-[84px] flex-col justify-center py-3">
110 + <span className="flex items-start justify-between gap-2">
111 + <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
112 + <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
113 + </span>
114 + <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
115 + <span className="tnum mt-1.5 text-xs text-ink-2">
116 + <span aria-hidden>{top.country.flag} </span>
117 + {t('explore.question.leader', { country: top.country.name ?? top.country.id, value: formatValue(top.value, r.indicator), year: top.year ?? r.year_used ?? '' })}
118 + </span>
119 + </Link>
120 + </li>
121 + );
122 + }
123 + const g = a as RegionResponse | null;
124 + if (!g || g.members.length < 2) return null;
125 + const slugs = [...g.members]
126 + .sort((x, y) => (y.values.population?.value ?? 0) - (x.values.population?.value ?? 0))
127 + .slice(0, 8)
128 + .map((m) => m.slug ?? m.id);
129 + const gdp = g.aggregates.gdp;
130 + return (
131 + <li key={q.key} className="border-t border-rule">
132 + <Link href={routes.compare(...slugs)} className="group flex min-h-[84px] flex-col justify-center py-3">
133 + <span className="flex items-start justify-between gap-2">
134 + <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
135 + <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
136 + </span>
137 + <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
138 + <span className="tnum mt-1.5 text-xs text-ink-2">
139 + {g.members.slice(0, 8).map((m) => m.flag).join(' ')} · {t('explore.q.members', { n: g.n_members })}
140 + {gdp?.value != null ? ` · ${t('regions.gdp')} ${formatValue(gdp.value, gdp.indicator)}` : ''}
141 + </span>
142 + </Link>
143 + </li>
144 + );
145 + })}
146 + </ul>
147 + </Section>
148 +
149 + <div className="grid gap-x-10 lg:grid-cols-2">
150 + <Section id="changes" title={t('explore.changes.title')} subtitle={t('explore.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('explore.changes.all')} →</Link>}>
151 + <ChangesFeed initial={changes?.items ?? []} kinds={changes?.kinds ?? []} limit={30} compact />
152 + </Section>
153 + <Section id="similar" title={t('explore.similar.title')} subtitle={t('explore.similar.sub')}>
154 + <SimilarityPlayground />
155 + </Section>
156 + </div>
157 + </>
158 + );
159 +}
modified apps/web/src/app/icon.png +0 −0

Binary file not shown.

modified apps/web/src/app/icon.svg +1 −1
@@ -1 +1 @@
1 −<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" fill="none" stroke="#1c5cab" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect width="32" height="32" rx="7" fill="#fbfaf7"/><circle cx="16" cy="16" r="12"/><path d="M5 19.3h22"/><path d="M10.1 26.6 16 7.6l5.9 19"/><path d="M16 7.6c-3 2.9-4.3 7.1-4.3 11.7" opacity="0.55"/><path d="M16 7.6c3 2.9 4.3 7.1 4.3 11.7" opacity="0.55"/></svg>
1 +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" fill="none" stroke="#1c5cab" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect width="32" height="32" rx="7" fill="#fbfaf7" stroke="none"/><circle cx="16" cy="16" r="12"/><path d="M5 19.3h22"/><path d="M10.1 26.6 16 7.6l5.9 19"/><path d="M16 7.6c-3 2.9-4.3 7.1-4.3 11.7" opacity="0.55"/><path d="M16 7.6c3 2.9 4.3 7.1 4.3 11.7" opacity="0.55"/></svg>
added apps/web/src/app/indicators/[slug]/opengraph-image.tsx +62 −0
@@ -0,0 +1,62 @@
1 +import { ImageResponse } from 'next/og';
2 +import { t } from '@/i18n';
3 +import { safe } from '@/lib/api';
4 +import { apiExplore } from '@/lib/api-explore';
5 +import { formatValue } from '@/lib/format';
6 +import { OG_INK2, OG_INK3, OG_RULE, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from '../../og-shared';
7 +
8 +export const alt = 'Indicator by country on CountryAtlas';
9 +export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
10 +export const contentType = 'image/png';
11 +export const revalidate = 3600;
12 +
13 +/** Light→dark sequential ramp (globals.css --seq-1…7, light theme) — Satori needs literal colours. */
14 +const SEQ = ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b'];
15 +
16 +/** Per-indicator social image: name, world value + kind, year, coverage, mini sequential legend. */
17 +export default async function IndicatorOgImage({ params }: { params: Promise<{ slug: string }> }) {
18 + const { slug } = await params;
19 + const data = await safe(apiExplore.indicator(slug));
20 + const ind = data?.indicator;
21 + const name = ind?.name ?? slug.replace(/-/g, ' ');
22 + const wl = data?.world_latest ?? null;
23 + const kind = wl ? (wl.kind === 'sum' ? 'World total' : wl.kind === 'weighted_mean' ? 'World average' : 'World median') : null;
24 + const sub = [ind?.unit, ind?.topic ? ind.topic.replace(/-/g, ' ') : null].filter(Boolean).join(' · ');
25 + const coverage = data ? `${data.coverage.n_countries ?? 0} countries · ${data.years.first ?? ''}–${data.years.last_actual ?? ''}` : '';
26 + return new ImageResponse(
27 + (
28 + <OgFrame>
29 + <OgWordmark size={28} />
30 + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 48, maxWidth: 900 }}>
31 + <div style={{ fontSize: 22, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 2, display: 'flex' }}>{t('common.indicator')}</div>
32 + <div style={{ fontSize: name.length > 40 ? 52 : 64, fontWeight: 600, letterSpacing: -1.5, lineHeight: 1.05, marginTop: 8 }}>{name}</div>
33 + {sub ? <div style={{ marginTop: 10, fontSize: 24, color: OG_INK2 }}>{sub}</div> : null}
34 + </div>
35 + <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginTop: 'auto', paddingTop: 24, borderTop: `1px solid ${OG_RULE}` }}>
36 + {wl && ind && wl.value != null ? (
37 + <div style={{ display: 'flex', flexDirection: 'column' }}>
38 + <div style={{ fontSize: 18, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 1.5 }}>{kind}</div>
39 + <div style={{ fontSize: 52, fontWeight: 600, marginTop: 6 }}>{formatValue(wl.value, ind)}</div>
40 + <div style={{ fontSize: 18, color: OG_INK3, marginTop: 2 }}>{`${wl.year ?? ''}${coverage ? ` · ${coverage}` : ''}`}</div>
41 + </div>
42 + ) : (
43 + <div style={{ fontSize: 24, color: OG_INK2 }}>{coverage || 'World map, trend, ranking and sources'}</div>
44 + )}
45 + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
46 + <div style={{ display: 'flex', gap: 3 }}>
47 + {SEQ.map((c) => (
48 + <div key={c} style={{ width: 34, height: 14, background: c, borderRadius: 2 }} />
49 + ))}
50 + </div>
51 + <div style={{ display: 'flex', justifyContent: 'space-between', width: 256, marginTop: 6, fontSize: 16, color: OG_INK3 }}>
52 + <span>low</span>
53 + <span>high</span>
54 + </div>
55 + </div>
56 + </div>
57 + <div style={{ position: 'absolute', right: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
58 + </OgFrame>
59 + ),
60 + { ...size },
61 + );
62 +}
added apps/web/src/app/indicators/[slug]/page.tsx +350 −0
@@ -0,0 +1,350 @@
1 +import { Download, ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import { t, tOpt } from '@/i18n';
6 +import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
7 +import { apiExplore } from '@/lib/api-explore';
8 +import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format';
9 +import { SITE_URL, routes } from '@/lib/site';
10 +import { topicById } from '@/lib/topics';
11 +import type { CountrySummary, FormatSpec, Series } from '@/lib/types';
12 +import type { IndicatorResponse, RankedValue } from '@/lib/types-explore';
13 +import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars';
14 +import { EmptyState, NotBuiltState } from '@/components/data/empty-state';
15 +import type { ProvenancePayload } from '@/components/data/provenance-context';
16 +import { Section } from '@/components/data/section';
17 +import { CodeBlock } from '@/components/explore/copy-button';
18 +import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';
19 +import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare';
20 +import { IndicatorMap } from '@/components/indicators/indicator-map';
21 +import { IndicatorTrend } from '@/components/indicators/indicator-trend';
22 +import { baseFeatures } from '@/components/indicators/map-geometry';
23 +
24 +export const revalidate = 900;
25 +
26 +type Params = { slug: string };
27 +
28 +async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> {
29 + try {
30 + return await apiExplore.indicator(slug);
31 + } catch (e) {
32 + if (isNotFound(e)) return null;
33 + if (isNotBuilt(e)) return 'not-built';
34 + throw e;
35 + }
36 +}
37 +
38 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
39 + const { slug } = await params;
40 + const data = await load(slug);
41 + if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } };
42 + const ind = data.indicator;
43 + const name = titleCase(ind.short_name ?? ind.name ?? slug);
44 + const title = t('indicator.title', { name });
45 + const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' });
46 + const canonical = routes.indicator(ind.slug);
47 + return {
48 + title,
49 + description,
50 + alternates: { canonical },
51 + openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' },
52 + twitter: { card: 'summary_large_image', title, description },
53 + };
54 +}
55 +
56 +/** "Life expectancy" → "Life Expectancy"; keeps acronyms (GDP, CO₂) and short function words lower-case. */
57 +function titleCase(s: string): string {
58 + const small = new Set(['of', 'per', 'to', 'in', 'at', 'by', 'and', 'or', 'the', 'a', 'an', 'vs', 'on', 'for', 'as']);
59 + return s
60 + .split(' ')
61 + .map((w, i) => (i > 0 && small.has(w.toLowerCase()) ? w.toLowerCase() : w === w.toUpperCase() ? w : w.charAt(0).toUpperCase() + w.slice(1)))
62 + .join(' ');
63 +}
64 +
65 +function specOf(ind: IndicatorResponse['indicator']): FormatSpec {
66 + return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };
67 +}
68 +
69 +export default async function IndicatorPage({ params }: { params: Promise<Params> }) {
70 + const { slug } = await params;
71 + const data = await load(slug);
72 + if (data === null) notFound();
73 + if (data === 'not-built') return <NotBuiltState />;
74 +
75 + const ind = data.indicator;
76 + const name = ind.name ?? ind.slug;
77 + const spec = specOf(ind);
78 + const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null;
79 +
80 + const [countriesRes, map, trend, related] = await Promise.all([
81 + safe(api.countries()),
82 + safe(api.indicatorMap(ind.slug, yearUsed ? { year: yearUsed } : {})),
83 + safe(apiExplore.indicatorTrend(ind.slug, 'world')),
84 + ind.topic ? safe(apiExplore.indicators({ topic: ind.topic })) : Promise.resolve(null),
85 + ]);
86 + const countries = countriesRes?.items ?? [];
87 + const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country');
88 +
89 + // Default comparison: the 3 largest economies that have data for this indicator.
90 + const defaults: CompareCountry[] = [...onlyCountries]
91 + .filter((c) => isNum(c.gdp_latest) && map && map.values[c.id] != null)
92 + .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0))
93 + .slice(0, 3)
94 + .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag }));
95 + const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null;
96 + const initialSeries: Series[] = bundle?.series ?? [];
97 +
98 + const years = data.coverage.by_year.map((y) => y.year);
99 + const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' };
100 +
101 + const wl = data.world_latest;
102 + const worldPayload: ProvenancePayload = {
103 + indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description },
104 + value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: map?.provenance ?? data.top5[0]?.provenance ?? null } : null,
105 + country: null,
106 + downloadHref: routes.indicatorDownload(ind.slug),
107 + };
108 +
109 + const hib = ind.higher_is_better;
110 + const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best');
111 + const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst');
112 + const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank));
113 +
114 + // Countries without data for the map year.
115 + const missing: CountrySummary[] = map ? onlyCountries.filter((c) => map.values[c.id] == null).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : [];
116 + const relatedRows = (related?.items ?? []).filter((i) => i.slug !== ind.slug && (!ind.subtopic || i.subtopic === ind.subtopic)).slice(0, 8);
117 + const topic = topicById(ind.topic ?? '');
118 + const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`;
119 +
120 + const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A');
121 + const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null;
122 +
123 + return (
124 + <>
125 + <PageHeader
126 + crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]}
127 + eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')}
128 + title={name}
129 + lede={ind.description}
130 + meta={
131 + <>
132 + {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })}
133 + {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''}
134 + </>
135 + }
136 + actions={
137 + <>
138 + {ind.ranking_eligible !== false ? (
139 + <Link href={routes.ranking(ind.slug)} className={ACTION_CLS}>
140 + {t('common.ranking')}
141 + </Link>
142 + ) : null}
143 + <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}>
144 + <Download size={14} aria-hidden /> {t('common.downloadCsv')}
145 + </a>
146 + </>
147 + }
148 + />
149 +
150 + {/* World figure + freshness — three lines, per spec */}
151 + <section aria-label={t('indicator.freshness')} className="border-y border-rule">
152 + <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule">
153 + <div className="col-span-2 sm:col-span-1 lg:pr-4">
154 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{worldKindLabel ?? t('indicator.world.median')}</dt>
155 + <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd>
156 + {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null}
157 + </div>
158 + <div className="lg:px-4">
159 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.latest')}</dt>
160 + <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd>
161 + {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null}
162 + </div>
163 + <div className="lg:px-4">
164 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.source')}</dt>
165 + <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>
166 + </div>
167 + <div className="lg:pl-4">
168 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.refreshed')}</dt>
169 + <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd>
170 + {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null}
171 + </div>
172 + </dl>
173 + </section>
174 +
175 + <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0">
176 + {map && features.length ? (
177 + <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} initial={map} years={years.length ? years : [map.year_used ?? 0]} spec={spec} payload={worldPayload} />
178 + ) : (
179 + <EmptyState title={t('common.noDataLong')} />
180 + )}
181 + </Section>
182 +
183 + <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}>
184 + <IndicatorTrend slug={ind.slug} initial={trend} spec={spec} payload={worldPayload} />
185 + </Section>
186 +
187 + <Section id="top" title={t('indicator.top.title')} subtitle={yearUsed ? t('indicator.top.sub', { year: yearUsed }) : undefined} actions={ind.ranking_eligible !== false ? <Link href={routes.ranking(ind.slug)} className="text-accent hover:underline">{t('indicator.top.fullRanking')} →</Link> : null}>
188 + {data.top5.length === 0 ? (
189 + <EmptyState compact title={t('common.noDataLong')} />
190 + ) : (
191 + <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">
192 + <div>
193 + <h3 className="mb-2 text-sm font-semibold text-ink">{topLabel}</h3>
194 + <RankedBars rows={toRows(data.top5)} spec={spec} provenance={data.top5[0]?.provenance ?? null} />
195 + </div>
196 + {data.bottom5.length ? (
197 + <div>
198 + <h3 className="mb-2 text-sm font-semibold text-ink">{bottomLabel}</h3>
199 + <RankedBars rows={toRows(data.bottom5)} spec={spec} provenance={data.bottom5[0]?.provenance ?? null} />
200 + </div>
201 + ) : null}
202 + </div>
203 + )}
204 + </Section>
205 +
206 + <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}>
207 + <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} />
208 + </Section>
209 +
210 + <Section id="definition" title={t('indicator.definition')} level={2}>
211 + <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
212 + <div>
213 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.definition')}</dt>
214 + <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd>
215 + </div>
216 + {ind.methodology ? (
217 + <div>
218 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.methodology')}</dt>
219 + <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd>
220 + </div>
221 + ) : null}
222 + <div>
223 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.unit')}</dt>
224 + <dd className="mt-0.5 text-ink">
225 + {ind.unit ?? t('common.na')}
226 + {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null}
227 + </dd>
228 + </div>
229 + <div>
230 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.frequency')}</dt>
231 + <dd className="mt-0.5 text-ink">{freqLabel}</dd>
232 + </div>
233 + <div>
234 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.aggregation')}</dt>
235 + <dd className="mt-0.5 text-ink">
236 + {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')}
237 + </dd>
238 + </div>
239 + {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? (
240 + <div>
241 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.bounds')}</dt>
242 + <dd className="tnum mt-0.5 text-ink">
243 + {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'}
244 + </dd>
245 + </div>
246 + ) : null}
247 + </dl>
248 + </Section>
249 +
250 + <Section id="sources" title={t('indicator.sources.title')} subtitle={t('indicator.sources.sub')}>
251 + <ol className="divide-y divide-rule border-y border-rule">
252 + {data.sources.map((s) => (
253 + <li key={`${s.source_id}-${s.series_code}`} className="grid gap-x-6 gap-y-1 py-3 sm:grid-cols-[2.5rem_minmax(0,1fr)_auto] sm:items-start">
254 + <span className="tnum text-xs text-ink-3">
255 + <span className="sr-only">{t('indicator.sources.priority')} </span>#{s.priority ?? '—'}
256 + </span>
257 + <div className="min-w-0 text-sm">
258 + <div className="flex flex-wrap items-baseline gap-x-2">
259 + <Link href={routes.source(s.source_id)} className="link-quiet font-medium text-ink">
260 + {s.source_name ?? s.source_id}
261 + </Link>
262 + <span className="text-ink-3">{s.dataset}</span>
263 + <code className="rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code>
264 + {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null}
265 + </div>
266 + <div className="tnum mt-0.5 text-xs text-ink-3">
267 + {s.n_countries != null ? t('indicators.coverageShort', { n: grouped(s.n_countries) }) : ''}
268 + {s.n_observations != null ? ` · ${grouped(s.n_observations)} obs.` : ''}
269 + {s.last_year ? ` · → ${s.last_year}` : ''}
270 + {s.licence ? ` · ${s.licence}` : ''}
271 + {s.transform ? ` · ${t('indicator.sources.transform', { expr: s.transform })}` : ''}
272 + {s.countries?.length ? ` · ${t('indicator.sources.restricted', { n: s.countries.length })}` : ''}
273 + </div>
274 + {s.notes ? <p className="mt-1 line-clamp-2 text-xs text-ink-3">{s.notes}</p> : null}
275 + </div>
276 + {s.url ? (
277 + <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center gap-1 text-xs text-accent hover:underline md:min-h-[32px]">
278 + <ExternalLink size={12} aria-hidden /> {t('indicator.sources.open')}
279 + </a>
280 + ) : null}
281 + </li>
282 + ))}
283 + </ol>
284 + <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.sources.rule')}</p>
285 + </Section>
286 +
287 + <div className="grid gap-x-10 lg:grid-cols-2">
288 + <Section id="no-data" title={t('indicator.nodata.title')} subtitle={map && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: map.year_used ?? yearUsed }) : t('indicator.nodata.none', { year: map.year_used ?? yearUsed })) : undefined}>
289 + {missing.length ? (
290 + <>
291 + <ul className="flex flex-wrap gap-1.5 text-xs">
292 + {missing.slice(0, 24).map((c) => (
293 + <li key={c.id}>
294 + <Link href={routes.country(c.slug ?? c.id)} className="inline-flex min-h-[32px] items-center gap-1 rounded-sm border border-rule px-2 text-ink-2 hover:border-accent hover:text-accent">
295 + <span aria-hidden>{c.flag}</span> {c.name}
296 + </Link>
297 + </li>
298 + ))}
299 + {missing.length > 24 ? <li className="inline-flex min-h-[32px] items-center px-1 text-ink-3">{t('indicator.nodata.more', { n: missing.length - 24 })}</li> : null}
300 + </ul>
301 + <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.nodata.why')}</p>
302 + </>
303 + ) : (
304 + <p className="text-sm text-ink-2">{t('indicator.nodata.why')}</p>
305 + )}
306 + </Section>
307 +
308 + <Section id="downloads" title={t('indicator.downloads.title')} subtitle={t('indicator.downloads.sub')}>
309 + <div className="flex flex-wrap gap-2">
310 + <a href={routes.indicatorDownload(ind.slug, 'csv')} className={ACTION_CLS}>
311 + <Download size={14} aria-hidden /> {t('indicator.downloads.csv')}
312 + </a>
313 + <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}>
314 + <Download size={14} aria-hidden /> {t('indicator.downloads.json')}
315 + </a>
316 + </div>
317 + <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3>
318 + <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p>
319 + <CodeBlock code={apiSnippet} lang="bash" />
320 + <p className="mt-2 text-xs text-ink-3">
321 + <Link href={routes.api()} className="text-accent hover:underline">
322 + {t('data.api.link')} →
323 + </Link>
324 + </p>
325 + </Section>
326 + </div>
327 +
328 + <Section id="related" title={t('indicator.related.title')} subtitle={ind.subtopic ? t('indicator.related.sub', { subtopic: ind.subtopic }) : undefined}>
329 + {relatedRows.length === 0 ? (
330 + <p className="text-sm text-ink-3">{t('indicator.related.none')}</p>
331 + ) : (
332 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
333 + {relatedRows.map((r) => (
334 + <li key={r.slug} className="border-t border-rule">
335 + <Link href={routes.indicator(r.slug)} className="group flex min-h-[56px] flex-col justify-center py-2">
336 + <span className="text-sm text-ink group-hover:text-accent">{r.name}</span>
337 + <span className="tnum text-xs text-ink-3">
338 + {r.unit}
339 + {r.n_countries != null ? ` · ${t('indicators.coverageShort', { n: grouped(r.n_countries) })}` : ''}
340 + {r.last_year ? ` · → ${r.last_year}` : ''}
341 + </span>
342 + </Link>
343 + </li>
344 + ))}
345 + </ul>
346 + )}
347 + </Section>
348 + </>
349 + );
350 +}
added apps/web/src/app/indicators/page.tsx +61 −0
@@ -0,0 +1,61 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { t } from '@/i18n';
4 +import { isNotBuilt, safe } from '@/lib/api';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { grouped } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import { TOPICS, isTopicId, topicById } from '@/lib/topics';
9 +import type { IndicatorSummary } from '@/lib/types';
10 +import { NotBuiltState } from '@/components/data/empty-state';
11 +import { PageHeader } from '@/components/explore/page-header';
12 +import { IndicatorDirectory } from '@/components/indicators/directory';
13 +
14 +export const revalidate = 900;
15 +
16 +type Search = Promise<{ topic?: string | string[] }>;
17 +
18 +export async function generateMetadata({ searchParams }: { searchParams: Search }): Promise<Metadata> {
19 + const sp = await searchParams;
20 + const topic = typeof sp.topic === 'string' && isTopicId(sp.topic) ? topicById(sp.topic) : null;
21 + const title = topic ? `${topic.name} — ${t('indicators.title')}` : t('indicators.title');
22 + return {
23 + title,
24 + description: topic ? topic.blurb : t('indicators.sub', { n: 260 }),
25 + alternates: { canonical: routes.indicators(topic?.id) },
26 + };
27 +}
28 +
29 +export default async function IndicatorsPage({ searchParams }: { searchParams: Search }) {
30 + const sp = await searchParams;
31 + const initialTopic = typeof sp.topic === 'string' && isTopicId(sp.topic) ? sp.topic : null;
32 +
33 + let items: IndicatorSummary[] = [];
34 + let notBuilt = false;
35 + try {
36 + items = (await apiExplore.indicators()).items;
37 + } catch (e) {
38 + if (isNotBuilt(e)) notBuilt = true;
39 + else throw e;
40 + }
41 + // Topic membership + order from the API (an indicator can belong to several topics).
42 + const perTopic = notBuilt ? [] : await Promise.all(TOPICS.map((tp) => safe(apiExplore.indicators({ topic: tp.id }))));
43 + const topicMembers: Record<string, string[]> = {};
44 + TOPICS.forEach((tp, i) => {
45 + const res = perTopic[i];
46 + if (res) topicMembers[tp.id] = res.items.map((it) => it.slug);
47 + });
48 +
49 + return (
50 + <>
51 + <PageHeader title={t('indicators.title')} lede={t('indicators.sub', { n: grouped(items.length || 260) })} />
52 + {notBuilt ? (
53 + <NotBuiltState />
54 + ) : (
55 + <Suspense fallback={null}>
56 + <IndicatorDirectory items={items} topicMembers={topicMembers} initialTopic={initialTopic} />
57 + </Suspense>
58 + )}
59 + </>
60 + );
61 +}
added apps/web/src/app/methodology/page.tsx +283 −0
@@ -0,0 +1,283 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import type { ReactNode } from 'react';
4 +import { t, tOpt } from '@/i18n';
5 +import { isNotBuilt } from '@/lib/api';
6 +import { apiExplore } from '@/lib/api-explore';
7 +import { formatDate, grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { MethodologyResponse } from '@/lib/types-explore';
10 +import { StatusBadge } from '@/components/data/freshness-badge';
11 +import { PageHeader } from '@/components/explore/page-header';
12 +import { Toc } from '@/components/methodology/toc';
13 +
14 +export const revalidate = 3600;
15 +
16 +export const metadata: Metadata = {
17 + title: t('method.title'),
18 + description: t('method.sub'),
19 + alternates: { canonical: routes.methodology() },
20 +};
21 +
22 +const SECTIONS = ['principles', 'countries', 'indicators', 'sources', 'validation', 'freshness', 'rankings', 'changes', 'similarity', 'insights', 'dna', 'limitations', 'versioning', 'licence'] as const;
23 +
24 +function Prose({ id, title, children }: { id: string; title: string; children: ReactNode }) {
25 + return (
26 + <section id={id} className="scroll-mt-28 border-t border-rule py-8 first:border-t-0 md:py-10" aria-labelledby={`${id}-h`}>
27 + <h2 id={`${id}-h`} className="display text-2xl text-ink md:text-3xl">
28 + <a href={`#${id}`} className="link-quiet">
29 + {title}
30 + </a>
31 + </h2>
32 + <div className="mt-3 max-w-prose space-y-3 text-base leading-relaxed text-ink-2 [&_p]:text-pretty">{children}</div>
33 + </section>
34 + );
35 +}
36 +
37 +function Kv({ rows }: { rows: Array<[string, ReactNode]> }) {
38 + return (
39 + <dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-6 gap-y-1 text-sm">
40 + {rows.map(([k, v]) => (
41 + <div key={k} className="contents">
42 + <dt className="text-ink-2">{k}</dt>
43 + <dd className="tnum text-right text-ink">{v}</dd>
44 + </div>
45 + ))}
46 + </dl>
47 + );
48 +}
49 +
50 +export default async function MethodologyPage() {
51 + let m: MethodologyResponse | null = null;
52 + try {
53 + m = await apiExplore.methodology();
54 + } catch (e) {
55 + if (!isNotBuilt(e)) throw e;
56 + }
57 + const toc = SECTIONS.map((id) => ({ id, label: t(`method.${id}` as 'method.principles') }));
58 + const stale = m?.validation.stale_after_days ?? { A: 800, Q: 200, M: 75 };
59 +
60 + return (
61 + <>
62 + <PageHeader title={t('method.title')} lede={t('method.sub')} meta={m?.meta.built_at ? `${t('site.footer.refreshed', { date: formatDate(m.meta.built_at) })}${m.meta.run_id ? ` · ${t('site.footer.build', { run: m.meta.run_id })}` : ''}` : undefined} />
63 + <div className="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-12">
64 + <div className="lg:self-start">
65 + <Toc items={toc} />
66 + </div>
67 + <article className="min-w-0">
68 + <Prose id="principles" title={t('method.principles')}>
69 + <ol className="list-decimal space-y-2 pl-5">
70 + {(m?.principles ?? []).map((p) => (
71 + <li key={p}>{p}</li>
72 + ))}
73 + </ol>
74 + </Prose>
75 +
76 + <Prose id="countries" title={t('method.countries')}>
77 + <p>{t('method.countries.text')}</p>
78 + <p className="text-sm">
79 + <Link href={routes.regions()} className="text-accent hover:underline">
80 + {t('regions.title')} →
81 + </Link>
82 + </p>
83 + </Prose>
84 +
85 + <Prose id="indicators" title={t('method.indicators')}>
86 + <p>{t('method.indicators.text', { n: grouped(m?.indicators.n ?? 260) })}</p>
87 + {m ? (
88 + <div className="grid gap-6 pt-2 sm:grid-cols-2">
89 + <div>
90 + <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.indicators.formats')}</h3>
91 + <Kv rows={m.indicators.formats.map((f) => [f.format, grouped(f.n)] as [string, ReactNode])} />
92 + </div>
93 + <div>
94 + <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.indicators.units')}</h3>
95 + <Kv rows={m.indicators.units.slice(0, 10).map((u) => [u.unit, grouped(u.n)] as [string, ReactNode])} />
96 + <h3 className="mb-1.5 mt-5 text-sm font-semibold text-ink">{t('method.indicators.frequencies')}</h3>
97 + <Kv rows={Object.entries(m.indicators.frequencies).map(([k, v]) => [tOpt(`indicator.frequency.${k}`, k), grouped(v)] as [string, ReactNode])} />
98 + <h3 className="mb-1.5 mt-5 text-sm font-semibold text-ink">{t('method.indicators.aggregations')}</h3>
99 + <Kv rows={Object.entries(m.indicators.aggregations).map(([k, v]) => [k, grouped(v)] as [string, ReactNode])} />
100 + </div>
101 + </div>
102 + ) : null}
103 + <p className="text-sm">
104 + <Link href={routes.indicators()} className="text-accent hover:underline">
105 + {t('indicators.title')} →
106 + </Link>
107 + </p>
108 + </Prose>
109 +
110 + <Prose id="sources" title={t('method.sources')}>
111 + <p>{m?.sources.priority_rule}</p>
112 + <p>{t('method.sources.text2')}</p>
113 + {m ? (
114 + <div className="grid gap-6 pt-2 sm:grid-cols-2">
115 + <div>
116 + <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.sources.mapped')}</h3>
117 + <Kv
118 + rows={Object.entries(m.sources.series_mapped_per_connector)
119 + .sort((a, b) => b[1] - a[1])
120 + .map(([k, v]) => [k, grouped(v)] as [string, ReactNode])}
121 + />
122 + </div>
123 + <div>
124 + <h3 className="mb-1.5 text-sm font-semibold text-ink">{t('method.sources.urls')}</h3>
125 + <ul className="space-y-1 text-xs">
126 + {Object.entries(m.sources.urls).map(([k, v]) => (
127 + <li key={k} className="grid grid-cols-[5rem_minmax(0,1fr)] gap-2">
128 + <span className="text-ink-2">{k}</span>
129 + <code className="break-all font-mono text-ink">{v}</code>
130 + </li>
131 + ))}
132 + </ul>
133 + </div>
134 + </div>
135 + ) : null}
136 + <p className="text-sm">
137 + <Link href={routes.sources()} className="text-accent hover:underline">
138 + {t('sources.title')} →
139 + </Link>
140 + </p>
141 + </Prose>
142 +
143 + <Prose id="validation" title={t('method.validation')}>
144 + <p>{t('method.validation.text')}</p>
145 + {m ? (
146 + <table className="w-full border-collapse text-sm">
147 + <thead>
148 + <tr className="border-b border-rule text-left text-xs text-ink-3">
149 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('method.rule.code')}</th>
150 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('method.rule.severity')}</th>
151 + <th scope="col" className="py-1.5 font-medium">{t('method.rule.effect')}</th>
152 + </tr>
153 + </thead>
154 + <tbody className="divide-y divide-rule">
155 + {m.validation.rules.map((r) => (
156 + <tr key={r.code}>
157 + <td className="py-2 pr-3 align-top font-mono text-xs text-ink">{r.code}</td>
158 + <td className="py-2 pr-3 align-top text-xs text-ink-2">{r.severity}</td>
159 + <td className="py-2 align-top text-ink-2">{r.text}</td>
160 + </tr>
161 + ))}
162 + </tbody>
163 + </table>
164 + ) : null}
165 + <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.validation.statuses')}</h3>
166 + <ul className="space-y-2">
167 + {(['verified', 'imported', 'warning', 'stale', 'quarantined'] as const).map((s) => (
168 + <li key={s} className="flex items-start gap-3">
169 + <span className="mt-0.5 shrink-0">
170 + <StatusBadge status={s} />
171 + </span>
172 + <span className="text-sm">{t(`method.validation.status.${s}` as 'method.validation.status.verified')}</span>
173 + </li>
174 + ))}
175 + </ul>
176 + <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.validation.stale')}</h3>
177 + <p className="text-sm">{t('method.validation.staleText', { a: stale.A ?? 800, q: stale.Q ?? 200, m: stale.M ?? 75 })}</p>
178 + </Prose>
179 +
180 + <Prose id="freshness" title={t('method.freshness')}>
181 + <p>{t('method.freshness.text')}</p>
182 + {m ? (
183 + <>
184 + <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.periods')}</h3>
185 + <Kv rows={Object.entries(m.periods).map(([k, v]) => [k, <code key={k} className="font-mono text-xs">{v}</code>] as [string, ReactNode])} />
186 + </>
187 + ) : null}
188 + </Prose>
189 +
190 + <Prose id="rankings" title={t('method.rankings')}>
191 + <p>{t('method.rankings.text')}</p>
192 + {m ? <p className="text-sm text-ink-3">{m.derived.rankings}</p> : null}
193 + <p className="text-sm">
194 + <Link href={routes.rankings()} className="text-accent hover:underline">
195 + {t('nav.rankings')} →
196 + </Link>
197 + </p>
198 + </Prose>
199 +
200 + <Prose id="changes" title={t('method.changes')}>
201 + <p>{t('method.changes.text')}</p>
202 + {m ? <p className="text-sm text-ink-3">{m.derived.changes}</p> : null}
203 + <p className="text-sm">
204 + <Link href={routes.changes()} className="text-accent hover:underline">
205 + {t('changes.title')} →
206 + </Link>
207 + </p>
208 + </Prose>
209 +
210 + <Prose id="similarity" title={t('method.similarity')}>
211 + <p>{t('method.similarity.text')}</p>
212 + {m ? (
213 + <>
214 + <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.similarity.modes')}</h3>
215 + <dl className="space-y-1.5 text-sm">
216 + {Object.entries(m.derived.similarity.modes).map(([k, v]) => (
217 + <div key={k} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2">
218 + <dt className="font-medium text-ink">{tOpt(`country.similar.mode.${k}`, k)}</dt>
219 + <dd className="text-ink-2">{v}</dd>
220 + </div>
221 + ))}
222 + </dl>
223 + <p className="text-sm text-ink-3">{m.derived.similarity.method}</p>
224 + </>
225 + ) : null}
226 + </Prose>
227 +
228 + <Prose id="insights" title={t('method.insights')}>
229 + <p>{t('method.insights.text')}</p>
230 + {m ? <p className="text-sm text-ink-3">{m.derived.insights}</p> : null}
231 + </Prose>
232 +
233 + <Prose id="dna" title={t('method.dna')}>
234 + <p>{t('method.dna.text')}</p>
235 + {m ? (
236 + <ul className="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
237 + {m.derived.country_dna.dimensions.map((d) => (
238 + <li key={d.id} className="grid grid-cols-[8rem_minmax(0,1fr)] gap-2">
239 + <span className="font-medium text-ink">{d.label}</span>
240 + <span className="min-w-0">
241 + {d.indicators.map((s, i) => (
242 + <span key={s}>
243 + {i > 0 ? ', ' : ''}
244 + <Link href={routes.indicator(s)} className="text-accent hover:underline">
245 + {s}
246 + </Link>
247 + </span>
248 + ))}
249 + </span>
250 + </li>
251 + ))}
252 + </ul>
253 + ) : null}
254 + {m ? <p className="text-sm text-ink-3">{m.derived.world_aggregates}</p> : null}
255 + </Prose>
256 +
257 + <Prose id="limitations" title={t('method.limitations')}>
258 + <p>{t('method.limitations.text')}</p>
259 + </Prose>
260 +
261 + <Prose id="versioning" title={t('method.versioning')}>
262 + <p>{t('method.versioning.text')}</p>
263 + </Prose>
264 +
265 + <Prose id="licence" title={t('method.licence')}>
266 + <p>{t('method.licence.text')}</p>
267 + {m ? (
268 + <>
269 + <h3 className="pt-2 text-sm font-semibold text-ink">{t('method.formatting')}</h3>
270 + <Kv rows={Object.entries(m.formatting).map(([k, v]) => [k, v] as [string, ReactNode])} />
271 + </>
272 + ) : null}
273 + <p className="text-sm">
274 + <Link href={routes.data()} className="text-accent hover:underline">
275 + {t('data.title')} →
276 + </Link>
277 + </p>
278 + </Prose>
279 + </article>
280 + </div>
281 + </>
282 + );
283 +}
added apps/web/src/app/rankings/[indicator]/opengraph-image.tsx +44 −0
@@ -0,0 +1,44 @@
1 +import { ImageResponse } from 'next/og';
2 +import { t } from '@/i18n';
3 +import { safe } from '@/lib/api';
4 +import { apiCompare } from '@/lib/api-compare';
5 +import { OG_INK2, OG_INK3, OG_RULE, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from '../../og-shared';
6 +
7 +export const alt = 'Country ranking on CountryAtlas';
8 +export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
9 +export const contentType = 'image/png';
10 +export const revalidate = 3600;
11 +
12 +/** Social image for /rankings/[indicator]: title + the top 3 (flag, name, value). */
13 +export default async function RankingOgImage({ params }: { params: Promise<{ indicator: string }> }) {
14 + const { indicator } = await params;
15 + const data = await safe(apiCompare.ranking(indicator, { limit: 3, sparkline: false }));
16 + const name = data?.indicator.short_name ?? data?.indicator.name ?? indicator.replace(/-/g, ' ');
17 + const year = data?.year_used ?? '';
18 + return new ImageResponse(
19 + (
20 + <OgFrame>
21 + <OgWordmark size={28} />
22 + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 48 }}>
23 + <div style={{ fontSize: 22, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 2, display: 'flex' }}>{t('rankings.title')}</div>
24 + <div style={{ fontSize: name.length > 28 ? 52 : 64, fontWeight: 600, letterSpacing: -1.5, lineHeight: 1.05, marginTop: 8, display: 'flex' }}>{t('ranking.heading', { name })}</div>
25 + {year ? <div style={{ marginTop: 10, fontSize: 26, color: OG_INK2, display: 'flex' }}>{`${String(year)}${data ? ` · ${data.n} countries` : ''}`}</div> : null}
26 + </div>
27 + <div style={{ display: 'flex', gap: 40, marginTop: 'auto', paddingTop: 22, borderTop: `1px solid ${OG_RULE}` }}>
28 + {(data?.rows ?? []).slice(0, 3).map((r) => (
29 + <div key={r.country.id} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
30 + <div style={{ fontSize: 26, color: OG_INK3, display: 'flex' }}>{String(r.rank)}</div>
31 + <div style={{ fontSize: 44, lineHeight: 1, display: 'flex' }}>{r.country.flag ?? ''}</div>
32 + <div style={{ display: 'flex', flexDirection: 'column' }}>
33 + <div style={{ fontSize: 20, color: OG_INK2, display: 'flex' }}>{r.country.name ?? r.country.id}</div>
34 + <div style={{ fontSize: 30, fontWeight: 600, display: 'flex' }}>{r.formatted ?? String(r.value ?? '')}</div>
35 + </div>
36 + </div>
37 + ))}
38 + </div>
39 + <div style={{ position: 'absolute', right: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
40 + </OgFrame>
41 + ),
42 + { ...size },
43 + );
44 +}
added apps/web/src/app/rankings/[indicator]/page.tsx +135 −0
@@ -0,0 +1,135 @@
1 +import { Download, ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import { t } from '@/i18n';
6 +import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
7 +import { apiCompare } from '@/lib/api-compare';
8 +import { grouped } from '@/lib/format';
9 +import { parseRankingState, type RankingState } from '@/lib/ranking-state';
10 +import { routes } from '@/lib/site';
11 +import { topicById } from '@/lib/topics';
12 +import type { RankingResponse } from '@/lib/types';
13 +import { toCountryLite, type CountryLite } from '@/lib/types-compare';
14 +import { Choropleth } from '@/components/charts/choropleth';
15 +import { NotBuiltState } from '@/components/data/empty-state';
16 +import { Section } from '@/components/data/section';
17 +import { RankHistoryPanel } from '@/components/rankings/rank-history-panel';
18 +import { RankingView } from '@/components/rankings/ranking-view';
19 +
20 +export const revalidate = 900;
21 +
22 +type Params = { indicator: string };
23 +type SP = Record<string, string | string[] | undefined>;
24 +
25 +async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> {
26 + if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
27 + try {
28 + return await apiCompare.ranking(slug, { year: state.year, group: state.group, sort: state.sort, limit: 300, sparkline: true });
29 + } catch (e) {
30 + if (isNotFound(e)) return null;
31 + if (isNotBuilt(e)) return 'not-built';
32 + throw e;
33 + }
34 +}
35 +
36 +export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {
37 + const [{ indicator }, sp] = await Promise.all([params, searchParams]);
38 + const state = parseRankingState(sp);
39 + const data = await load(indicator, state);
40 + if (!data || data === 'not-built') return { title: t('ranking.notFound'), robots: { index: false } };
41 + const name = data.indicator.short_name ?? data.indicator.name ?? indicator;
42 + const year = data.year_used ?? '';
43 + const world = state.group === 'world';
44 + const title = world ? t('ranking.pageTitle', { name, year }) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year });
45 + const top = data.rows
46 + .slice(0, 3)
47 + .map((r) => r.country.name)
48 + .filter(Boolean)
49 + .join(', ');
50 + const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top });
51 + const canonical = routes.ranking(data.indicator.slug, { group: state.group });
52 + return {
53 + title,
54 + description,
55 + alternates: { canonical },
56 + robots: state.year != null || state.highlight || state.q ? { index: false, follow: true } : undefined,
57 + openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' },
58 + twitter: { card: 'summary_large_image', title, description },
59 + };
60 +}
61 +
62 +export default async function RankingPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {
63 + const [{ indicator }, sp] = await Promise.all([params, searchParams]);
64 + const state = parseRankingState(sp);
65 + const data = await load(indicator, state);
66 + if (data === null) notFound();
67 + if (data === 'not-built') return <NotBuiltState />;
68 +
69 + const ind = data.indicator;
70 + const name = ind.short_name ?? ind.name ?? indicator;
71 + const year = data.year_used;
72 + const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null;
73 + const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5);
74 +
75 + const [regions, countriesRes, map, history] = await Promise.all([
76 + safe(apiCompare.regions()),
77 + safe(api.countries()),
78 + year != null ? safe(apiCompare.indicatorMap(ind.slug, { year })) : Promise.resolve(null),
79 + historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null),
80 + ]);
81 + const countries: CountryLite[] = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));
82 + const topic = topicById(ind.topic ?? '');
83 + const groupName = data.group.name ?? state.group;
84 +
85 + return (
86 + <>
87 + <header className="pb-3 pt-6 md:pt-10">
88 + <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0">
89 + <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">
90 + {t('rankings.title')}
91 + </Link>
92 + {topic ? (
93 + <>
94 + <span className="mx-1.5">/</span>
95 + <Link href={`${routes.rankings()}#topic-${topic.id}`} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">
96 + {topic.short}
97 + </Link>
98 + </>
99 + ) : null}
100 + </nav>
101 + <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">{state.group === 'world' ? t('ranking.heading', { name }) : t('ranking.headingGroup', { name, group: groupName })}</h1>
102 + <p className="tnum mt-2 text-sm text-ink-2">
103 + {ind.name && ind.name !== name ? <span className="text-ink">{ind.name} · </span> : null}
104 + {t('ranking.definition', { unit: ind.unit ?? '', n: grouped(data.n), year: year ?? t('common.na') })}
105 + </p>
106 + <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
107 + <Link href={routes.indicator(ind.slug)} className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline md:min-h-[32px]">
108 + {t('ranking.openIndicator')}
109 + <ExternalLink size={13} aria-hidden />
110 + </Link>
111 + <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">
112 + <Download size={13} aria-hidden />
113 + {t('ranking.download')}
114 + </a>
115 + {data.rows.length >= 2 ? (
116 + <Link href={routes.compare(...data.rows.slice(0, Math.min(5, data.rows.length)).map((r) => r.country.slug ?? r.country.id.toLowerCase()))} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
117 + {t('ranking.compareTop', { n: Math.min(5, data.rows.length) })}
118 + </Link>
119 + ) : null}
120 + </div>
121 + </header>
122 +
123 + <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} />
124 +
125 + <div className="grid gap-x-10 lg:grid-cols-2">
126 + <Section id="map" title={t('ranking.map.title')} subtitle={t('ranking.map.sub', { name: ind.short_name ?? ind.name, year: map?.year_used ?? year ?? '' })} level={3}>
127 + {map && countriesRes ? <Choropleth map={map} countries={countriesRes.items} compact /> : <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p>}
128 + </Section>
129 + <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={3}>
130 + <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} />
131 + </Section>
132 + </div>
133 + </>
134 + );
135 +}
added apps/web/src/app/rankings/page.tsx +48 −0
@@ -0,0 +1,48 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt, safe } from '@/lib/api';
4 +import { apiCompare } from '@/lib/api-compare';
5 +import { routes } from '@/lib/site';
6 +import type { RankingRow } from '@/lib/types';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +import { RankingsDirectory } from '@/components/rankings/rankings-directory';
9 +
10 +export const revalidate = 900;
11 +
12 +export const metadata: Metadata = {
13 + title: t('rankings.metaTitle'),
14 + description: t('rankings.description', { n: 230 }),
15 + alternates: { canonical: routes.rankings() },
16 + openGraph: { title: `${t('rankings.metaTitle')} — ${t('site.name')}`, description: t('rankings.description', { n: 230 }), url: routes.rankings(), type: 'website' },
17 +};
18 +
19 +/**
20 + * /rankings — every rankable indicator grouped by topic (featured first). Top-3 previews are fetched
21 + * server-side for the featured ones (≈ 27 requests, under the API's 120 req/min budget); the others load
22 + * client-side on scroll.
23 + */
24 +export default async function RankingsPage() {
25 + let list;
26 + try {
27 + list = await apiCompare.rankings();
28 + } catch (e) {
29 + if (isNotBuilt(e)) return <NotBuiltState />;
30 + throw e;
31 + }
32 + const featured = list.items.filter((i) => i.featured).slice(0, 30);
33 + const tops = await Promise.all(featured.map((i) => safe(apiCompare.ranking(i.slug, { limit: 3, sparkline: false }))));
34 + const previews: Record<string, RankingRow[] | null> = {};
35 + featured.forEach((i, k) => {
36 + previews[i.slug] = tops[k]?.rows ?? null;
37 + });
38 +
39 + return (
40 + <>
41 + <header className="pb-2 pt-6 md:pt-10">
42 + <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('rankings.title')}</h1>
43 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('rankings.sub', { n: list.n })}</p>
44 + </header>
45 + <RankingsDirectory items={list.items} previews={previews} />
46 + </>
47 + );
48 +}
added apps/web/src/app/regions/[slug]/page.tsx +117 −0
@@ -0,0 +1,117 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { t, tOpt } from '@/i18n';
5 +import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6 +import { apiExplore } from '@/lib/api-explore';
7 +import { formatValue, grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { IndicatorCard } from '@/lib/types';
10 +import type { RegionResponse } from '@/lib/types-explore';
11 +import { NotBuiltState } from '@/components/data/empty-state';
12 +import { Section } from '@/components/data/section';
13 +import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';
14 +import { MemberMap } from '@/components/regions/member-map';
15 +import { MemberRanking } from '@/components/regions/member-ranking';
16 +import { MembersTable } from '@/components/regions/members-table';
17 +
18 +export const revalidate = 900;
19 +
20 +type Params = { slug: string };
21 +
22 +async function load(slug: string): Promise<RegionResponse | 'not-built' | null> {
23 + try {
24 + return await apiExplore.region(slug);
25 + } catch (e) {
26 + if (isNotFound(e)) return null;
27 + if (isNotBuilt(e)) return 'not-built';
28 + throw e;
29 + }
30 +}
31 +
32 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
33 + const { slug } = await params;
34 + const data = await load(slug);
35 + if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } };
36 + const name = data.group.name ?? slug;
37 + const title = t('region.title', { name });
38 + const description = t('region.description', { name, n: data.n_members });
39 + const canonical = routes.region(data.group.slug ?? slug);
40 + return { title, description, alternates: { canonical }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } };
41 +}
42 +
43 +/** Extra indicators offered in the member ranking picker besides the headline ones. */
44 +const EXTRA_RANKING = ['gdp-per-capita-ppp', 'population-growth', 'fertility-rate', 'renewable-electricity-share', 'internet-users', 'co2-per-capita', 'government-debt-pct-gdp', 'median-age'];
45 +
46 +export default async function RegionPage({ params }: { params: Promise<Params> }) {
47 + const { slug } = await params;
48 + const data = await load(slug);
49 + if (data === null) notFound();
50 + if (data === 'not-built') return <NotBuiltState />;
51 +
52 + const g = data.group;
53 + const name = g.name ?? slug;
54 + const gslug = g.slug ?? g.id;
55 + const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? '');
56 + const [countriesRes, extraInd] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true }))]);
57 + const countries = countriesRes?.items ?? [];
58 + const memberIds = data.members.map((m) => m.id);
59 + const aggs = Object.values(data.aggregates);
60 + const options: IndicatorCard[] = [...data.headline_indicators, ...(extraInd?.items ?? []).filter((i) => EXTRA_RANKING.includes(i.slug) || i.featured)].filter((i) => i.ranking_eligible !== false);
61 + const bySize = [...data.members].sort((a, b) => (b.values.population?.value ?? 0) - (a.values.population?.value ?? 0));
62 + const compareSlugs = bySize.slice(0, 8).map((m) => m.slug ?? m.id);
63 + const compareAll = data.n_members <= 8;
64 +
65 + return (
66 + <>
67 + <PageHeader
68 + crumbs={[{ href: routes.regions(), label: t('regions.title') }]}
69 + eyebrow={kindLabel}
70 + title={name}
71 + lede={data.description}
72 + meta={`${t('regions.members', { n: grouped(data.n_members) })}${g.wb_code ? ` · ${g.wb_code}` : ''}`}
73 + actions={
74 + compareSlugs.length >= 2 ? (
75 + <Link href={routes.compare(...compareSlugs)} className={ACTION_CLS}>
76 + {compareAll ? t('region.compareAll') : t('region.compareFirst', { n: compareSlugs.length })}
77 + </Link>
78 + ) : null
79 + }
80 + />
81 +
82 + <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0">
83 + {aggs.length === 0 ? (
84 + <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>
85 + ) : (
86 + <dl className="grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
87 + {aggs.map((a) => (
88 + <div key={a.indicator.slug} className="border-t border-rule py-3">
89 + <dt className="text-xs font-medium text-ink-2">
90 + <Link href={routes.indicator(a.indicator.slug)} className="link-quiet -my-3 inline-flex min-h-[44px] items-center md:my-0 md:min-h-0">
91 + {a.indicator.short_name ?? a.indicator.name}
92 + </Link>
93 + <span className="ml-1.5 rounded-xs border border-rule px-1 text-2xs uppercase tracking-wide text-ink-3">{tOpt(`regions.${a.kind}`, a.kind)}</span>
94 + </dt>
95 + <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink">{formatValue(a.value, a.indicator)}</dd>
96 + <dd className="tnum mt-1 text-xs text-ink-3">{t('region.aggregates.n', { n: grouped(a.n ?? 0), year: a.year ?? '' })}</dd>
97 + </div>
98 + ))}
99 + </dl>
100 + )}
101 + </Section>
102 +
103 + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
104 + <Section id="ranking" title={t('region.ranking.title')} subtitle={t('region.ranking.sub')}>
105 + <MemberRanking groupSlug={gslug} groupName={name} initial={data.ranking} options={options} />
106 + </Section>
107 + <Section id="map" title={t('region.map.title')} subtitle={t('region.map.sub', { n: grouped(data.n_members), name })}>
108 + {countries.length ? <MemberMap members={memberIds} countries={countries} name={name} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}
109 + </Section>
110 + </div>
111 +
112 + <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}>
113 + {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}
114 + </Section>
115 + </>
116 + );
117 +}
added apps/web/src/app/regions/page.tsx +94 −0
@@ -0,0 +1,94 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { t, tOpt } from '@/i18n';
4 +import { isNotBuilt, safe } from '@/lib/api';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { compact, formatValue, grouped, isNum } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { RegionItem, RegionResponse } from '@/lib/types-explore';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { Section } from '@/components/data/section';
11 +import { PageHeader } from '@/components/explore/page-header';
12 +
13 +export const revalidate = 900;
14 +
15 +export const metadata: Metadata = {
16 + title: t('regions.title'),
17 + description: t('regions.sub'),
18 + alternates: { canonical: routes.regions() },
19 +};
20 +
21 +const KIND_ORDER = ['world', 'region', 'continent', 'income', 'org', 'custom'] as const;
22 +
23 +export default async function RegionsPage() {
24 + let items: RegionItem[] = [];
25 + let notBuilt = false;
26 + try {
27 + items = (await apiExplore.regions()).items;
28 + } catch (e) {
29 + if (isNotBuilt(e)) notBuilt = true;
30 + else throw e;
31 + }
32 + if (notBuilt) {
33 + return (
34 + <>
35 + <PageHeader title={t('regions.title')} lede={t('regions.sub')} />
36 + <NotBuiltState />
37 + </>
38 + );
39 + }
40 + // Third figure (median life expectancy) comes from the group detail — fetched in parallel, cached by ISR.
41 + const details = await Promise.all(items.map((g) => safe(apiExplore.region(g.slug ?? g.id))));
42 + const detailById = new Map<string, RegionResponse | null>(items.map((g, i) => [g.id, details[i] ?? null]));
43 +
44 + const groups = KIND_ORDER.map((kind) => ({ kind, rows: items.filter((g) => (g.kind ?? 'custom') === kind) })).filter((s) => s.rows.length);
45 + const other = items.filter((g) => !KIND_ORDER.includes((g.kind ?? 'custom') as (typeof KIND_ORDER)[number]));
46 + if (other.length) groups.push({ kind: 'custom', rows: other });
47 +
48 + return (
49 + <>
50 + <PageHeader title={t('regions.title')} lede={t('regions.sub')} meta={`${grouped(items.length)} groups`} />
51 + {groups.map(({ kind, rows }, gi) => (
52 + <Section key={kind} id={kind} title={tOpt(`regions.kind.${kind}`, kind)} className={gi === 0 ? 'border-t-0' : undefined} tight={kind === 'world'}>
53 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
54 + {rows.map((g) => {
55 + const d = detailById.get(g.id);
56 + const le = d?.aggregates['life-expectancy'];
57 + const gdppc = d?.aggregates['gdp-per-capita'];
58 + return (
59 + <li key={g.id} className="border-t border-rule">
60 + <Link href={routes.region(g.slug ?? g.id)} className="group flex min-h-[64px] flex-col justify-center py-3">
61 + <span className="flex items-baseline justify-between gap-3">
62 + <span className="text-sm font-semibold text-ink group-hover:text-accent">{g.name}</span>
63 + <span className="tnum shrink-0 text-xs text-ink-3">{t('regions.members', { n: g.n_members ?? d?.n_members ?? 0 })}</span>
64 + </span>
65 + <dl className="tnum mt-1 grid grid-cols-3 gap-x-3 text-xs">
66 + <div className="min-w-0">
67 + <dt className="truncate text-2xs text-ink-3">
68 + {t('regions.population')} <span className="text-ink-3/80">({t('regions.sum')})</span>
69 + </dt>
70 + <dd className="truncate text-ink">{isNum(g.population_latest) ? compact(g.population_latest) : t('common.na')}</dd>
71 + </div>
72 + <div className="min-w-0">
73 + <dt className="truncate text-2xs text-ink-3">
74 + {t('regions.gdp')} <span className="text-ink-3/80">({t('regions.sum')})</span>
75 + </dt>
76 + <dd className="truncate text-ink">{isNum(g.gdp_latest) ? `US$${compact(g.gdp_latest)}` : t('common.na')}</dd>
77 + </div>
78 + <div className="min-w-0">
79 + <dt className="truncate text-2xs text-ink-3">
80 + {le ? `${t('regions.lifeExpectancy')} (${t('regions.median')})` : gdppc ? `${t('common.gdpPerCapita')} (${t('regions.weighted_mean')})` : t('regions.lifeExpectancy')}
81 + </dt>
82 + <dd className="truncate text-ink">{le ? formatValue(le.value, le.indicator) : gdppc ? formatValue(gdppc.value, gdppc.indicator) : t('common.na')}</dd>
83 + </div>
84 + </dl>
85 + </Link>
86 + </li>
87 + );
88 + })}
89 + </ul>
90 + </Section>
91 + ))}
92 + </>
93 + );
94 +}
modified apps/web/src/app/sitemap.ts +25 −1
@@ -1,5 +1,7 @@
1 1 import type { MetadataRoute } from 'next';
2 2 import { api, safe } from '@/lib/api';
3 +import { apiCompare } from '@/lib/api-compare';
4 +import { apiExplore } from '@/lib/api-explore';
3 5 import { SITE_URL, routes } from '@/lib/site';
4 6 import { TOPICS } from '@/lib/topics';
5 7
@@ -22,5 +24,27 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
22 24 ...TOPICS.map((tp) => ({ url: `${SITE_URL}${routes.countryTopic(slug, tp.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 })),
23 25 ];
24 26 });
25 − return [...staticEntries, ...countryEntries];
27 + // Indicators, regions, sources + static reference pages (explore agent).
28 + const [indicatorsRes, regionsRes, sourcesRes] = await Promise.all([safe(apiExplore.indicators()), safe(apiExplore.regions()), safe(apiExplore.sources())]);
29 + const exploreStatic: MetadataRoute.Sitemap = [
30 + { url: `${SITE_URL}${routes.indicators()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
31 + { url: `${SITE_URL}${routes.regions()}`, lastModified, changeFrequency: 'weekly', priority: 0.7 },
32 + { url: `${SITE_URL}${routes.explore()}`, lastModified, changeFrequency: 'daily', priority: 0.7 },
33 + { url: `${SITE_URL}${routes.changes()}`, lastModified, changeFrequency: 'daily', priority: 0.6 },
34 + { url: `${SITE_URL}${routes.data()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
35 + { url: `${SITE_URL}${routes.sources()}`, lastModified, changeFrequency: 'weekly', priority: 0.5 },
36 + { url: `${SITE_URL}${routes.methodology()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
37 + { url: `${SITE_URL}${routes.api()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
38 + ];
39 + const indicatorEntries: MetadataRoute.Sitemap = (indicatorsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.indicator(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 }));
40 + const regionEntries: MetadataRoute.Sitemap = (regionsRes?.items ?? []).map((g) => ({ url: `${SITE_URL}${routes.region(g.slug ?? g.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 }));
41 + const sourceEntries: MetadataRoute.Sitemap = (sourcesRes?.items ?? []).map((s) => ({ url: `${SITE_URL}${routes.source(s.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.4 }));
42 + // Compare landing + rankings (compare/rankings agent).
43 + const rankingsRes = await safe(apiCompare.rankings());
44 + const rankingEntries: MetadataRoute.Sitemap = [
45 + { url: `${SITE_URL}${routes.compare()}`, lastModified, changeFrequency: 'monthly', priority: 0.7 },
46 + { url: `${SITE_URL}${routes.rankings()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
47 + ...(rankingsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.ranking(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 })),
48 + ];
49 + return [...staticEntries, ...countryEntries, ...exploreStatic, ...indicatorEntries, ...regionEntries, ...sourceEntries, ...rankingEntries];
26 50 }
added apps/web/src/app/sources/[id]/page.tsx +190 −0
@@ -0,0 +1,190 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import { t, tOpt } from '@/i18n';
6 +import { isNotBuilt, isNotFound } from '@/lib/api';
7 +import { apiExplore } from '@/lib/api-explore';
8 +import { cn } from '@/lib/cn';
9 +import { compact, formatDate, grouped } from '@/lib/format';
10 +import { routes } from '@/lib/site';
11 +import type { ImportRun, SourceIndicatorRow, SourceResponse } from '@/lib/types-explore';
12 +import { DataTable, type DataTableColumn } from '@/components/data/data-table';
13 +import { NotBuiltState } from '@/components/data/empty-state';
14 +import { FreshnessBadge } from '@/components/data/freshness-badge';
15 +import { Section } from '@/components/data/section';
16 +import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';
17 +import { FooterCredits } from '@/components/layout/site-footer';
18 +
19 +export const revalidate = 900;
20 +
21 +type Params = { id: string };
22 +
23 +async function load(id: string): Promise<SourceResponse | 'not-built' | null> {
24 + try {
25 + return await apiExplore.source(id, 60);
26 + } catch (e) {
27 + if (isNotFound(e)) return null;
28 + if (isNotBuilt(e)) return 'not-built';
29 + throw e;
30 + }
31 +}
32 +
33 +export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
34 + const { id } = await params;
35 + const data = await load(id);
36 + if (!data || data === 'not-built') return { title: t('source.notFound'), robots: { index: false } };
37 + const s = data.source;
38 + const title = t('source.title', { name: s.name ?? s.id });
39 + const description = t('source.description', { name: s.name ?? s.id, n: s.n_indicators ?? data.indicators.length, licence: s.licence ?? '' });
40 + return { title, description, alternates: { canonical: routes.source(s.id) }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: routes.source(s.id), type: 'article' } };
41 +}
42 +
43 +function RunStatus({ status }: { status: string | null }) {
44 + const s = (status ?? '').toLowerCase();
45 + const label = tOpt(`source.run.${s}`, status ?? t('common.na'));
46 + return <span className={cn('inline-flex items-center rounded-sm border px-1.5 py-0.5 text-2xs font-medium', s === 'ok' ? 'border-rule text-ink-2' : s === 'partial' ? 'border-warn/40 text-warn' : s === 'failed' || s === 'quarantined' ? 'border-down/40 text-down' : 'border-rule text-ink-3')}>{label}</span>;
47 +}
48 +
49 +function duration(r: ImportRun): string {
50 + if (!r.started_at || !r.finished_at) return t('common.na');
51 + const s = (new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()) / 1000;
52 + if (!Number.isFinite(s) || s < 0) return t('common.na');
53 + return s < 60 ? `${Math.round(s)} s` : `${Math.round(s / 60)} min`;
54 +}
55 +
56 +export default async function SourcePage({ params }: { params: Promise<Params> }) {
57 + const { id } = await params;
58 + const data = await load(id);
59 + if (data === null) notFound();
60 + if (data === 'not-built') return <NotBuiltState />;
61 + const s = data.source;
62 + const name = s.name ?? s.id;
63 +
64 + const indCols: DataTableColumn<SourceIndicatorRow>[] = [
65 + {
66 + key: 'indicator',
67 + header: t('source.series.indicator'),
68 + cell: (r) => (
69 + <Link href={routes.indicator(r.slug)} className="link-quiet inline-flex min-h-[32px] items-center text-ink">
70 + {r.name ?? r.slug}
71 + </Link>
72 + ),
73 + },
74 + { key: 'code', header: t('source.series.code'), cell: (r) => <code className="font-mono text-xs text-ink-2">{[r.dataset, r.series_code].filter(Boolean).join(' · ')}</code> },
75 + { key: 'priority', header: t('source.series.priority'), numeric: true, cell: (r) => (r.priority != null ? `#${r.priority}` : t('common.na')) },
76 + { key: 'coverage', header: t('source.series.coverage'), numeric: true, cell: (r) => (r.n_countries != null ? `${grouped(r.n_countries)} · ${r.n_observations != null ? compact(r.n_observations) : ''}` : t('common.na')) },
77 + { key: 'last', header: t('source.series.lastYear'), numeric: true, cell: (r) => r.last_year ?? t('common.na'), hideOnMobile: true },
78 + { key: 'status', header: t('source.series.status'), cell: (r) => <RunStatus status={r.last_status} /> },
79 + ];
80 + const runCols: DataTableColumn<ImportRun>[] = [
81 + { key: 'started', header: t('source.runs.started'), cell: (r) => <span className="tnum text-xs">{r.started_at ? `${formatDate(r.started_at)} ${r.started_at.slice(11, 16)}` : t('common.na')}</span> },
82 + { key: 'dataset', header: t('source.runs.dataset'), cell: (r) => <span className="break-all font-mono text-xs text-ink-2">{r.dataset}</span> },
83 + { key: 'status', header: t('source.runs.status'), cell: (r) => <RunStatus status={r.status} /> },
84 + { key: 'rows', header: t('source.runs.rows'), numeric: true, cell: (r) => (r.rows_valid != null ? grouped(r.rows_valid) : t('common.na')) },
85 + { key: 'warn', header: t('source.runs.warnings'), numeric: true, cell: (r) => (r.warnings != null ? grouped(r.warnings) : t('common.na')), hideOnMobile: true },
86 + { key: 'err', header: t('source.runs.errors'), numeric: true, cell: (r) => (r.errors ? <span className="text-down">{grouped(r.errors)}</span> : r.errors === 0 ? '0' : t('common.na')), hideOnMobile: true },
87 + { key: 'dur', header: t('source.runs.duration'), numeric: true, cell: (r) => duration(r), hideOnMobile: true },
88 + ];
89 +
90 + return (
91 + <>
92 + <PageHeader
93 + crumbs={[{ href: routes.sources(), label: t('sources.title') }]}
94 + eyebrow={s.organization}
95 + title={name}
96 + lede={s.attribution}
97 + meta={
98 + <>
99 + <code className="font-mono">{s.id}</code>
100 + {s.licence ? ` · ${s.licence}` : ''}
101 + {s.n_indicators != null ? ` · ${t('sources.indicators', { n: grouped(s.n_indicators) })}` : ''}
102 + {s.n_observations != null ? ` · ${t('sources.observations', { n: compact(s.n_observations) })}` : ''}
103 + </>
104 + }
105 + actions={
106 + s.url ? (
107 + <a href={s.url} target="_blank" rel="noopener noreferrer" className={ACTION_CLS}>
108 + <ExternalLink size={14} aria-hidden /> {t('source.open', { name })}
109 + </a>
110 + ) : null
111 + }
112 + />
113 +
114 + <section aria-label={t('source.freshness.title')} className="border-y border-rule">
115 + <dl className="grid grid-cols-1 gap-y-3 py-4 sm:grid-cols-3 sm:divide-x sm:divide-rule">
116 + <div className="sm:pr-4">
117 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.sourceUpdated')}</dt>
118 + <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>
119 + </div>
120 + <div className="sm:px-4">
121 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.retrieved')}</dt>
122 + <dd className="tnum mt-1 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">
123 + {formatDate(s.last_success_at ?? data.freshness.retrieved_at)} <FreshnessBadge retrievedAt={s.last_success_at ?? data.freshness.retrieved_at} />
124 + </dd>
125 + </div>
126 + <div className="sm:pl-4">
127 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.built')}</dt>
128 + <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.built_at)}</dd>
129 + </div>
130 + </dl>
131 + </section>
132 +
133 + <Section id="about" title={t('source.about')} className="border-t-0" tight>
134 + <dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
135 + <div>
136 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.organisation')}</dt>
137 + <dd className="text-ink">{s.organization ?? t('common.na')}</dd>
138 + </div>
139 + <div>
140 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.licence')}</dt>
141 + <dd className="text-ink">{s.licence ?? t('common.na')}</dd>
142 + </div>
143 + <div>
144 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.attribution')}</dt>
145 + <dd className="text-ink">{s.attribution ?? t('common.na')}</dd>
146 + </div>
147 + <div>
148 + <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.apiBase')}</dt>
149 + <dd className="break-all font-mono text-xs text-ink">{s.api_base ?? t('common.na')}</dd>
150 + </div>
151 + {s.notes ? (
152 + <div className="sm:col-span-2">
153 + <dd className="text-ink-2">{s.notes}</dd>
154 + </div>
155 + ) : null}
156 + </dl>
157 + </Section>
158 +
159 + {data.datasets.length ? (
160 + <Section id="datasets" title={t('source.datasets.title')} subtitle={t('source.datasets.sub')} tight>
161 + <DataTable
162 + rows={data.datasets}
163 + rowKey={(d) => d.dataset ?? 'null'}
164 + caption={t('source.datasets.title')}
165 + columns={[
166 + { key: 'dataset', header: t('source.datasets.dataset'), cell: (d) => <span className="font-medium text-ink">{d.dataset ?? t('common.na')}</span> },
167 + { key: 'obs', header: t('source.datasets.observations'), numeric: true, cell: (d) => (d.n_observations != null ? grouped(d.n_observations) : t('common.na')) },
168 + { key: 'ind', header: t('source.datasets.indicators'), numeric: true, cell: (d) => (d.n_indicators != null ? grouped(d.n_indicators) : t('common.na')) },
169 + { key: 'cty', header: t('source.datasets.countries'), numeric: true, cell: (d) => (d.n_countries != null ? grouped(d.n_countries) : t('common.na')) },
170 + { key: 'years', header: t('source.datasets.years'), numeric: true, cell: (d) => (d.first_year && d.last_year ? `${d.first_year}–${d.last_year}` : t('common.na')) },
171 + ]}
172 + />
173 + </Section>
174 + ) : null}
175 +
176 + <Section id="series" title={t('source.series.title')} subtitle={t('source.series.sub', { n: grouped(data.indicators.length) })}>
177 + {data.indicators.length ? <DataTable rows={data.indicators} rowKey={(r) => `${r.slug}-${r.series_code}`} columns={indCols} caption={t('source.series.title')} dense /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}
178 + </Section>
179 +
180 + <Section id="runs" title={t('source.runs.title')} subtitle={t('source.runs.sub', { n: grouped(data.import_runs.length) })}>
181 + {data.import_runs.length ? <DataTable rows={data.import_runs} rowKey={(r) => `${r.run_id}-${r.dataset}`} columns={runCols} caption={t('source.runs.title')} dense /> : <p className="text-sm text-ink-3">{t('source.runs.none')}</p>}
182 + </Section>
183 +
184 + <div className="border-t border-rule pt-4 text-sm text-ink-2">
185 + <span className="mr-2 font-medium text-ink">{t('sources.contact')}</span>
186 + <FooterCredits className="mt-1 inline text-xs" />
187 + </div>
188 + </>
189 + );
190 +}
added apps/web/src/app/sources/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { isNotBuilt } from '@/lib/api';
6 +import { apiExplore } from '@/lib/api-explore';
7 +import { compact, formatDate, grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { SourcesResponse } from '@/lib/types-explore';
10 +import { NotBuiltState } from '@/components/data/empty-state';
11 +import { FreshnessBadge } from '@/components/data/freshness-badge';
12 +import { PageHeader } from '@/components/explore/page-header';
13 +import { FooterCredits } from '@/components/layout/site-footer';
14 +
15 +export const revalidate = 900;
16 +
17 +export const metadata: Metadata = {
18 + title: t('sources.title'),
19 + description: t('sources.sub', { n: 9 }),
20 + alternates: { canonical: routes.sources() },
21 +};
22 +
23 +export default async function SourcesPage() {
24 + let data: SourcesResponse | null = null;
25 + try {
26 + data = await apiExplore.sources();
27 + } catch (e) {
28 + if (!isNotBuilt(e)) throw e;
29 + }
30 + const items = data?.items ?? [];
31 + return (
32 + <>
33 + <PageHeader title={t('sources.title')} lede={t('sources.sub', { n: grouped(items.length || 9) })} />
34 + {!data ? (
35 + <NotBuiltState />
36 + ) : (
37 + <>
38 + <p className="max-w-prose border-y border-rule py-4 text-sm leading-relaxed text-ink-2">{t('sources.rule')}</p>
39 + <ol className="divide-y divide-rule">
40 + {items.map((s) => (
41 + <li key={s.id} className="grid gap-x-8 gap-y-2 py-4 md:grid-cols-[minmax(0,1fr)_16rem]">
42 + <div className="min-w-0">
43 + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
44 + <Link href={routes.source(s.id)} className="link-quiet text-lg font-semibold text-ink">
45 + {s.name ?? s.id}
46 + </Link>
47 + <span className="text-sm text-ink-3">{s.organization}</span>
48 + {s.licence ? <span className="rounded-xs border border-rule px-1 py-px text-2xs text-ink-2">{s.licence}</span> : null}
49 + </div>
50 + {s.attribution ? <p className="mt-1 text-sm text-ink-2">{s.attribution}</p> : null}
51 + <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
52 + <code className="font-mono text-ink-3">{s.id}</code>
53 + {s.url ? (
54 + <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[32px] items-center gap-1 text-accent hover:underline">
55 + <ExternalLink size={12} aria-hidden /> {t('sources.website')}
56 + </a>
57 + ) : null}
58 + <Link href={routes.source(s.id)} className="inline-flex min-h-[32px] items-center text-accent hover:underline">
59 + {t('common.more')} →
60 + </Link>
61 + </div>
62 + </div>
63 + <dl className="tnum grid grid-cols-2 gap-x-4 gap-y-1 text-sm md:grid-cols-1">
64 + <div className="flex justify-between gap-2 md:justify-start">
65 + <dt className="text-ink-3">{t('sources.observations', { n: '' }).trim()}</dt>
66 + <dd className="text-ink">{s.n_observations != null ? compact(s.n_observations) : t('common.na')}</dd>
67 + </div>
68 + <div className="flex justify-between gap-2 md:justify-start">
69 + <dt className="text-ink-3">{t('sources.indicators', { n: '' }).trim()}</dt>
70 + <dd className="text-ink">{s.n_indicators != null ? grouped(s.n_indicators) : t('common.na')}</dd>
71 + </div>
72 + <div className="col-span-2 flex flex-wrap items-center gap-2 md:col-span-1">
73 + <dt className="sr-only">{t('sources.lastImport', { date: '' })}</dt>
74 + <dd className="text-xs text-ink-3">{t('sources.lastImport', { date: formatDate(s.last_success_at ?? s.last_retrieved_at) })}</dd>
75 + <dd>
76 + <FreshnessBadge retrievedAt={s.last_success_at ?? s.last_retrieved_at} />
77 + </dd>
78 + </div>
79 + </dl>
80 + </li>
81 + ))}
82 + </ol>
83 + <div className="mt-8 border-t border-rule pt-4 text-sm text-ink-2">
84 + <span className="mr-2 font-medium text-ink">{t('sources.contact')}</span>
85 + <FooterCredits className="mt-1 inline text-xs" />
86 + </div>
87 + </>
88 + )}
89 + </>
90 + );
91 +}
added apps/web/src/components/admin/admin-nav.tsx +34 −0
@@ -0,0 +1,34 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { usePathname } from 'next/navigation';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { routes } from '@/lib/site';
7 +
8 +const ITEMS = [
9 + { href: routes.admin(), key: 'admin.nav.overview' },
10 + { href: routes.admin('coverage'), key: 'admin.nav.coverage' },
11 + { href: routes.admin('runs'), key: 'admin.nav.runs' },
12 + { href: routes.admin('issues'), key: 'admin.nav.issues' },
13 + { href: routes.admin('raw'), key: 'admin.nav.raw' },
14 +] as const;
15 +
16 +export function AdminNav() {
17 + const pathname = usePathname();
18 + return (
19 + <nav aria-label={t('admin.title')} className="scrollbar-none -mx-1 min-w-0 overflow-x-auto">
20 + <ul className="flex items-center gap-0.5">
21 + {ITEMS.map((it) => {
22 + const active = pathname === it.href;
23 + return (
24 + <li key={it.href} className="shrink-0">
25 + <Link href={it.href} aria-current={active ? 'page' : undefined} className={cn('inline-flex h-10 items-center rounded-sm px-2.5 text-sm md:h-8', active ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
26 + {t(it.key)}
27 + </Link>
28 + </li>
29 + );
30 + })}
31 + </ul>
32 + </nav>
33 + );
34 +}
added apps/web/src/components/admin/admin-ui.tsx +100 −0
@@ -0,0 +1,100 @@
1 +import type { ReactNode } from 'react';
2 +import { t } from '@/i18n';
3 +import { cn } from '@/lib/cn';
4 +
5 +/** Dense, plain admin primitives: heading row, key/value list, compact table, status pill. */
6 +export function AdminSection({ title, sub, actions, children, className }: { title: string; sub?: ReactNode; actions?: ReactNode; children: ReactNode; className?: string }) {
7 + return (
8 + <section className={cn('border-t border-rule py-4 first:border-t-0', className)}>
9 + <div className="mb-2 flex flex-wrap items-end justify-between gap-2">
10 + <div>
11 + <h2 className="text-base font-semibold text-ink">{title}</h2>
12 + {sub ? <p className="text-xs text-ink-3">{sub}</p> : null}
13 + </div>
14 + {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
15 + </div>
16 + {children}
17 + </section>
18 + );
19 +}
20 +
21 +export function Kv({ rows, className }: { rows: Array<[string, ReactNode]>; className?: string }) {
22 + return (
23 + <dl className={cn('grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs', className)}>
24 + {rows.map(([k, v]) => (
25 + <div key={k} className="contents">
26 + <dt className="text-ink-3">{k}</dt>
27 + <dd className="tnum break-all text-ink">{v ?? t('admin.na')}</dd>
28 + </div>
29 + ))}
30 + </dl>
31 + );
32 +}
33 +
34 +export interface Col<T> {
35 + key: string;
36 + header: ReactNode;
37 + cell: (row: T) => ReactNode;
38 + numeric?: boolean;
39 + className?: string;
40 +}
41 +
42 +/** Compact table with horizontal scroll on narrow screens (admin only — dense by design). */
43 +export function AdminTable<T>({ rows, columns, rowKey, className, empty }: { rows: T[]; columns: Col<T>[]; rowKey: (r: T, i: number) => string; className?: string; empty?: ReactNode }) {
44 + if (rows.length === 0) return <p className="py-3 text-xs text-ink-3">{empty ?? t('common.noDataLong')}</p>;
45 + return (
46 + <div className={cn('-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0', className)}>
47 + <table className="w-full min-w-[640px] border-collapse text-xs">
48 + <thead>
49 + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">
50 + {columns.map((c) => (
51 + <th key={c.key} scope="col" className={cn('py-1.5 pr-3 font-medium', c.numeric && 'text-right', c.className)}>
52 + {c.header}
53 + </th>
54 + ))}
55 + </tr>
56 + </thead>
57 + <tbody className="divide-y divide-rule">
58 + {rows.map((r, i) => (
59 + <tr key={rowKey(r, i)} className="hover:bg-surface-2/60">
60 + {columns.map((c) => (
61 + <td key={c.key} className={cn('py-1.5 pr-3 align-top', c.numeric && 'tnum text-right', c.className)}>
62 + {c.cell(r)}
63 + </td>
64 + ))}
65 + </tr>
66 + ))}
67 + </tbody>
68 + </table>
69 + </div>
70 + );
71 +}
72 +
73 +export function Pill({ tone, children }: { tone: 'ok' | 'warn' | 'bad' | 'muted'; children: ReactNode }) {
74 + return (
75 + <span className={cn('inline-flex items-center rounded-sm border px-1.5 py-px text-2xs font-medium', tone === 'ok' && 'border-up/40 text-up', tone === 'warn' && 'border-warn/40 text-warn', tone === 'bad' && 'border-down/40 text-down', tone === 'muted' && 'border-rule text-ink-3')}>
76 + {children}
77 + </span>
78 + );
79 +}
80 +
81 +export function statusTone(s: string | null | undefined): 'ok' | 'warn' | 'bad' | 'muted' {
82 + const v = (s ?? '').toLowerCase();
83 + if (v === 'ok' || v === 'verified') return 'ok';
84 + if (v === 'partial' || v === 'warning' || v === 'stale') return 'warn';
85 + if (v === 'failed' || v === 'quarantined' || v === 'error') return 'bad';
86 + return 'muted';
87 +}
88 +
89 +export function bytes(n: number | null | undefined): string {
90 + if (n == null) return t('admin.na');
91 + if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
92 + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;
93 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`;
94 + return `${n} B`;
95 +}
96 +
97 +export function ts(iso: string | null | undefined): string {
98 + if (!iso) return t('admin.na');
99 + return iso.replace('T', ' ').replace(/\.\d+/, '').replace(/\+00:00$/, 'Z');
100 +}
added apps/web/src/components/admin/coverage-table.tsx +128 −0
@@ -0,0 +1,128 @@
1 +'use client';
2 +import { ArrowDown, ArrowUp } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useMemo, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { cn } from '@/lib/cn';
7 +import { grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { AdminCoverageCountry, AdminCoverageIndicator } from '@/lib/types-explore';
10 +
11 +function useSort<T extends object>(rows: T[], initial: { key: keyof T & string; dir: 'asc' | 'desc' }) {
12 + const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>(initial);
13 + const sorted = useMemo(() => {
14 + const copy = [...rows];
15 + const get = (r: T): unknown => (r as Record<string, unknown>)[sort.key];
16 + copy.sort((a, b) => {
17 + const va = get(a);
18 + const vb = get(b);
19 + if (va == null && vb == null) return 0;
20 + if (va == null) return 1;
21 + if (vb == null) return -1;
22 + const cmp = typeof va === 'number' && typeof vb === 'number' ? va - vb : String(va).localeCompare(String(vb));
23 + return sort.dir === 'asc' ? cmp : -cmp;
24 + });
25 + return copy;
26 + }, [rows, sort]);
27 + const toggle = (key: string) => setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' }));
28 + return { sorted, sort, toggle };
29 +}
30 +
31 +function Th({ k, label, sort, toggle, numeric }: { k: string; label: string; sort: { key: string; dir: 'asc' | 'desc' }; toggle: (k: string) => void; numeric?: boolean }) {
32 + const active = sort.key === k;
33 + return (
34 + <th scope="col" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-1.5 pr-3 font-medium', numeric && 'text-right')}>
35 + <button type="button" onClick={() => toggle(k)} className={cn('inline-flex min-h-[28px] items-center gap-1 whitespace-nowrap text-2xs uppercase tracking-wide hover:text-ink', active ? 'text-ink' : 'text-ink-3')}>
36 + {label}
37 + {active ? sort.dir === 'asc' ? <ArrowUp size={11} aria-hidden /> : <ArrowDown size={11} aria-hidden /> : null}
38 + </button>
39 + </th>
40 + );
41 +}
42 +
43 +/** Coverage matrix: indicators × countries (two sortable tables, filter box). */
44 +export function CoverageTable({ indicators, countries }: { indicators: AdminCoverageIndicator[]; countries: AdminCoverageCountry[] }) {
45 + const [view, setView] = useState<'indicators' | 'countries'>('indicators');
46 + const [q, setQ] = useState('');
47 + const ql = q.trim().toLowerCase();
48 + const ind = useSort(indicators.filter((r) => !ql || r.indicator_id.includes(ql) || (r.topic ?? '').includes(ql) || (r.primary_source_id ?? '').includes(ql)), { key: 'n_countries', dir: 'asc' });
49 + const cty = useSort(countries.filter((r) => !ql || r.id.toLowerCase().includes(ql) || (r.short_name ?? '').toLowerCase().includes(ql)), { key: 'coverage_pct', dir: 'desc' });
50 +
51 + return (
52 + <div className="min-w-0">
53 + <div className="mb-2 flex flex-wrap items-center gap-2">
54 + <div role="tablist" className="flex gap-0.5">
55 + {(['indicators', 'countries'] as const).map((v) => (
56 + <button key={v} role="tab" type="button" aria-selected={view === v} onClick={() => setView(v)} className={cn('inline-flex h-9 items-center rounded-sm px-2.5 text-xs', view === v ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2')}>
57 + {t(`admin.coverage.${v}` as 'admin.coverage.indicators')} <span className="tnum ml-1 opacity-70">{v === 'indicators' ? indicators.length : countries.length}</span>
58 + </button>
59 + ))}
60 + </div>
61 + <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('search.placeholder.short')} aria-label={t('nav.search')} className="h-9 w-56 rounded-sm border border-rule bg-surface px-2 text-xs outline-none focus:border-accent" />
62 + </div>
63 + <div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
64 + {view === 'indicators' ? (
65 + <table className="w-full min-w-[760px] border-collapse text-xs">
66 + <thead>
67 + <tr className="border-b border-rule text-left">
68 + <Th k="indicator_id" label={t('admin.coverage.indicator')} sort={ind.sort} toggle={ind.toggle} />
69 + <Th k="topic" label={t('admin.coverage.topic')} sort={ind.sort} toggle={ind.toggle} />
70 + <Th k="primary_source_id" label={t('admin.coverage.source')} sort={ind.sort} toggle={ind.toggle} />
71 + <Th k="n_countries" label={t('admin.coverage.nCountries')} sort={ind.sort} toggle={ind.toggle} numeric />
72 + <Th k="n_latest" label={t('admin.coverage.nLatest')} sort={ind.sort} toggle={ind.toggle} numeric />
73 + <Th k="n_observations" label={t('admin.coverage.nObs')} sort={ind.sort} toggle={ind.toggle} numeric />
74 + <Th k="first_year" label={t('admin.coverage.years')} sort={ind.sort} toggle={ind.toggle} numeric />
75 + <Th k="latest_year" label={t('admin.coverage.latestYear')} sort={ind.sort} toggle={ind.toggle} numeric />
76 + <Th k="latest_source_updated_at" label={t('admin.coverage.updated')} sort={ind.sort} toggle={ind.toggle} />
77 + </tr>
78 + </thead>
79 + <tbody className="divide-y divide-rule">
80 + {ind.sorted.map((r) => (
81 + <tr key={r.indicator_id} className="hover:bg-surface-2/60">
82 + <td className="py-1 pr-3">
83 + <Link href={routes.indicator(r.indicator_id)} className="font-mono text-ink hover:text-accent">
84 + {r.indicator_id}
85 + </Link>
86 + </td>
87 + <td className="py-1 pr-3 text-ink-2">{r.topic}</td>
88 + <td className="py-1 pr-3 text-ink-2">{r.primary_source_id}</td>
89 + <td className={cn('tnum py-1 pr-3 text-right', (r.n_countries ?? 0) < 50 && 'text-warn')}>{r.n_countries != null ? grouped(r.n_countries) : '—'}</td>
90 + <td className="tnum py-1 pr-3 text-right">{r.n_latest != null ? grouped(r.n_latest) : '—'}</td>
91 + <td className="tnum py-1 pr-3 text-right">{r.n_observations != null ? grouped(r.n_observations) : '—'}</td>
92 + <td className="tnum py-1 pr-3 text-right">{r.first_year && r.last_year ? `${r.first_year}–${r.last_year}` : '—'}</td>
93 + <td className="tnum py-1 pr-3 text-right">{r.latest_year ?? '—'}</td>
94 + <td className="tnum py-1 pr-3 text-ink-2">{r.latest_source_updated_at?.slice(0, 10) ?? '—'}</td>
95 + </tr>
96 + ))}
97 + </tbody>
98 + </table>
99 + ) : (
100 + <table className="w-full min-w-[560px] border-collapse text-xs">
101 + <thead>
102 + <tr className="border-b border-rule text-left">
103 + <Th k="short_name" label={t('admin.coverage.country')} sort={cty.sort} toggle={cty.toggle} />
104 + <Th k="id" label="ISO3" sort={cty.sort} toggle={cty.toggle} />
105 + <Th k="n_indicators" label={t('admin.coverage.indicators')} sort={cty.sort} toggle={cty.toggle} numeric />
106 + <Th k="n_observations" label={t('admin.coverage.nObs')} sort={cty.sort} toggle={cty.toggle} numeric />
107 + <Th k="latest_year" label={t('admin.coverage.latestYear')} sort={cty.sort} toggle={cty.toggle} numeric />
108 + <Th k="coverage_pct" label={t('admin.coverage.pct')} sort={cty.sort} toggle={cty.toggle} numeric />
109 + </tr>
110 + </thead>
111 + <tbody className="divide-y divide-rule">
112 + {cty.sorted.map((r) => (
113 + <tr key={r.id} className="hover:bg-surface-2/60">
114 + <td className="py-1 pr-3 text-ink">{r.short_name ?? r.id}</td>
115 + <td className="py-1 pr-3 font-mono text-ink-2">{r.id}</td>
116 + <td className="tnum py-1 pr-3 text-right">{r.n_indicators != null ? grouped(r.n_indicators) : '—'}</td>
117 + <td className="tnum py-1 pr-3 text-right">{r.n_observations != null ? grouped(r.n_observations) : '—'}</td>
118 + <td className="tnum py-1 pr-3 text-right">{r.latest_year ?? '—'}</td>
119 + <td className={cn('tnum py-1 pr-3 text-right', (r.coverage_pct ?? 0) < 50 && 'text-warn')}>{r.coverage_pct != null ? `${r.coverage_pct.toFixed(1)} %` : '—'}</td>
120 + </tr>
121 + ))}
122 + </tbody>
123 + </table>
124 + )}
125 + </div>
126 + </div>
127 + );
128 +}
modified apps/web/src/components/brand/Logo.tsx +1 −1
@@ -48,6 +48,6 @@ export function Logo({ variant = 'full', size = 26, className }: { variant?: 'fu
48 48
49 49 /** Raw SVG string of the mark (for favicons / OG rasterisation); `color` is a CSS colour. */
50 50 export function logoMarkSvg({ size = 512, color = '#1c5cab', background, radius = 0 }: { size?: number; color?: string; background?: string; radius?: number } = {}): string {
51 − const bg = background ? `<rect width="32" height="32" rx="${radius}" fill="${background}"/>` : '';
51 + const bg = background ? `<rect width="32" height="32" rx="${radius}" fill="${background}" stroke="none"/>` : '';
52 52 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${bg}<circle cx="16" cy="16" r="13"/><path d="M4.1 19.5h23.8"/><path d="M9.6 27.2 16 6.8l6.4 20.4"/><path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55"/><path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55"/></svg>`;
53 53 }
added apps/web/src/components/changes/changes-feed.tsx +254 −0
@@ -0,0 +1,254 @@
1 +'use client';
2 +import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useMemo, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientExplore } from '@/lib/client-api-explore';
7 +import { cn } from '@/lib/cn';
8 +import { formatPeriod, grouped } from '@/lib/format';
9 +import { WB_REGIONS } from '@/lib/regions';
10 +import { severityLabel, severityLevel } from '@/lib/severity';
11 +import { routes } from '@/lib/site';
12 +import type { ChangeItem } from '@/lib/types';
13 +import { BottomSheet } from '@/components/data/bottom-sheet';
14 +import { kindLabel } from '@/components/data/change-list';
15 +import { CountryChip } from '@/components/data/country-chip';
16 +import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';
17 +
18 +const ICON: Record<string, typeof ArrowUpRight> = {
19 + yoy_jump: ArrowUpRight,
20 + yoy_drop: ArrowDownRight,
21 + record_high: Trophy,
22 + record_low: AlertTriangle,
23 + n_year_high: TrendingUp,
24 + n_year_low: TrendingDown,
25 + sign_flip: Repeat,
26 + accelerating: TrendingUp,
27 + decelerating: TrendingDown,
28 +};
29 +
30 +type Sev = 0 | 0.4 | 0.7;
31 +
32 +/**
33 + * Global change feed with filters (kind chips, indicator typeahead, region, minimum severity) in a bottom
34 + * sheet on phones and inline on desktop. Items are grouped by period (year). Server passes the first page;
35 + * changing a server-side filter (kind / indicator / severity) refetches `/api/v1/changes`; region filters
36 + * client-side (the API has no region parameter but every item carries the country's region).
37 + */
38 +export function ChangesFeed({ initial, kinds, limit = 100, compact = false }: { initial: ChangeItem[]; kinds: string[]; limit?: number; compact?: boolean }) {
39 + const [kind, setKind] = useState<string | null>(null);
40 + const [indicator, setIndicator] = useState<PickedEntity | null>(null);
41 + const [region, setRegion] = useState<string | null>(null);
42 + const [minSev, setMinSev] = useState<Sev>(0);
43 + const [items, setItems] = useState<ChangeItem[]>(initial);
44 + const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
45 + const [sheet, setSheet] = useState(false);
46 + const first = useRef(true);
47 +
48 + useEffect(() => {
49 + if (first.current) {
50 + first.current = false;
51 + return;
52 + }
53 + const ctrl = new AbortController();
54 + setState('loading');
55 + clientExplore
56 + .changes({ limit, kind, indicator: indicator?.slug ?? null, min_severity: minSev || null }, ctrl.signal)
57 + .then((r) => {
58 + setItems(r.items);
59 + setState('idle');
60 + })
61 + .catch((e) => {
62 + if ((e as Error).name !== 'AbortError') setState('error');
63 + });
64 + return () => ctrl.abort();
65 + }, [kind, indicator, minSev, limit]);
66 +
67 + const visible = useMemo(() => (region ? items.filter((c) => (c.country?.region ?? '').toUpperCase() === region) : items), [items, region]);
68 + const groups = useMemo(() => {
69 + const m = new Map<string, ChangeItem[]>();
70 + for (const c of visible) {
71 + const key = c.period ? formatPeriod(c.period, 'A') : t('common.na');
72 + if (!m.has(key)) m.set(key, []);
73 + m.get(key)!.push(c);
74 + }
75 + return Array.from(m.entries()).sort((a, b) => b[0].localeCompare(a[0]));
76 + }, [visible]);
77 +
78 + const active = (kind ? 1 : 0) + (indicator ? 1 : 0) + (region ? 1 : 0) + (minSev ? 1 : 0);
79 + const reset = () => {
80 + setKind(null);
81 + setIndicator(null);
82 + setRegion(null);
83 + setMinSev(0);
84 + };
85 +
86 + const filters = (
87 + <div className="space-y-4">
88 + <div>
89 + <div className="eyebrow mb-1.5">{t('changes.filter.kind')}</div>
90 + <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.kind')}>
91 + <li>
92 + <Chip active={!kind} onClick={() => setKind(null)}>
93 + {t('common.all')}
94 + </Chip>
95 + </li>
96 + {kinds.map((k) => (
97 + <li key={k}>
98 + <Chip active={kind === k} onClick={() => setKind(kind === k ? null : k)}>
99 + {kindLabel(k, 10)}
100 + </Chip>
101 + </li>
102 + ))}
103 + </ul>
104 + </div>
105 + <div>
106 + <div className="eyebrow mb-1.5">{t('changes.filter.indicator')}</div>
107 + {indicator ? (
108 + <span className="inline-flex h-11 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-sm text-accent md:h-9">
109 + {indicator.name}
110 + <button type="button" onClick={() => setIndicator(null)} className="grid h-9 w-8 place-items-center" aria-label={t('common.reset')}>
111 + <X size={13} aria-hidden />
112 + </button>
113 + </span>
114 + ) : (
115 + <EntityPicker type="indicator" placeholder={t('changes.filter.search')} onPick={setIndicator} className="max-w-sm" size="sm" />
116 + )}
117 + </div>
118 + {!compact ? (
119 + <div>
120 + <div className="eyebrow mb-1.5">{t('changes.filter.region')}</div>
121 + <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.region')}>
122 + <li>
123 + <Chip active={!region} onClick={() => setRegion(null)}>
124 + {t('common.all')}
125 + </Chip>
126 + </li>
127 + {WB_REGIONS.map((r) => (
128 + <li key={r.id}>
129 + <Chip active={region === r.id} onClick={() => setRegion(region === r.id ? null : r.id)}>
130 + {r.short}
131 + </Chip>
132 + </li>
133 + ))}
134 + </ul>
135 + </div>
136 + ) : null}
137 + <div>
138 + <div className="eyebrow mb-1.5">{t('changes.filter.severity')}</div>
139 + <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.severity')}>
140 + {(
141 + [
142 + [0, t('changes.filter.severity.any')],
143 + [0.4, t('changes.filter.severity.notable')],
144 + [0.7, t('changes.filter.severity.major')],
145 + ] as Array<[Sev, string]>
146 + ).map(([v, label]) => (
147 + <li key={v}>
148 + <Chip active={minSev === v} onClick={() => setMinSev(v)}>
149 + {label}
150 + </Chip>
151 + </li>
152 + ))}
153 + </ul>
154 + </div>
155 + </div>
156 + );
157 +
158 + return (
159 + <div className="min-w-0">
160 + <div className="flex flex-wrap items-center gap-2 pb-3">
161 + <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-11 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>
162 + <SlidersHorizontal size={15} aria-hidden />
163 + {t('common.filters')}
164 + {active ? <span className="tnum">({active})</span> : null}
165 + </button>
166 + {compact ? (
167 + <button type="button" onClick={() => setSheet(true)} className={cn('hidden h-9 items-center gap-1.5 rounded-sm border px-3 text-sm md:inline-flex', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>
168 + <SlidersHorizontal size={15} aria-hidden />
169 + {t('common.filters')}
170 + {active ? <span className="tnum">({active})</span> : null}
171 + </button>
172 + ) : null}
173 + {active ? (
174 + <button type="button" onClick={reset} className="inline-flex h-11 items-center gap-1 rounded-sm px-2 text-xs text-ink-2 hover:text-ink md:h-9">
175 + <X size={13} aria-hidden /> {t('common.reset')}
176 + </button>
177 + ) : null}
178 + <span className="tnum ml-auto text-xs text-ink-3" aria-live="polite">
179 + {state === 'loading' ? t('changes.loading') : t('changes.count', { n: grouped(visible.length) })}
180 + </span>
181 + </div>
182 + {!compact ? <div className="hidden pb-4 md:block">{filters}</div> : null}
183 +
184 + {state === 'error' ? <p className="py-6 text-sm text-down">{t('changes.error')}</p> : null}
185 + {state !== 'error' && visible.length === 0 && state !== 'loading' ? <p className="py-8 text-center text-sm text-ink-3">{t('changes.none')}</p> : null}
186 +
187 + <div className={cn('transition-opacity', state === 'loading' && 'opacity-50')} aria-busy={state === 'loading'}>
188 + {groups.map(([period, rows]) => (
189 + <section key={period} className="pb-4">
190 + <h3 className="tnum sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14">
191 + {period}
192 + </h3>
193 + <ol className="divide-y divide-rule">
194 + {rows.map((c, i) => (
195 + <ChangeRow key={c.id ?? `${period}-${i}`} c={c} />
196 + ))}
197 + </ol>
198 + </section>
199 + ))}
200 + </div>
201 +
202 + <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('common.filters')}>
203 + {filters}
204 + <div className="mt-6 flex justify-between">
205 + <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={reset}>
206 + {t('common.reset')}
207 + </button>
208 + <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>
209 + {t('common.apply')}
210 + </button>
211 + </div>
212 + </BottomSheet>
213 + </div>
214 + );
215 +}
216 +
217 +function ChangeRow({ c }: { c: ChangeItem }) {
218 + const Icon = ICON[c.kind ?? ''] ?? ArrowUpRight;
219 + const lvl = severityLevel(c.severity);
220 + const ind = c.indicator as { id: string; slug?: string; name?: string | null; topic?: string | null };
221 + const slug = ind.slug ?? ind.id;
222 + const seriesHref = c.country?.slug && ind.topic ? routes.countryIndicator(c.country.slug, ind.topic, slug) : routes.indicator(slug);
223 + return (
224 + <li className="flex gap-3 py-3">
225 + <span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm', lvl === 'high' ? 'bg-accent-soft text-accent' : 'bg-surface-2 text-ink-2')} aria-hidden>
226 + <Icon size={15} />
227 + </span>
228 + <div className="min-w-0 flex-1">
229 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3">
230 + {c.country ? <CountryChip country={{ slug: c.country.slug ?? c.country.id, name: c.country.name ?? c.country.id, flag: c.country.flag }} size="sm" /> : null}
231 + <span className="font-medium uppercase tracking-wide">{kindLabel(c.kind, c.window_years)}</span>
232 + <span className={cn('rounded-xs px-1 py-px', lvl === 'high' ? 'bg-accent-soft text-accent' : lvl === 'medium' ? 'bg-surface-2 text-ink-2' : 'bg-surface-2 text-ink-3')}>{severityLabel(c.severity)}</span>
233 + </div>
234 + <p className="mt-0.5 text-sm leading-snug text-ink">{c.headline ?? `${ind.name ?? slug}: ${c.formatted ?? ''}`}</p>
235 + <div className="mt-1 flex flex-wrap gap-x-3 text-xs">
236 + <Link href={seriesHref} className="inline-flex min-h-[32px] items-center text-accent hover:underline">
237 + {t('changes.series')} →
238 + </Link>
239 + <Link href={routes.indicator(slug)} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent hover:underline">
240 + {ind.name ?? slug}
241 + </Link>
242 + </div>
243 + </div>
244 + </li>
245 + );
246 +}
247 +
248 +function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
249 + return (
250 + <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-11 items-center rounded-sm border px-3 text-sm md:h-9 md:px-2.5', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
251 + {children}
252 + </button>
253 + );
254 +}
modified apps/web/src/components/charts/dna-radial.tsx +4 −4
@@ -17,7 +17,7 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }:
17 17 if (!dna || present.length === 0) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>;
18 18
19 19 const R = 100;
20 − const pad = compact ? 16 : 44;
20 + const pad = compact ? 16 : 100;
21 21 const cx = R + pad;
22 22 const cy = R + pad;
23 23 const W = 2 * (R + pad);
@@ -28,7 +28,7 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }:
28 28
29 29 return (
30 30 <figure className={cn('min-w-0', className)}>
31 − <svg viewBox={`0 0 ${W} ${W}`} width="100%" style={{ maxWidth: size, aspectRatio: '1 / 1', margin: '0 auto' }} role="img" aria-label={summary} className="ca-chart">
31 + <svg viewBox={`0 0 ${W} ${W}`} width="100%" style={{ maxWidth: size, aspectRatio: '1 / 1', margin: '0 auto', overflow: 'visible' }} role="img" aria-label={summary} className="ca-chart">
32 32 <title>{t('country.dna.title')}</title>
33 33 <desc>{summary}</desc>
34 34 {[25, 50, 75, 100].map((r) => (
@@ -44,11 +44,11 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }:
44 44 ))}
45 45 {!compact
46 46 ? dims.map((d, i) => {
47 − const [x, y] = pt(i, R + 22);
47 + const [x, y] = pt(i, R + 16);
48 48 const a = angle(i);
49 49 const anchor = Math.abs(Math.cos(a)) < 0.2 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
50 50 return (
51 − <text key={d.key} x={x} y={y} textAnchor={anchor} dy="0.32em" className={isNum(d.value) ? 'label' : ''} style={{ fontSize: 10, fill: isNum(d.value) ? CHART.ink2 : CHART.ink3 }}>
51 + <text key={d.key} x={x} y={y} textAnchor={anchor} dy="0.32em" className={isNum(d.value) ? 'label' : ''} style={{ fontSize: 11, fill: isNum(d.value) ? CHART.ink2 : CHART.ink3 }}>
52 52 {d.label}
53 53 {isNum(d.value) ? ` ${Math.round(d.value)}` : ' —'}
54 54 </text>
modified apps/web/src/components/charts/line-chart.tsx +7 −1
@@ -75,6 +75,12 @@ export function LineChart({
75 75 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);
76 76 const margin: Margin = { ...DEFAULT_MARGIN, ...marginIn };
77 77 if (endLabels && series.length > 1 && series.length <= 4) margin.right = Math.max(margin.right, 72);
78 + // Left margin fits the longest y tick label (≈ 6.4 px per character at 11 px) so labels are never clipped.
79 + if (marginIn?.left == null) {
80 + const probe = extent(series.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1];
81 + const longest = Math.max(...yScale(probe, [0, 1], { log }).ticks(4).map((tk) => formatTick(tk, spec).length), 3);
82 + margin.left = Math.min(84, Math.max(36, Math.round(longest * 6.4) + 12));
83 + }
78 84 const [hover, setHover] = useState<number | null>(null); // x-value (fractional year)
79 85
80 86 const model = useMemo(() => {
@@ -263,7 +269,7 @@ export function LineChart({
263 269 : null}
264 270
265 271 {series.length === 1 && model.layers[0]?.last ? (
266 − <text className="label label-strong" x={Math.min(model.layers[0].last.x + 8, model.innerW - 4)} y={model.layers[0].last.y} dy="0.32em" textAnchor={model.layers[0].last.x + 60 > model.innerW ? 'end' : 'start'} dx={model.layers[0].last.x + 60 > model.innerW ? -10 : 0}>
272 + <text className="label label-strong" x={Math.min(model.layers[0].last.x, model.innerW)} y={model.layers[0].last.y - 10} textAnchor={model.layers[0].last.x > model.innerW * 0.8 ? 'end' : 'middle'} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
267 273 {formatValue(model.layers[0].last.p.value, spec)}
268 274 </text>
269 275 ) : null}
modified apps/web/src/components/charts/ranked-bars.tsx +1 −1
@@ -63,7 +63,7 @@ export function RankedBars({
63 63 </span>
64 64 );
65 65 return (
66 − <li key={r.id} className="grid grid-cols-[minmax(0,11rem)_1fr] items-center gap-x-3 py-1.5 sm:grid-cols-[minmax(0,13rem)_1fr]">
66 + <li key={r.id} className="grid grid-cols-[minmax(0,46%)_1fr] items-center gap-x-3 py-1.5 sm:grid-cols-[minmax(0,13rem)_1fr]">
67 67 <div className="flex min-w-0 items-center gap-2">
68 68 {showRank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{r.rank ?? i + 1}</span> : null}
69 69 {linkRows && r.href ? (
added apps/web/src/components/compare/compare-builder.tsx +210 −0
@@ -0,0 +1,210 @@
1 +'use client';
2 +import { ArrowRight, ChevronDown, ChevronUp, GripVertical, Plus, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useCallback, useMemo, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { cn } from '@/lib/cn';
8 +import { COMPARE_GROUP_PRESETS } from '@/lib/compare-presets';
9 +import { MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES } from '@/lib/compare-state';
10 +import { routes } from '@/lib/site';
11 +import type { CountryLite } from '@/lib/types-compare';
12 +import { seriesVar } from '@/components/charts/palette';
13 +import { COMPARE_PRESETS } from '@/components/home/compare-teaser';
14 +import { CountryPickerSheet, CountryTypeahead } from './country-picker';
15 +
16 +/**
17 + * /compare landing: typeahead + ordered chips (drag on desktop, up/down buttons everywhere), presets
18 + * (G7, Nordics, BRICS, USMCA…, "Canada vs peers" from /similar) and the Compare button → /compare/a/b/c.
19 + * On phones the chip row is sticky under the header and the picker opens as a bottom sheet.
20 + */
21 +export function CompareBuilder({ countries, peers, initial = [] }: { countries: CountryLite[]; peers: CountryLite[]; initial?: string[] }) {
22 + const router = useRouter();
23 + const bySlug = useMemo(() => new Map(countries.map((c) => [c.slug, c])), [countries]);
24 + const [selected, setSelected] = useState<string[]>(() => initial.filter((s) => bySlug.has(s)).slice(0, MAX_COMPARE_COUNTRIES));
25 + const [sheet, setSheet] = useState(false);
26 + const [dragFrom, setDragFrom] = useState<number | null>(null);
27 + const exclude = useMemo(() => new Set(selected), [selected]);
28 + const chosen = selected.map((s) => bySlug.get(s)!).filter(Boolean);
29 + const canGo = selected.length >= MIN_COMPARE_COUNTRIES;
30 + const full = selected.length >= MAX_COMPARE_COUNTRIES;
31 +
32 + const add = useCallback((c: CountryLite) => setSelected((s) => (s.includes(c.slug) || s.length >= MAX_COMPARE_COUNTRIES ? s : [...s, c.slug])), []);
33 + const remove = (slug: string) => setSelected((s) => s.filter((x) => x !== slug));
34 + const move = (from: number, to: number) =>
35 + setSelected((s) => {
36 + if (to < 0 || to >= s.length || from === to) return s;
37 + const next = [...s];
38 + const [item] = next.splice(from, 1);
39 + next.splice(to, 0, item!);
40 + return next;
41 + });
42 + const go = () => {
43 + if (canGo) router.push(routes.compare(...selected));
44 + };
45 +
46 + const peerPreset = peers.length >= 2 ? ['canada', ...peers.filter((p) => p.slug !== 'canada').slice(0, 5).map((p) => p.slug)] : null;
47 +
48 + return (
49 + <div>
50 + {/* Selected chips — sticky on phones */}
51 + <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 sm:px-6 md:static md:mx-0 md:border-0 md:bg-transparent md:px-0 md:py-0 md:backdrop-blur-none">
52 + <div className="flex items-center gap-2 md:hidden">
53 + <button type="button" onClick={() => setSheet(true)} disabled={full} className={cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-sm border px-3 text-sm', full ? 'border-rule text-ink-3' : 'border-ink text-ink')}>
54 + <Plus size={15} aria-hidden />
55 + {t('compare.add')}
56 + </button>
57 + <ul className="scrollbar-none flex min-w-0 flex-1 gap-1.5 overflow-x-auto" aria-label={t('compare.builder.selected')}>
58 + {chosen.map((c, i) => (
59 + <li key={c.slug} className="shrink-0">
60 + <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm">
61 + <span aria-hidden className="h-2 w-2 rounded-full" style={{ background: seriesVar(i) }} />
62 + <span aria-hidden>{c.flag}</span>
63 + <span className="max-w-[9rem] truncate">{c.name}</span>
64 + <button type="button" onClick={() => remove(c.slug)} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-ink" aria-label={t('compare.builder.remove', { name: c.name })}>
65 + <X size={14} aria-hidden />
66 + </button>
67 + </span>
68 + </li>
69 + ))}
70 + </ul>
71 + <button type="button" onClick={go} disabled={!canGo} className={cn('inline-flex h-11 shrink-0 items-center gap-1 rounded-sm px-3 text-sm font-medium', canGo ? 'bg-ink text-paper' : 'bg-surface-2 text-ink-3')}>
72 + {t('compare.builder.go')}
73 + <ArrowRight size={15} aria-hidden />
74 + </button>
75 + </div>
76 +
77 + <div className="hidden md:block">
78 + <CountryTypeahead countries={countries} exclude={exclude} onPick={add} disabled={full} placeholder={full ? t('compare.builder.full', { max: MAX_COMPARE_COUNTRIES }) : undefined} className="max-w-xl" />
79 + </div>
80 + </div>
81 +
82 + {/* Ordered list with reorder controls */}
83 + <div className="mt-4">
84 + <div className="eyebrow mb-2">
85 + {t('compare.builder.selected')} · <span className="tnum">{selected.length}/{MAX_COMPARE_COUNTRIES}</span>
86 + </div>
87 + {chosen.length === 0 ? (
88 + <p className="border-t border-dashed border-rule-strong py-5 text-sm text-ink-3">{t('compare.builder.empty')}</p>
89 + ) : (
90 + <ol className="divide-y divide-rule border-y border-rule">
91 + {chosen.map((c, i) => (
92 + <li
93 + key={c.slug}
94 + draggable
95 + onDragStart={() => setDragFrom(i)}
96 + onDragOver={(e) => e.preventDefault()}
97 + onDrop={() => {
98 + if (dragFrom != null) move(dragFrom, i);
99 + setDragFrom(null);
100 + }}
101 + onDragEnd={() => setDragFrom(null)}
102 + className={cn('flex min-h-[52px] items-center gap-2 py-1', dragFrom === i && 'opacity-50')}
103 + >
104 + <span className="hidden cursor-grab text-ink-3 md:inline" title={t('compare.builder.drag')} aria-hidden>
105 + <GripVertical size={16} />
106 + </span>
107 + <span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />
108 + <span aria-hidden className="text-xl leading-none">
109 + {c.flag}
110 + </span>
111 + <span className="min-w-0 flex-1 truncate text-sm font-medium text-ink">{c.name}</span>
112 + <div className="flex items-center">
113 + <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="tap grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30" aria-label={t('compare.builder.moveUp', { name: c.name })}>
114 + <ChevronUp size={16} aria-hidden />
115 + </button>
116 + <button type="button" onClick={() => move(i, i + 1)} disabled={i === chosen.length - 1} className="tap grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30" aria-label={t('compare.builder.moveDown', { name: c.name })}>
117 + <ChevronDown size={16} aria-hidden />
118 + </button>
119 + <button type="button" onClick={() => remove(c.slug)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-down" aria-label={t('compare.builder.remove', { name: c.name })}>
120 + <X size={16} aria-hidden />
121 + </button>
122 + </div>
123 + </li>
124 + ))}
125 + </ol>
126 + )}
127 + <div className="mt-4 hidden flex-wrap items-center gap-3 md:flex">
128 + <button type="button" onClick={go} disabled={!canGo} className={cn('inline-flex h-10 items-center gap-2 rounded-sm px-4 text-sm font-medium', canGo ? 'bg-ink text-paper hover:bg-accent hover:text-accent-ink' : 'bg-surface-2 text-ink-3')}>
129 + {canGo ? t('compare.builder.goN', { n: selected.length }) : t('compare.builder.go')}
130 + <ArrowRight size={15} aria-hidden />
131 + </button>
132 + {!canGo ? <span className="text-sm text-ink-3">{t('compare.builder.needMore', { n: MIN_COMPARE_COUNTRIES - selected.length })}</span> : null}
133 + {selected.length ? (
134 + <button type="button" onClick={() => setSelected([])} className="inline-flex h-10 items-center rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2">
135 + {t('compare.builder.clear')}
136 + </button>
137 + ) : null}
138 + </div>
139 + {!canGo && selected.length ? <p className="mt-2 text-sm text-ink-3 md:hidden">{t('compare.builder.needMore', { n: MIN_COMPARE_COUNTRIES - selected.length })}</p> : null}
140 + </div>
141 +
142 + {/* Presets */}
143 + <section className="hairline mt-8 pt-6" aria-labelledby="presets-h">
144 + <h2 id="presets-h" className="display text-xl text-ink md:text-2xl">
145 + {t('compare.presets.title')}
146 + </h2>
147 + <p className="mt-1 text-sm text-ink-2">{t('compare.presets.sub')}</p>
148 + <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
149 + {[...COMPARE_GROUP_PRESETS.map((p) => ({ id: p.id, label: t(`compare.preset.${p.id}` as 'compare.preset.g7'), hint: null as string | null, slugs: p.slugs })), ...(peerPreset ? [{ id: 'peers', label: t('compare.preset.peers', { name: 'Canada' }), hint: t('compare.preset.peersHint', { name: 'Canada' }), slugs: peerPreset }] : [])].map((p) => {
150 + const members = p.slugs.map((s) => bySlug.get(s)).filter((c): c is CountryLite => !!c);
151 + if (members.length < 2) return null;
152 + return (
153 + <li key={p.id} className="border-t border-rule">
154 + <div className="flex min-h-[64px] items-center gap-3 py-2.5">
155 + <Link href={routes.compare(...members.map((m) => m.slug))} className="group min-w-0 flex-1">
156 + <span className="flex items-center gap-2">
157 + <span className="text-sm font-medium text-ink group-hover:text-accent">{p.label}</span>
158 + <span className="tnum text-2xs text-ink-3">{t('compare.presets.count', { n: members.length })}</span>
159 + </span>
160 + <span className="mt-0.5 flex flex-wrap gap-x-1 text-base leading-tight" aria-hidden>
161 + {members.map((m) => (
162 + <span key={m.slug}>{m.flag}</span>
163 + ))}
164 + </span>
165 + {p.hint ? <span className="block text-xs text-ink-3">{p.hint}</span> : null}
166 + </Link>
167 + <button
168 + type="button"
169 + onClick={() => setSelected(members.map((m) => m.slug))}
170 + className="tap shrink-0 rounded-sm border border-rule px-2.5 text-xs text-ink-2 hover:border-accent hover:text-accent md:min-h-[32px]"
171 + aria-label={`${p.label}: ${t('compare.builder.open')}`}
172 + >
173 + {t('compare.builder.open')}
174 + </button>
175 + </div>
176 + </li>
177 + );
178 + })}
179 + </ul>
180 +
181 + <h3 className="eyebrow mt-8">{t('compare.presets.pairs')}</h3>
182 + <ul className="mt-2 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-4">
183 + {COMPARE_PRESETS.map(([a, b]) => {
184 + const ca = bySlug.get(a);
185 + const cb = bySlug.get(b);
186 + if (!ca || !cb) return null;
187 + return (
188 + <li key={`${a}-${b}`} className="border-t border-rule">
189 + <Link href={routes.compare(a, b)} className="group flex min-h-[52px] items-center gap-2 py-2 text-sm">
190 + <span aria-hidden className="text-lg leading-none">
191 + {ca.flag}
192 + </span>
193 + <span className="truncate text-ink group-hover:text-accent">{ca.name}</span>
194 + <span className="text-ink-3">{t('compare.vs')}</span>
195 + <span aria-hidden className="text-lg leading-none">
196 + {cb.flag}
197 + </span>
198 + <span className="truncate text-ink group-hover:text-accent">{cb.name}</span>
199 + <ArrowRight size={14} aria-hidden className="ml-auto shrink-0 text-ink-3 group-hover:text-accent" />
200 + </Link>
201 + </li>
202 + );
203 + })}
204 + </ul>
205 + </section>
206 +
207 + <CountryPickerSheet open={sheet} onClose={() => setSheet(false)} countries={countries} exclude={exclude} onPick={add} title={t('compare.addCountry')} closeOnPick={false} />
208 + </div>
209 + );
210 +}
added apps/web/src/components/compare/compare-chart.tsx +222 −0
@@ -0,0 +1,222 @@
1 +'use client';
2 +import { Info, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname, useRouter } from 'next/navigation';
5 +import { useEffect, useMemo, useRef, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { clientCompare } from '@/lib/client-api-compare';
8 +import { cn } from '@/lib/cn';
9 +import { compareQuery, type CompareState } from '@/lib/compare-state';
10 +import { formatDate, formatPeriod, formatValue } from '@/lib/format';
11 +import { routes } from '@/lib/site';
12 +import type { FormatSpec, IndicatorCard, MetricValue } from '@/lib/types';
13 +import type { CompareSeries, CountryLite } from '@/lib/types-compare';
14 +import { LineChart, type LineSeries } from '@/components/charts/line-chart';
15 +import { seriesVar } from '@/components/charts/palette';
16 +import { pointsFromSeries } from '@/components/charts/scales';
17 +import { EmptyState } from '@/components/data/empty-state';
18 +import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context';
19 +
20 +const CHART_H = 220;
21 +const HERO_H = 340;
22 +const RANGE_PRESETS = [10, 20, 30] as const;
23 +
24 +/** Display spec for a compare series given the value mode (index → dimensionless, pct → percent). */
25 +export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec {
26 + if (mode === 'index100') return { format: 'index', precision: 1, unit: unitOverride ?? 'index', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };
27 + if (mode === 'pct') return { format: 'percent', precision: 1, unit: '%', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };
28 + return { format: ind.format, unit: unitOverride ?? ind.unit, unit_short: unitOverride ? null : ind.unit_short, precision: ind.precision, name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };
29 +}
30 +
31 +/**
32 + * One indicator, one line per country (colour = position of the country in the comparison). `initial` series
33 + * render at once; otherwise the chart mounts on scroll and fetches `/compare?indicators=<slug>` for the current
34 + * range/mode. Below: latest value per country and the list of sources (they may differ per country).
35 + */
36 +export function CompareChart({ indicator, countries, state, initial, snapshot, hero = false, maxYear }: { indicator: IndicatorCard; countries: CountryLite[]; state: CompareState; initial?: CompareSeries[] | null; snapshot?: Record<string, MetricValue> | null; hero?: boolean; maxYear?: number }) {
37 + const { open } = useProvenance();
38 + const router = useRouter();
39 + const pathname = usePathname();
40 + const ref = useRef<HTMLElement>(null);
41 + const [series, setSeries] = useState<CompareSeries[] | null | undefined>(initial);
42 + const [visible, setVisible] = useState(!!initial || hero);
43 + const [error, setError] = useState(false);
44 + const ids = countries.map((c) => c.id);
45 + const key = `${ids.join(',')}|${state.from ?? ''}|${state.to ?? ''}|${state.mode}`;
46 + const loadedKey = useRef(initial ? key : '');
47 +
48 + useEffect(() => {
49 + if (visible || !ref.current) return;
50 + const el = ref.current;
51 + if (typeof IntersectionObserver === 'undefined') {
52 + setVisible(true);
53 + return;
54 + }
55 + const io = new IntersectionObserver(
56 + (entries) => {
57 + if (entries.some((e) => e.isIntersecting)) {
58 + setVisible(true);
59 + io.disconnect();
60 + }
61 + },
62 + { rootMargin: '480px 0px' },
63 + );
64 + io.observe(el);
65 + return () => io.disconnect();
66 + }, [visible]);
67 +
68 + useEffect(() => {
69 + if (!visible || loadedKey.current === key) return;
70 + const ctrl = new AbortController();
71 + setError(false);
72 + clientCompare
73 + .compare(ids, [indicator.slug], { from: state.from, to: state.to, mode: state.mode }, ctrl.signal)
74 + .then((r) => {
75 + loadedKey.current = key;
76 + setSeries(r.series);
77 + })
78 + .catch((e) => {
79 + if ((e as Error).name !== 'AbortError') {
80 + setError(true);
81 + setSeries(null);
82 + }
83 + });
84 + return () => ctrl.abort();
85 + // eslint-disable-next-line react-hooks/exhaustive-deps
86 + }, [visible, key, indicator.slug]);
87 +
88 + const unitOverride = series?.find((s) => s.transform?.unit)?.transform?.unit ?? (series?.[0]?.unit && state.mode !== 'absolute' ? series[0].unit : null);
89 + const spec = specForMode(indicator, state.mode, unitOverride);
90 + const name = indicator.name ?? indicator.slug;
91 +
92 + const lines: LineSeries[] = useMemo(
93 + () =>
94 + countries
95 + .map((c, i) => {
96 + const s = series?.find((x) => x.country.id === c.id);
97 + return { id: c.id, name: c.name, colorIndex: i, points: s ? pointsFromSeries(s.values) : [] };
98 + })
99 + .filter((l) => l.points.length > 0),
100 + [series, countries],
101 + );
102 +
103 + const latest = countries.map((c, i) => {
104 + const s = series?.find((x) => x.country.id === c.id);
105 + const last = s ? [...s.values].reverse().find((v) => v.value != null && !v.is_forecast) ?? null : null;
106 + const snap = snapshot?.[c.id];
107 + return { c, i, last, snap: snap && snap.has_data ? snap : null, provenance: last?.provenance ?? s?.provenance ?? snap?.provenance ?? null };
108 + });
109 +
110 + const sources = useMemo(() => {
111 + const m = new Map<string, { label: string; names: string[]; first: (typeof latest)[number] }>();
112 + for (const l of latest) {
113 + const p = l.provenance;
114 + if (!p) continue;
115 + const label = [p.source_name ?? p.source, p.dataset].filter(Boolean).join(' — ');
116 + const cur = m.get(label);
117 + if (cur) cur.names.push(l.c.name);
118 + else m.set(label, { label, names: [l.c.name], first: l });
119 + }
120 + return Array.from(m.values());
121 + }, [latest]);
122 +
123 + const payloadOf = (l: (typeof latest)[number]): ProvenancePayload => ({
124 + indicator: { slug: indicator.slug, name, format: indicator.format, unit: indicator.unit, unit_short: indicator.unit_short, precision: indicator.precision, frequency: indicator.frequency, higher_is_better: indicator.higher_is_better },
125 + value: l.last
126 + ? { value: l.last.value, period: l.last.period, year: l.last.year, unit: indicator.unit, is_estimate: l.last.is_estimate, is_forecast: l.last.is_forecast, status: l.last.status, provenance: l.last.provenance }
127 + : l.snap
128 + ? { value: l.snap.value, formatted: l.snap.formatted, period: l.snap.period, year: l.snap.year, unit: l.snap.unit, is_estimate: l.snap.is_estimate, is_forecast: l.snap.is_forecast, status: l.snap.status, provenance: l.snap.provenance }
129 + : null,
130 + country: { id: l.c.id, slug: l.c.slug, name: l.c.name, flag: l.c.flag },
131 + });
132 +
133 + const height = hero ? HERO_H : CHART_H;
134 + const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false });
135 + const rangeActive = (n: number | null) => (n == null ? state.from == null : maxYear != null && state.from === maxYear - n);
136 + const subtitleUnit = state.mode === 'index100' ? t('compare.charts.indexBase', { year: series?.[0]?.transform?.base_year ?? series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'pct' ? t('compare.charts.pctUnit') : (unitOverride ?? indicator.unit);
137 +
138 + return (
139 + <article ref={ref} id={`chart-${indicator.slug}`} className={cn('min-w-0 scroll-mt-40', hero && 'rounded-sm border border-rule bg-surface p-4 md:p-5')} aria-labelledby={`chart-${indicator.slug}-h`}>
140 + <header className="mb-2 flex items-start justify-between gap-3">
141 + <div className="min-w-0">
142 + {hero ? <div className="eyebrow mb-0.5">{t('compare.hero.title')}</div> : null}
143 + <h3 id={`chart-${indicator.slug}-h`} className={cn('font-semibold leading-snug text-ink', hero ? 'display text-xl md:text-2xl' : 'text-sm')}>
144 + <Link href={routes.indicator(indicator.slug)} className="link-quiet">
145 + {name}
146 + </Link>
147 + </h3>
148 + {subtitleUnit ? <p className="text-xs text-ink-2">{subtitleUnit}</p> : null}
149 + </div>
150 + <div className="flex shrink-0 items-center gap-1 text-xs">
151 + {hero && maxYear != null ? (
152 + <div role="radiogroup" aria-label={t('compare.range.preset')} className="hidden items-center gap-0.5 sm:flex">
153 + {[...RANGE_PRESETS, null].map((n) => (
154 + <button key={n ?? 'all'} type="button" role="radio" aria-checked={rangeActive(n)} onClick={() => setFrom(n == null ? null : maxYear - n)} className={cn('tnum inline-flex h-8 items-center rounded-sm px-2', rangeActive(n) ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2')}>
155 + {n == null ? t('compare.range.all') : `${n} y`}
156 + </button>
157 + ))}
158 + </div>
159 + ) : null}
160 + <Link href={routes.ranking(indicator.slug)} className="inline-flex min-h-[32px] items-center rounded-sm px-1.5 text-ink-2 hover:text-accent">
161 + {t('compare.hero.ranking')}
162 + </Link>
163 + {hero ? (
164 + <button type="button" onClick={() => router.replace(`${pathname}${compareQuery({ ...state, indicator: null })}`, { scroll: false })} className="tap -mr-2 grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink md:min-h-[32px] md:min-w-[32px]" aria-label={t('compare.hero.close')}>
165 + <X size={16} aria-hidden />
166 + </button>
167 + ) : null}
168 + </div>
169 + </header>
170 +
171 + <div className="min-w-0" style={{ minHeight: height }}>
172 + {error ? (
173 + <EmptyState compact title={t('compare.charts.error')} />
174 + ) : series === undefined ? (
175 + <div className="grid place-items-center text-sm text-ink-3" style={{ height }}>
176 + {t('compare.charts.loading')}
177 + </div>
178 + ) : lines.length === 0 ? (
179 + <EmptyState compact title={t('chart.noData')} />
180 + ) : (
181 + <LineChart series={lines} spec={spec} log={state.log} height={height} endLabels={false} defaultWidth={hero ? 960 : 560} className="[&_ul[aria-label=Legend]]:hidden" margin={{ right: 16 }} />
182 + )}
183 + </div>
184 +
185 + {/* Latest values per country */}
186 + <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs" aria-label={t('compare.charts.latest')}>
187 + {latest.map((l) => {
188 + const has = !!l.last || !!l.snap;
189 + const text = l.last ? (state.mode === 'absolute' && l.snap?.formatted && l.snap.year === l.last.year ? l.snap.formatted : formatValue(l.last.value, spec)) : l.snap ? l.snap.formatted ?? formatValue(l.snap.value, l.snap) : t('common.noData');
190 + const when = l.last ? formatPeriod(l.last.period, indicator.frequency) : l.snap?.year;
191 + return (
192 + <li key={l.c.id}>
193 + <button type="button" disabled={!has} onClick={() => has && open(payloadOf(l))} className="inline-flex min-h-[32px] items-center gap-1.5 rounded-sm px-1 hover:bg-surface-2 disabled:hover:bg-transparent" aria-label={`${l.c.name}: ${text}. ${t('common.openProvenance')}`}>
194 + <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(l.i) }} />
195 + <span aria-hidden>{l.c.flag}</span>
196 + <span className={cn('tnum font-medium', has ? 'text-ink' : 'text-ink-3')}>{text}</span>
197 + {when ? <span className="tnum text-ink-3">{when}</span> : null}
198 + </button>
199 + </li>
200 + );
201 + })}
202 + </ul>
203 +
204 + {/* Sources — may differ per country */}
205 + {sources.length ? (
206 + <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-2">
207 + <span className="text-ink-3">{sources.length > 1 ? t('compare.charts.sources') : t('common.source')}:</span>
208 + {sources.map((s) => (
209 + <button key={s.label} type="button" onClick={() => open(payloadOf(s.first))} className="inline-flex min-h-[28px] items-center gap-1 rounded-sm px-1 text-left hover:text-accent" aria-label={`${t('compare.charts.sourceFor', { name: s.names.join(', ') })}: ${s.label}. ${t('common.openProvenance')}`}>
210 + <span>
211 + {s.label}
212 + {sources.length > 1 ? <span className="text-ink-3"> ({s.names.join(', ')})</span> : null}
213 + {s.first.provenance?.retrieved_at && sources.length === 1 ? <span className="text-ink-3"> · {t('common.retrieved', { date: formatDate(s.first.provenance.retrieved_at) })}</span> : null}
214 + </span>
215 + <Info size={11} aria-hidden className="shrink-0 text-ink-3" />
216 + </button>
217 + ))}
218 + </p>
219 + ) : null}
220 + </article>
221 + );
222 +}
added apps/web/src/components/compare/compare-controls.tsx +256 −0
@@ -0,0 +1,256 @@
1 +'use client';
2 +import { Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Download, Plus, Share2, SlidersHorizontal, X } from 'lucide-react';
3 +import { usePathname, useRouter } from 'next/navigation';
4 +import { useCallback, useEffect, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { cn } from '@/lib/cn';
7 +import { COMPARE_TABS, MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state';
8 +import { routes } from '@/lib/site';
9 +import { COMPARE_MODES, type CompareMode, type CountryLite } from '@/lib/types-compare';
10 +import { seriesVar } from '@/components/charts/palette';
11 +import { BottomSheet } from '@/components/data/bottom-sheet';
12 +import { CountryPickerSheet } from './country-picker';
13 +
14 +export const COMPARE_MIN_YEAR = 1960;
15 +
16 +/**
17 + * Sticky controls of /compare/[...slugs]: country chips (remove, reorder, add), view tabs and — for chart
18 + * tabs — year range, value mode, log toggle, CSV download and share. Every change is written to the URL
19 + * (`router.replace`, no scroll jump); country changes rewrite the path (`router.push`).
20 + */
21 +export function CompareControls({ countries, allCountries, state, maxYear, downloadHref }: { countries: CountryLite[]; allCountries: CountryLite[]; state: CompareState; maxYear: number; downloadHref: string | null }) {
22 + const router = useRouter();
23 + const pathname = usePathname();
24 + const [picker, setPicker] = useState(false);
25 + const [options, setOptions] = useState(false);
26 + const [copied, setCopied] = useState(false);
27 + const [dragFrom, setDragFrom] = useState<number | null>(null);
28 + const tabsRef = useRef<HTMLDivElement>(null);
29 + const slugs = countries.map((c) => c.slug);
30 + const exclude = new Set(slugs);
31 + const chartsTab = state.tab !== 'snapshot';
32 +
33 + useEffect(() => {
34 + const el = tabsRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
35 + el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior });
36 + }, [state.tab]);
37 +
38 + const setState = useCallback(
39 + (patch: Partial<CompareState>) => {
40 + router.replace(`${pathname}${compareQuery({ ...state, ...patch })}`, { scroll: false });
41 + },
42 + [router, pathname, state],
43 + );
44 + const setCountries = (next: string[]) => {
45 + router.push(`${routes.compare(...next)}${compareQuery(state)}`, { scroll: false });
46 + };
47 + const move = (from: number, to: number) => {
48 + if (to < 0 || to >= slugs.length || from === to) return;
49 + const next = [...slugs];
50 + const [it] = next.splice(from, 1);
51 + next.splice(to, 0, it!);
52 + setCountries(next);
53 + };
54 + const remove = (slug: string) => {
55 + if (slugs.length <= MIN_COMPARE_COUNTRIES) return;
56 + setCountries(slugs.filter((s) => s !== slug));
57 + };
58 + const add = (c: CountryLite) => {
59 + if (slugs.length >= MAX_COMPARE_COUNTRIES) return;
60 + setCountries([...slugs, c.slug]);
61 + };
62 + const share = async () => {
63 + const url = window.location.href;
64 + try {
65 + if (navigator.share) {
66 + await navigator.share({ title: document.title, url });
67 + return;
68 + }
69 + await navigator.clipboard.writeText(url);
70 + setCopied(true);
71 + setTimeout(() => setCopied(false), 1800);
72 + } catch {
73 + /* cancelled */
74 + }
75 + };
76 +
77 + const years: number[] = [];
78 + for (let y = maxYear; y >= COMPARE_MIN_YEAR; y--) years.push(y);
79 + const canRemove = slugs.length > MIN_COMPARE_COUNTRIES;
80 + const full = slugs.length >= MAX_COMPARE_COUNTRIES;
81 +
82 + const rangeAndMode = (
83 + <>
84 + <fieldset className="flex items-center gap-1.5">
85 + <legend className="sr-only">{t('compare.range')}</legend>
86 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
87 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('compare.range.from')}</span>
88 + <select value={state.from ?? ''} onChange={(e) => setState({ from: e.target.value ? Number(e.target.value) : null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('compare.range.from')}>
89 + <option value="">{t('compare.range.all')}</option>
90 + {years.map((y) => (
91 + <option key={y} value={y} disabled={state.to != null && y > state.to}>
92 + {y}
93 + </option>
94 + ))}
95 + </select>
96 + </label>
97 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
98 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('compare.range.to')}</span>
99 + <select value={state.to ?? ''} onChange={(e) => setState({ to: e.target.value ? Number(e.target.value) : null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('compare.range.to')}>
100 + <option value="">{t('compare.range.all')}</option>
101 + {years.map((y) => (
102 + <option key={y} value={y} disabled={state.from != null && y < state.from}>
103 + {y}
104 + </option>
105 + ))}
106 + </select>
107 + </label>
108 + </fieldset>
109 + <div role="radiogroup" aria-label={t('compare.valueMode')} className="flex flex-wrap gap-1">
110 + {COMPARE_MODES.map((m: CompareMode) => (
111 + <button key={m} type="button" role="radio" aria-checked={state.mode === m} onClick={() => setState({ mode: m })} className={cn('inline-flex h-11 items-center rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.mode === m ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
112 + {t(`compare.mode.${m}` as 'compare.mode.absolute')}
113 + </button>
114 + ))}
115 + </div>
116 + <label className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.log ? 'border-ink text-ink' : 'border-rule text-ink-2')}>
117 + <input type="checkbox" checked={state.log} onChange={(e) => setState({ log: e.target.checked })} className="accent-[var(--accent)]" />
118 + {t('compare.log')}
119 + </label>
120 + </>
121 + );
122 +
123 + const actions = (
124 + <>
125 + {downloadHref ? (
126 + <a href={downloadHref} download className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:h-9 md:px-2">
127 + <Download size={14} aria-hidden />
128 + <span className="hidden sm:inline">{t('compare.download')}</span>
129 + <span className="sm:hidden">CSV</span>
130 + </a>
131 + ) : null}
132 + <button type="button" onClick={share} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:h-9 md:px-2" aria-live="polite">
133 + {copied ? <Check size={14} aria-hidden className="text-up" /> : <Share2 size={14} aria-hidden />}
134 + {copied ? t('compare.copied') : t('compare.share')}
135 + </button>
136 + </>
137 + );
138 +
139 + return (
140 + <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 md:top-[56px]">
141 + {/* Row 1: country chips */}
142 + <div className="flex items-center gap-2 px-4 pt-2 sm:px-6">
143 + <ul className="scrollbar-none flex min-w-0 flex-1 gap-1.5 overflow-x-auto py-0.5" aria-label={t('compare.legend')}>
144 + {countries.map((c, i) => (
145 + <li
146 + key={c.slug}
147 + className={cn('group/chip shrink-0', dragFrom === i && 'opacity-50')}
148 + draggable
149 + onDragStart={() => setDragFrom(i)}
150 + onDragOver={(e) => e.preventDefault()}
151 + onDrop={() => {
152 + if (dragFrom != null) move(dragFrom, i);
153 + setDragFrom(null);
154 + }}
155 + onDragEnd={() => setDragFrom(null)}
156 + >
157 + <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">
158 + <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />
159 + <span aria-hidden>{c.flag}</span>
160 + <span className="max-w-[8rem] truncate text-ink md:max-w-[11rem]">{c.name}</span>
161 + <span className="hidden items-center md:flex">
162 + <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="grid h-9 w-6 place-items-center text-ink-3 hover:text-ink disabled:opacity-25" aria-label={t('compare.builder.moveUp', { name: c.name })}>
163 + <ChevronLeft size={13} aria-hidden />
164 + </button>
165 + <button type="button" onClick={() => move(i, i + 1)} disabled={i === countries.length - 1} className="grid h-9 w-6 place-items-center text-ink-3 hover:text-ink disabled:opacity-25" aria-label={t('compare.builder.moveDown', { name: c.name })}>
166 + <ChevronRight size={13} aria-hidden />
167 + </button>
168 + </span>
169 + <button type="button" onClick={() => remove(c.slug)} disabled={!canRemove} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down disabled:opacity-25 md:h-9 md:w-7" aria-label={t('compare.removeCountry', { name: c.name })}>
170 + <X size={14} aria-hidden />
171 + </button>
172 + </span>
173 + </li>
174 + ))}
175 + <li className="shrink-0">
176 + <button type="button" onClick={() => setPicker(true)} disabled={full} className={cn('inline-flex h-11 items-center gap-1 rounded-sm border border-dashed px-2.5 text-sm md:h-9', full ? 'border-rule text-ink-3' : 'border-rule-strong text-ink-2 hover:border-accent hover:text-accent')} aria-label={t('compare.addCountry')}>
177 + <Plus size={14} aria-hidden />
178 + {t('compare.add')}
179 + </button>
180 + </li>
181 + </ul>
182 + <button type="button" onClick={() => setOptions(true)} className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 md:hidden" aria-label={t('compare.options')}>
183 + <SlidersHorizontal size={15} aria-hidden />
184 + <span className="sr-only sm:not-sr-only">{t('compare.options')}</span>
185 + </button>
186 + </div>
187 +
188 + {/* Row 2: tabs */}
189 + <div ref={tabsRef} role="tablist" aria-label={t('compare.tabs')} className="scrollbar-none mt-1 flex gap-0.5 overflow-x-auto px-4 sm:px-6">
190 + {COMPARE_TABS.map((tab: CompareTab) => {
191 + const active = state.tab === tab;
192 + return (
193 + <button
194 + key={tab}
195 + type="button"
196 + role="tab"
197 + aria-selected={active}
198 + onClick={() => setState({ tab, indicator: tab === state.tab ? state.indicator : null })}
199 + className={cn('relative inline-flex h-11 shrink-0 items-center px-3 text-sm md:h-10', active ? 'font-medium text-ink' : 'text-ink-2 hover:text-ink')}
200 + >
201 + {t(`compare.tab.${tab}` as 'compare.tab.snapshot')}
202 + {active ? <span aria-hidden className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-ink" /> : null}
203 + </button>
204 + );
205 + })}
206 + </div>
207 +
208 + {/* Row 3 (md+): range · mode · log · download · share */}
209 + <div className="hidden flex-wrap items-center gap-2 border-t border-rule px-4 py-1.5 sm:px-6 md:flex">
210 + {chartsTab ? rangeAndMode : null}
211 + <div className="ml-auto flex items-center gap-2">{actions}</div>
212 + </div>
213 +
214 + <CountryPickerSheet open={picker} onClose={() => setPicker(false)} countries={allCountries} exclude={exclude} onPick={add} />
215 +
216 + <BottomSheet open={options} onClose={() => setOptions(false)} side="center" title={t('compare.options')}>
217 + <div className="space-y-5">
218 + <div>
219 + <div className="eyebrow mb-1.5">{t('compare.legend')}</div>
220 + <ol className="divide-y divide-rule border-y border-rule">
221 + {countries.map((c, i) => (
222 + <li key={c.slug} className="flex min-h-[48px] items-center gap-2">
223 + <span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />
224 + <span aria-hidden className="text-lg leading-none">
225 + {c.flag}
226 + </span>
227 + <span className="min-w-0 flex-1 truncate text-sm text-ink">{c.name}</span>
228 + <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="tap grid place-items-center text-ink-2 disabled:opacity-30" aria-label={t('compare.builder.moveUp', { name: c.name })}>
229 + <ChevronUp size={16} aria-hidden />
230 + </button>
231 + <button type="button" onClick={() => move(i, i + 1)} disabled={i === countries.length - 1} className="tap grid place-items-center text-ink-2 disabled:opacity-30" aria-label={t('compare.builder.moveDown', { name: c.name })}>
232 + <ChevronDown size={16} aria-hidden />
233 + </button>
234 + <button type="button" onClick={() => remove(c.slug)} disabled={!canRemove} className="tap grid place-items-center text-ink-3 hover:text-down disabled:opacity-30" aria-label={t('compare.removeCountry', { name: c.name })}>
235 + <X size={16} aria-hidden />
236 + </button>
237 + </li>
238 + ))}
239 + </ol>
240 + <button type="button" onClick={() => { setOptions(false); setPicker(true); }} disabled={full} className="mt-2 inline-flex h-11 items-center gap-1 rounded-sm border border-dashed border-rule-strong px-3 text-sm text-ink-2 disabled:opacity-40">
241 + <Plus size={14} aria-hidden />
242 + {t('compare.addCountry')}
243 + </button>
244 + </div>
245 + {chartsTab ? (
246 + <div>
247 + <div className="eyebrow mb-1.5">{t('compare.range')} · {t('compare.valueMode')}</div>
248 + <div className="flex flex-wrap gap-2">{rangeAndMode}</div>
249 + </div>
250 + ) : null}
251 + <div className="flex flex-wrap gap-2">{actions}</div>
252 + </div>
253 + </BottomSheet>
254 + </div>
255 + );
256 +}
added apps/web/src/components/compare/country-picker.tsx +169 −0
@@ -0,0 +1,169 @@
1 +'use client';
2 +import { Search, X } from 'lucide-react';
3 +import { useEffect, useMemo, useRef, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import type { CountryLite } from '@/lib/types-compare';
7 +import { BottomSheet } from '@/components/data/bottom-sheet';
8 +
9 +/** Case/diacritic-insensitive prefix-then-substring match on name / ISO3 / slug. */
10 +export function filterCountries(items: CountryLite[], q: string, exclude: ReadonlySet<string> = new Set()): CountryLite[] {
11 + const ql = q.trim().toLowerCase();
12 + const pool = items.filter((c) => !exclude.has(c.slug));
13 + if (!ql) return pool;
14 + const starts = pool.filter((c) => c.name.toLowerCase().startsWith(ql) || c.id.toLowerCase() === ql || c.slug.startsWith(ql));
15 + const contains = pool.filter((c) => !starts.includes(c) && (c.name.toLowerCase().includes(ql) || c.slug.includes(ql)));
16 + return [...starts, ...contains];
17 +}
18 +
19 +/**
20 + * Country typeahead list (used inline on desktop and inside the bottom sheet on phones). Arrow keys move,
21 + * Enter picks, Esc clears. Already-selected countries are excluded.
22 + */
23 +export function CountryTypeahead({ countries, exclude, onPick, autoFocus, placeholder, max = 12, className, disabled }: { countries: CountryLite[]; exclude: ReadonlySet<string>; onPick: (c: CountryLite) => void; autoFocus?: boolean; placeholder?: string; max?: number; className?: string; disabled?: boolean }) {
24 + const [q, setQ] = useState('');
25 + const [open, setOpen] = useState(false);
26 + const [cursor, setCursor] = useState(0);
27 + const boxRef = useRef<HTMLDivElement>(null);
28 + const results = useMemo(() => filterCountries(countries, q, exclude).slice(0, max), [countries, q, exclude, max]);
29 +
30 + useEffect(() => {
31 + if (!open) return;
32 + const onDoc = (e: MouseEvent) => {
33 + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
34 + };
35 + document.addEventListener('mousedown', onDoc);
36 + return () => document.removeEventListener('mousedown', onDoc);
37 + }, [open]);
38 +
39 + const pick = (c: CountryLite) => {
40 + onPick(c);
41 + setQ('');
42 + setCursor(0);
43 + };
44 +
45 + return (
46 + <div ref={boxRef} className={cn('relative', className)}>
47 + <div className="relative">
48 + <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />
49 + <input
50 + type="search"
51 + role="combobox"
52 + aria-expanded={open && results.length > 0}
53 + aria-controls="country-typeahead-list"
54 + aria-autocomplete="list"
55 + autoFocus={autoFocus}
56 + disabled={disabled}
57 + value={q}
58 + onChange={(e) => {
59 + setQ(e.target.value);
60 + setOpen(true);
61 + setCursor(0);
62 + }}
63 + onFocus={() => setOpen(true)}
64 + onKeyDown={(e) => {
65 + if (e.key === 'ArrowDown') {
66 + e.preventDefault();
67 + setCursor((c) => Math.min(results.length - 1, c + 1));
68 + setOpen(true);
69 + } else if (e.key === 'ArrowUp') {
70 + e.preventDefault();
71 + setCursor((c) => Math.max(0, c - 1));
72 + } else if (e.key === 'Enter') {
73 + const r = results[cursor];
74 + if (r) {
75 + e.preventDefault();
76 + pick(r);
77 + }
78 + } else if (e.key === 'Escape') {
79 + setQ('');
80 + setOpen(false);
81 + }
82 + }}
83 + placeholder={placeholder ?? t('compare.builder.search')}
84 + aria-label={placeholder ?? t('compare.builder.search')}
85 + className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent disabled:opacity-50 md:h-10"
86 + />
87 + {q ? (
88 + <button type="button" onClick={() => setQ('')} className="absolute right-1 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>
89 + <X size={14} aria-hidden />
90 + </button>
91 + ) : null}
92 + </div>
93 + {open && (q || results.length) ? (
94 + <ul id="country-typeahead-list" role="listbox" className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-sm border border-rule bg-surface py-1 shadow-pop">
95 + {results.length === 0 ? (
96 + <li className="px-3 py-2 text-sm text-ink-3">{t('compare.builder.noMatch', { q })}</li>
97 + ) : (
98 + results.map((c, i) => (
99 + <li key={c.id} role="option" aria-selected={i === cursor}>
100 + <button
101 + type="button"
102 + onMouseDown={(e) => e.preventDefault()}
103 + onClick={() => pick(c)}
104 + onMouseEnter={() => setCursor(i)}
105 + className={cn('flex min-h-[44px] w-full items-center gap-2.5 px-3 text-left text-sm md:min-h-[36px]', i === cursor ? 'bg-surface-2 text-ink' : 'text-ink-2')}
106 + >
107 + <span aria-hidden className="text-lg leading-none">
108 + {c.flag}
109 + </span>
110 + <span className="truncate">{c.name}</span>
111 + <span className="tnum ml-auto text-2xs text-ink-3">{c.id}</span>
112 + </button>
113 + </li>
114 + ))
115 + )}
116 + </ul>
117 + ) : null}
118 + </div>
119 + );
120 +}
121 +
122 +/** Country picker in a sheet (bottom sheet on phones, centred panel on desktop): search + tappable list. */
123 +export function CountryPickerSheet({ open, onClose, countries, exclude, onPick, title, closeOnPick = true }: { open: boolean; onClose: () => void; countries: CountryLite[]; exclude: ReadonlySet<string>; onPick: (c: CountryLite) => void; title?: string; closeOnPick?: boolean }) {
124 + const [q, setQ] = useState('');
125 + const results = useMemo(() => filterCountries(countries, q, exclude), [countries, q, exclude]);
126 + useEffect(() => {
127 + if (!open) setQ('');
128 + }, [open]);
129 + return (
130 + <BottomSheet open={open} onClose={onClose} side="center" title={title ?? t('compare.addCountry')}>
131 + <div className="relative">
132 + <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />
133 + <input
134 + type="search"
135 + autoFocus
136 + value={q}
137 + onChange={(e) => setQ(e.target.value)}
138 + placeholder={t('compare.builder.search')}
139 + aria-label={t('compare.builder.search')}
140 + className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-3 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent"
141 + />
142 + </div>
143 + {results.length === 0 ? (
144 + <p className="py-6 text-center text-sm text-ink-3">{t('compare.builder.noMatch', { q })}</p>
145 + ) : (
146 + <ul className="mt-2 divide-y divide-rule">
147 + {results.map((c) => (
148 + <li key={c.id}>
149 + <button
150 + type="button"
151 + onClick={() => {
152 + onPick(c);
153 + if (closeOnPick) onClose();
154 + }}
155 + className="flex min-h-[48px] w-full items-center gap-3 py-1 text-left text-sm text-ink hover:text-accent"
156 + >
157 + <span aria-hidden className="text-xl leading-none">
158 + {c.flag}
159 + </span>
160 + <span className="truncate">{c.name}</span>
161 + <span className="tnum ml-auto text-2xs text-ink-3">{c.id}</span>
162 + </button>
163 + </li>
164 + ))}
165 + </ul>
166 + )}
167 + </BottomSheet>
168 + );
169 +}
added apps/web/src/components/compare/custom-panel.tsx +110 −0
@@ -0,0 +1,110 @@
1 +'use client';
2 +import { Plus, Search, X } from 'lucide-react';
3 +import { usePathname, useRouter } from 'next/navigation';
4 +import { useEffect, useMemo, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientCompare } from '@/lib/client-api-compare';
7 +import { cn } from '@/lib/cn';
8 +import { MAX_CUSTOM_INDICATORS, compareQuery, type CompareState } from '@/lib/compare-state';
9 +import { TOPICS, topicById } from '@/lib/topics';
10 +import type { IndicatorCard, IndicatorSummary } from '@/lib/types';
11 +import { BottomSheet } from '@/components/data/bottom-sheet';
12 +
13 +/**
14 + * Custom tab header: selected indicator chips (remove) + a picker sheet searching `/api/v1/indicators`
15 + * (fetched once on first open). Selection is written to `?indicators=` in the URL; charts are server-rendered.
16 + */
17 +export function CustomPanel({ selected, state }: { selected: IndicatorCard[]; state: CompareState }) {
18 + const router = useRouter();
19 + const pathname = usePathname();
20 + const [open, setOpen] = useState(false);
21 + const [q, setQ] = useState('');
22 + const [all, setAll] = useState<IndicatorSummary[] | null>(null);
23 + const [failed, setFailed] = useState(false);
24 + const slugs = state.indicators;
25 + const full = slugs.length >= MAX_CUSTOM_INDICATORS;
26 +
27 + useEffect(() => {
28 + if (!open || all || failed) return;
29 + const ctrl = new AbortController();
30 + clientCompare
31 + .indicators(ctrl.signal)
32 + .then((r) => setAll(r.items.filter((i) => (i.n_countries ?? 0) > 0)))
33 + .catch((e) => {
34 + if ((e as Error).name !== 'AbortError') setFailed(true);
35 + });
36 + return () => ctrl.abort();
37 + }, [open, all, failed]);
38 +
39 + const apply = (next: string[]) => router.replace(`${pathname}${compareQuery({ ...state, tab: 'custom', indicators: next, indicator: state.indicator && next.includes(state.indicator) ? state.indicator : null })}`, { scroll: false });
40 +
41 + const results = useMemo(() => {
42 + if (!all) return [];
43 + const ql = q.trim().toLowerCase();
44 + const pool = all.filter((i) => !slugs.includes(i.slug));
45 + const list = ql ? pool.filter((i) => (i.name ?? '').toLowerCase().includes(ql) || (i.short_name ?? '').toLowerCase().includes(ql) || i.slug.includes(ql) || (i.topic ?? '').includes(ql)) : pool;
46 + const order = new Map(TOPICS.map((tp, i) => [tp.id as string, i]));
47 + return [...list].sort((a, b) => (order.get(a.topic ?? '') ?? 99) - (order.get(b.topic ?? '') ?? 99) || Number(!!b.featured) - Number(!!a.featured) || (a.name ?? '').localeCompare(b.name ?? '')).slice(0, 80);
48 + }, [all, q, slugs]);
49 +
50 + return (
51 + <div>
52 + <div className="flex flex-wrap items-center gap-1.5">
53 + {selected.map((ind) => (
54 + <span key={ind.slug} className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2.5 text-sm md:h-9">
55 + <span className="max-w-[14rem] truncate text-ink">{ind.short_name ?? ind.name ?? ind.slug}</span>
56 + <button type="button" onClick={() => apply(slugs.filter((s) => s !== ind.slug))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('compare.custom.remove', { name: ind.short_name ?? ind.name ?? ind.slug })}>
57 + <X size={14} aria-hidden />
58 + </button>
59 + </span>
60 + ))}
61 + <button type="button" onClick={() => setOpen(true)} disabled={full} className={cn('inline-flex h-11 items-center gap-1 rounded-sm border border-dashed px-3 text-sm md:h-9', full ? 'border-rule text-ink-3' : 'border-rule-strong text-ink-2 hover:border-accent hover:text-accent')}>
62 + <Plus size={14} aria-hidden />
63 + {t('compare.custom.add')}
64 + </button>
65 + <span className="tnum ml-auto text-xs text-ink-3">{t('compare.custom.selected', { n: slugs.length, max: MAX_CUSTOM_INDICATORS })}</span>
66 + </div>
67 + {selected.length === 0 ? <p className="mt-4 border-t border-dashed border-rule-strong py-5 text-sm text-ink-3">{t('compare.custom.empty', { max: MAX_CUSTOM_INDICATORS })}</p> : null}
68 +
69 + <BottomSheet open={open} onClose={() => setOpen(false)} side="center" title={t('compare.custom.pick')}>
70 + <div className="relative">
71 + <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />
72 + <input type="search" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('compare.custom.search')} aria-label={t('compare.custom.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-3 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent" />
73 + </div>
74 + <p className="tnum mt-2 text-xs text-ink-3">{full ? t('compare.custom.full', { max: MAX_CUSTOM_INDICATORS }) : t('compare.custom.selected', { n: slugs.length, max: MAX_CUSTOM_INDICATORS })}</p>
75 + {failed ? (
76 + <p className="py-6 text-sm text-ink-3">{t('common.errorHint')}</p>
77 + ) : !all ? (
78 + <p className="py-6 text-sm text-ink-3">{t('common.loading')}</p>
79 + ) : results.length === 0 ? (
80 + <p className="py-6 text-center text-sm text-ink-3">{t('compare.custom.noMatch', { q })}</p>
81 + ) : (
82 + <ul className="mt-2 divide-y divide-rule">
83 + {results.map((i) => (
84 + <li key={i.slug}>
85 + <button
86 + type="button"
87 + disabled={full}
88 + onClick={() => {
89 + apply([...slugs, i.slug]);
90 + if (slugs.length + 1 >= MAX_CUSTOM_INDICATORS) setOpen(false);
91 + }}
92 + className="flex min-h-[48px] w-full items-center gap-3 py-1.5 text-left disabled:opacity-40"
93 + >
94 + <span className="min-w-0 flex-1">
95 + <span className="block truncate text-sm text-ink">{i.name ?? i.slug}</span>
96 + <span className="block truncate text-2xs text-ink-3">
97 + {topicById(i.topic ?? '')?.short ?? i.topic}
98 + {i.unit ? ` · ${i.unit}` : ''}
99 + </span>
100 + </span>
101 + <Plus size={16} aria-hidden className="shrink-0 text-ink-3" />
102 + </button>
103 + </li>
104 + ))}
105 + </ul>
106 + )}
107 + </BottomSheet>
108 + </div>
109 + );
110 +}
added apps/web/src/components/compare/snapshot-table.tsx +158 −0
@@ -0,0 +1,158 @@
1 +'use client';
2 +import { ChevronRight } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { COMPARE_TOPIC_TABS, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state';
7 +import { formatValue, isNum, ordinal } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare';
10 +import type { MetricValue } from '@/lib/types';
11 +import { seriesVar } from '@/components/charts/palette';
12 +import { payloadFor } from '@/components/data/metric';
13 +import { useProvenance } from '@/components/data/provenance-context';
14 +
15 +/** Where a snapshot indicator's chart lives: its topic tab when it is one of the compare tabs, else the custom tab. */
16 +export function chartHrefFor(slugs: string[], state: CompareState, indicator: { slug: string; topic: string | null }): string {
17 + const tab = (COMPARE_TOPIC_TABS as readonly string[]).includes(indicator.topic ?? '') ? (indicator.topic as CompareTab) : 'custom';
18 + const indicators = tab === 'custom' ? Array.from(new Set([indicator.slug, ...state.indicators])).slice(0, 6) : state.indicators;
19 + return `${routes.compare(...slugs)}${compareQuery({ ...state, tab, indicator: indicator.slug, indicators })}`;
20 +}
21 +
22 +/**
23 + * Snapshot comparison: countries as columns on md+ (value · year · rank chip per cell, best value highlighted);
24 + * on phones each indicator is a block with one horizontal mini bar per country — no horizontal overflow.
25 + * Any cell → provenance sheet; the indicator name → that indicator's chart.
26 + */
27 +export function SnapshotTable({ rows, countries, slugs, state }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; slugs: string[]; state: CompareState }) {
28 + const { open } = useProvenance();
29 + const visible = rows.filter((r) => countries.some((c) => r.values[c.id]?.has_data));
30 + if (visible.length === 0) return <p className="py-6 text-sm text-ink-3">{t('compare.snapshot.empty')}</p>;
31 +
32 + const openCell = (m: MetricValue, c: CountryLite, name: string) => open(payloadFor(m, { id: c.id, slug: c.slug, name: c.name, flag: c.flag }, { name }));
33 +
34 + return (
35 + <div className="min-w-0">
36 + {/* Desktop table */}
37 + <table className="hidden w-full border-collapse text-sm md:table">
38 + <caption className="sr-only">{t('compare.snapshot.title')}</caption>
39 + <thead>
40 + <tr className="border-b border-rule text-left text-xs text-ink-3">
41 + <th scope="col" className="w-[30%] py-2 pr-3 font-medium">
42 + {t('compare.snapshot.indicatorCol')}
43 + </th>
44 + {countries.map((c, i) => (
45 + <th key={c.id} scope="col" className="py-2 pr-3 text-right font-medium">
46 + <Link href={routes.country(c.slug)} className="link-quiet inline-flex max-w-full items-center gap-1.5">
47 + <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />
48 + <span aria-hidden>{c.flag}</span>
49 + <span className="truncate text-ink">{c.name}</span>
50 + </Link>
51 + </th>
52 + ))}
53 + </tr>
54 + </thead>
55 + <tbody className="divide-y divide-rule">
56 + {visible.map((r) => {
57 + const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;
58 + const hib = r.indicator.higher_is_better;
59 + return (
60 + <tr key={r.indicator.slug} className="hover:bg-surface-2/50">
61 + <th scope="row" className="py-2 pr-3 text-left align-top font-normal">
62 + <Link href={chartHrefFor(slugs, state, r.indicator)} className="group/i inline-flex min-h-[32px] items-center gap-1 text-sm font-medium text-ink hover:text-accent" aria-label={t('compare.snapshot.openChart', { name })}>
63 + <span>{r.indicator.name ?? name}</span>
64 + <ChevronRight size={13} aria-hidden className="shrink-0 text-ink-3 group-hover/i:text-accent" />
65 + </Link>
66 + <div className="text-2xs text-ink-3">
67 + {r.indicator.unit}
68 + {hib != null ? <span> · {hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}</span> : null}
69 + </div>
70 + </th>
71 + {countries.map((c) => {
72 + const m = r.values[c.id];
73 + const best = r.best === c.id;
74 + if (!m || !m.has_data)
75 + return (
76 + <td key={c.id} className="py-2 pr-3 text-right align-top text-ink-3">
77 + {t('common.noData')}
78 + </td>
79 + );
80 + return (
81 + <td key={c.id} className={cn('py-1 pr-1 text-right align-top', best && 'bg-accent-soft/60')}>
82 + <button type="button" onClick={() => openCell(m, c, r.indicator.name ?? name)} className="group/c -my-0 flex min-h-[44px] w-full flex-col items-end rounded-sm px-2 py-1 text-right hover:bg-surface-2" aria-label={t('common.openProvenance')}>
83 + <span className={cn('tnum text-base leading-tight', best ? 'font-semibold text-ink' : 'text-ink')}>{m.formatted ?? formatValue(m.value, m)}</span>
84 + <span className="tnum mt-0.5 flex items-center gap-1.5 text-2xs text-ink-3">
85 + <span>{m.year ?? ''}</span>
86 + {isNum(m.rank_world) && isNum(m.n_world) ? (
87 + <span className={cn('rounded-xs px-1 py-px', best ? 'bg-accent text-accent-ink' : 'bg-surface-2 text-ink-2')} title={t('compare.snapshot.rankOf', { rank: ordinal(m.rank_world), n: m.n_world })}>
88 + {t('compare.snapshot.rank', { rank: m.rank_world })}
89 + </span>
90 + ) : null}
91 + </span>
92 + </button>
93 + </td>
94 + );
95 + })}
96 + </tr>
97 + );
98 + })}
99 + </tbody>
100 + </table>
101 +
102 + {/* Phone: one block per indicator, one bar per country */}
103 + <ul className="divide-y divide-rule md:hidden">
104 + {visible.map((r) => {
105 + const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug;
106 + const vals = countries.map((c) => r.values[c.id]?.value).filter(isNum);
107 + const max = vals.length ? Math.max(...vals.map(Math.abs)) : 0;
108 + return (
109 + <li key={r.indicator.slug} className="py-3">
110 + <Link href={chartHrefFor(slugs, state, r.indicator)} className="group/i flex min-h-[44px] items-center justify-between gap-2" aria-label={t('compare.snapshot.openChart', { name })}>
111 + <span className="min-w-0">
112 + <span className="block text-sm font-medium text-ink group-hover/i:text-accent">{r.indicator.name ?? name}</span>
113 + <span className="block truncate text-2xs text-ink-3">{r.indicator.unit}</span>
114 + </span>
115 + <span className="inline-flex shrink-0 items-center gap-0.5 text-2xs text-accent">
116 + {t('compare.snapshot.showChart')}
117 + <ChevronRight size={12} aria-hidden />
118 + </span>
119 + </Link>
120 + <ol className="mt-1 space-y-0.5">
121 + {countries.map((c, i) => {
122 + const m = r.values[c.id];
123 + const best = r.best === c.id;
124 + const has = !!m && m.has_data && isNum(m.value);
125 + const pct = has && max > 0 ? Math.max(1.5, (Math.abs(m.value!) / max) * 100) : 0;
126 + return (
127 + <li key={c.id}>
128 + <button
129 + type="button"
130 + disabled={!has}
131 + onClick={() => has && openCell(m, c, r.indicator.name ?? name)}
132 + className="grid min-h-[44px] w-full grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-sm text-left hover:bg-surface-2 disabled:hover:bg-transparent"
133 + aria-label={has ? `${c.name}: ${m.formatted ?? formatValue(m.value, m)} (${m.year ?? ''}). ${t('common.openProvenance')}` : `${c.name}: ${t('common.noData')}`}
134 + >
135 + <span aria-hidden className="text-base leading-none">
136 + {c.flag}
137 + </span>
138 + <span className="min-w-0">
139 + <span className="h-2 w-full overflow-hidden rounded-xs bg-surface-2" aria-hidden style={{ display: 'block' }}>
140 + <span className="block h-full rounded-r-xs" style={{ width: `${pct}%`, background: seriesVar(i), opacity: best ? 1 : 0.8 }} />
141 + </span>
142 + </span>
143 + <span className="tnum flex items-baseline gap-1.5 text-right">
144 + <span className={cn('text-sm', best ? 'font-semibold text-ink' : 'text-ink')}>{has ? m.formatted ?? formatValue(m.value, m) : t('common.noData')}</span>
145 + <span className="text-2xs text-ink-3">{has ? m.year : ''}</span>
146 + </span>
147 + </button>
148 + </li>
149 + );
150 + })}
151 + </ol>
152 + </li>
153 + );
154 + })}
155 + </ul>
156 + </div>
157 + );
158 +}
modified apps/web/src/components/countries/directory.tsx +4 −4
@@ -126,7 +126,7 @@ export function CountryDirectory({ items }: { items: CountrySummary[] }) {
126 126 )}
127 127
128 128 {/* Header row (sm+) */}
129 − <div className="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] gap-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid md:grid-cols-[minmax(0,1fr)_9rem_8rem_8rem_5rem]">
129 + <div className="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] gap-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">
130 130 <span>{t('common.country')}</span>
131 131 <span className="hidden md:block">{t('common.region')}</span>
132 132 <span className="text-right">{t('common.population')}</span>
@@ -164,7 +164,7 @@ export function CountryDirectory({ items }: { items: CountrySummary[] }) {
164 164 <ol className="sticky top-20 flex flex-col items-center gap-0.5 text-2xs">
165 165 {letters.map((l) => (
166 166 <li key={l}>
167 − <a href={`#letter-${l}`} className="grid h-5 w-6 place-items-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-accent">
167 + <a href={`#letter-${l}`} className="grid h-6 w-8 place-items-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-accent">
168 168 {l}
169 169 </a>
170 170 </li>
@@ -191,7 +191,7 @@ export function CountryDirectory({ items }: { items: CountrySummary[] }) {
191 191 function Row({ c, rank }: { c: CountrySummary; rank?: number }) {
192 192 return (
193 193 <li>
194 − <Link href={routes.country(c.slug ?? c.id)} className="group grid min-h-[52px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] md:grid-cols-[minmax(0,1fr)_9rem_8rem_8rem_5rem]">
194 + <Link href={routes.country(c.slug ?? c.id)} className="group grid min-h-[52px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">
195 195 <span className="flex min-w-0 items-center gap-2.5">
196 196 {rank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{rank}</span> : null}
197 197 <span aria-hidden className="text-xl leading-none">
@@ -241,7 +241,7 @@ function ChipGroup({ label, value, onChange, options }: { label: string; value:
241 241
242 242 function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
243 243 return (
244 − <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-9 items-center rounded-sm border px-2.5 text-sm', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
244 + <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-11 items-center rounded-sm border px-3 text-sm md:h-9 md:px-2.5', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
245 245 {children}
246 246 </button>
247 247 );
modified apps/web/src/components/country/indicator-row.tsx +4 −4
@@ -76,7 +76,7 @@ export function IndicatorRow({ metric, country, regionName, series: initialSerie
76 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 77 <div className="min-w-0">
78 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">
79 + <Link href={routes.indicator(m.indicator)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2">
80 80 {series?.indicator.name ?? name}
81 81 </Link>
82 82 </h3>
@@ -95,13 +95,13 @@ export function IndicatorRow({ metric, country, regionName, series: initialSerie
95 95 <RankBadge rank={m} regionName={regionName} />
96 96 </div>
97 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">
98 + <Link href={routes.compare(country.slug ?? country.id)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
99 99 {t('topic.compareLink')}
100 100 </Link>
101 − <Link href={routes.ranking(m.indicator)} className="inline-flex min-h-[32px] items-center text-accent hover:underline">
101 + <Link href={routes.ranking(m.indicator)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
102 102 {t('topic.rankingLink')}
103 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">
104 + <Link href={routes.indicator(m.indicator)} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">
105 105 {t('topic.indicatorLink')}
106 106 </Link>
107 107 </div>
modified apps/web/src/components/country/similar-panel.tsx +5 −5
@@ -42,7 +42,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia
42 42 <div>
43 43 <div role="tablist" aria-label={t('country.similar.title', { name: '' }).trim()} className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0">
44 44 {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => (
45 − <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-9 shrink-0 items-center rounded-sm px-3 text-sm', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
45 + <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-11 shrink-0 items-center rounded-sm px-3 text-sm md:h-9', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
46 46 {t(`country.similar.mode.${m}` as const)}
47 47 </button>
48 48 ))}
@@ -57,16 +57,16 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia
57 57 const open = openPeer === key;
58 58 const contribs = parseContribs(p.contributions);
59 59 return (
60 − <li key={p.country.id} className="py-2">
61 − <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_minmax(4rem,7rem)_3.5rem_2rem] items-center gap-x-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,12rem)_3.5rem_2rem]">
60 + <li key={p.country.id} className="py-1 sm:py-2">
61 + <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_3.5rem_2rem] items-center gap-x-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,12rem)_3.5rem_2rem]">
62 62 <span className="tnum text-xs text-ink-3">{p.rank}</span>
63 − <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-w-0 items-center gap-1.5 text-sm">
63 + <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-1.5 text-sm sm:min-h-0">
64 64 <span aria-hidden className="text-base leading-none">
65 65 {p.country.flag}
66 66 </span>
67 67 <span className="truncate">{p.country.name}</span>
68 68 </Link>
69 − <div className="h-2 rounded-xs bg-surface-2" aria-hidden>
69 + <div className="order-last col-span-full mb-1 h-2 rounded-xs bg-surface-2 sm:order-none sm:col-span-1 sm:mb-0" aria-hidden>
70 70 <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />
71 71 </div>
72 72 <span className="tnum text-right text-sm font-medium text-ink" aria-label={t('country.similar.score', { score: fixed(p.score ?? 0, 0) })}>
added apps/web/src/components/explore/copy-button.tsx +42 −0
@@ -0,0 +1,42 @@
1 +'use client';
2 +import { Check, Copy } from 'lucide-react';
3 +import { useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +
7 +/** Small "Copy" button for code snippets (clipboard API; falls back to a hidden textarea select). */
8 +export function CopyButton({ text, className, label }: { text: string; className?: string; label?: string }) {
9 + const [done, setDone] = useState(false);
10 + const copy = async () => {
11 + try {
12 + await navigator.clipboard.writeText(text);
13 + } catch {
14 + const ta = document.createElement('textarea');
15 + ta.value = text;
16 + document.body.appendChild(ta);
17 + ta.select();
18 + document.execCommand('copy');
19 + ta.remove();
20 + }
21 + setDone(true);
22 + setTimeout(() => setDone(false), 1600);
23 + };
24 + return (
25 + <button type="button" onClick={copy} className={cn('inline-flex min-h-[36px] items-center gap-1 rounded-sm border border-rule px-2 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink', className)} aria-live="polite">
26 + {done ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
27 + {done ? t('indicator.copied') : label ?? t('indicator.copy')}
28 + </button>
29 + );
30 +}
31 +
32 +/** Monospace code block with a copy button in the corner. */
33 +export function CodeBlock({ code, className, lang }: { code: string; className?: string; lang?: string }) {
34 + return (
35 + <div className={cn('relative min-w-0 rounded-sm border border-rule bg-surface', className)}>
36 + <pre className="overflow-x-auto p-3 pr-20 font-mono text-xs leading-relaxed text-ink" data-lang={lang}>
37 + <code>{code}</code>
38 + </pre>
39 + <CopyButton text={code} className="absolute right-2 top-2 bg-surface" />
40 + </div>
41 + );
42 +}
added apps/web/src/components/explore/download-picker.tsx +37 −0
@@ -0,0 +1,37 @@
1 +'use client';
2 +import { Download } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { cn } from '@/lib/cn';
7 +import { routes } from '@/lib/site';
8 +import { EntityPicker, type PickedEntity } from './entity-picker';
9 +
10 +/** Typeahead → CSV / JSON download links for a country or an indicator dataset. */
11 +export function DownloadPicker({ type }: { type: 'country' | 'indicator' }) {
12 + const [picked, setPicked] = useState<PickedEntity | null>(null);
13 + const href = (fmt: 'csv' | 'json') => (picked ? (type === 'country' ? routes.countryDownload(picked.id, fmt) : routes.indicatorDownload(picked.slug, fmt)) : '#');
14 + const disabled = !picked;
15 + const cls = cn('inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border px-3 text-sm md:min-h-[36px]', disabled ? 'pointer-events-none border-rule text-ink-3' : 'border-rule text-ink hover:bg-surface-2');
16 + return (
17 + <div className="min-w-0">
18 + <EntityPicker type={type} placeholder={type === 'country' ? t('data.country.search') : t('data.indicator.search')} onPick={setPicked} keepValue className="max-w-md" />
19 + <div className="mt-3 flex flex-wrap items-center gap-2">
20 + <a href={href('csv')} className={cls} aria-disabled={disabled} download>
21 + <Download size={14} aria-hidden /> CSV
22 + </a>
23 + <a href={href('json')} className={cls} aria-disabled={disabled} download>
24 + <Download size={14} aria-hidden /> JSON
25 + </a>
26 + {picked ? (
27 + <Link href={type === 'country' ? routes.country(picked.slug) : routes.indicator(picked.slug)} className="inline-flex min-h-[44px] items-center gap-1 text-sm text-accent hover:underline md:min-h-[36px]">
28 + {picked.flag ? <span aria-hidden>{picked.flag}</span> : null}
29 + {picked.name} →
30 + </Link>
31 + ) : (
32 + <span className="text-xs text-ink-3">{t('data.pick')}</span>
33 + )}
34 + </div>
35 + </div>
36 + );
37 +}
added apps/web/src/components/explore/entity-picker-nav.tsx +10 −0
@@ -0,0 +1,10 @@
1 +'use client';
2 +import { useRouter } from 'next/navigation';
3 +import { routes } from '@/lib/site';
4 +import { EntityPicker } from './entity-picker';
5 +
6 +/** EntityPicker that navigates to the picked country / indicator page. */
7 +export function EntityPickerNav({ type, placeholder, className }: { type: 'country' | 'indicator'; placeholder: string; className?: string }) {
8 + const router = useRouter();
9 + return <EntityPicker type={type} placeholder={placeholder} className={className} onPick={(e) => router.push(type === 'country' ? routes.country(e.slug) : routes.indicator(e.slug))} />;
10 +}
added apps/web/src/components/explore/entity-picker.tsx +164 −0
@@ -0,0 +1,164 @@
1 +'use client';
2 +import { Search, X } from 'lucide-react';
3 +import { useEffect, useId, useRef, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { clientExplore } from '@/lib/client-api-explore';
6 +import { cn } from '@/lib/cn';
7 +import type { SearchHit } from '@/lib/types';
8 +
9 +export interface PickedEntity {
10 + type: 'country' | 'indicator';
11 + id: string;
12 + slug: string;
13 + name: string;
14 + flag?: string | null;
15 + hint?: string | null;
16 +}
17 +
18 +/**
19 + * Typeahead over `/api/v1/search?type=country|indicator`. Keyboard: ↑↓ ↵ esc. Emits a `PickedEntity`
20 + * and clears itself (`keepValue` to show the picked name instead). 44 px tall on phones.
21 + */
22 +export function EntityPicker({
23 + type,
24 + placeholder,
25 + onPick,
26 + exclude = [],
27 + keepValue = false,
28 + autoFocus = false,
29 + className,
30 + size = 'md',
31 +}: {
32 + type: 'country' | 'indicator';
33 + placeholder: string;
34 + onPick: (e: PickedEntity) => void;
35 + exclude?: string[];
36 + keepValue?: boolean;
37 + autoFocus?: boolean;
38 + className?: string;
39 + size?: 'sm' | 'md';
40 +}) {
41 + const id = useId();
42 + const [q, setQ] = useState('');
43 + const [hits, setHits] = useState<SearchHit[]>([]);
44 + const [open, setOpen] = useState(false);
45 + const [active, setActive] = useState(0);
46 + const [loading, setLoading] = useState(false);
47 + const abortRef = useRef<AbortController | null>(null);
48 + const wrapRef = useRef<HTMLDivElement>(null);
49 +
50 + useEffect(() => {
51 + const term = q.trim();
52 + abortRef.current?.abort();
53 + if (term.length < 1) {
54 + setHits([]);
55 + setLoading(false);
56 + return;
57 + }
58 + const ctrl = new AbortController();
59 + abortRef.current = ctrl;
60 + setLoading(true);
61 + const timer = setTimeout(async () => {
62 + try {
63 + const res = await clientExplore.search(term, type, 10, ctrl.signal);
64 + if (ctrl.signal.aborted) return;
65 + setHits(res.hits.filter((h) => h.type === type && !exclude.includes(h.id) && !exclude.includes(h.slug ?? '')));
66 + setActive(0);
67 + setOpen(true);
68 + } catch {
69 + if (!ctrl.signal.aborted) setHits([]);
70 + } finally {
71 + if (!ctrl.signal.aborted) setLoading(false);
72 + }
73 + }, 140);
74 + return () => clearTimeout(timer);
75 + // eslint-disable-next-line react-hooks/exhaustive-deps
76 + }, [q, type, exclude.join(',')]);
77 +
78 + useEffect(() => {
79 + const onDoc = (e: PointerEvent) => {
80 + if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
81 + };
82 + document.addEventListener('pointerdown', onDoc);
83 + return () => document.removeEventListener('pointerdown', onDoc);
84 + }, []);
85 +
86 + const pick = (h: SearchHit) => {
87 + onPick({ type, id: h.id, slug: h.slug ?? h.id, name: h.name, flag: h.country?.flag ?? null, hint: h.hint });
88 + setQ(keepValue ? h.name : '');
89 + setHits([]);
90 + setOpen(false);
91 + };
92 +
93 + const onKey = (e: React.KeyboardEvent) => {
94 + if (e.key === 'ArrowDown') {
95 + e.preventDefault();
96 + setOpen(true);
97 + setActive((a) => Math.min(hits.length - 1, a + 1));
98 + } else if (e.key === 'ArrowUp') {
99 + e.preventDefault();
100 + setActive((a) => Math.max(0, a - 1));
101 + } else if (e.key === 'Enter') {
102 + const h = hits[active];
103 + if (h) {
104 + e.preventDefault();
105 + pick(h);
106 + }
107 + } else if (e.key === 'Escape') {
108 + setOpen(false);
109 + }
110 + };
111 +
112 + const listId = `${id}-list`;
113 + return (
114 + <div ref={wrapRef} className={cn('relative min-w-0', className)}>
115 + <div className="relative flex items-center">
116 + <Search size={15} aria-hidden className="absolute left-2.5 text-ink-3" />
117 + <input
118 + type="search"
119 + value={q}
120 + onChange={(e) => setQ(e.target.value)}
121 + onFocus={() => hits.length && setOpen(true)}
122 + onKeyDown={onKey}
123 + placeholder={placeholder}
124 + aria-label={placeholder}
125 + role="combobox"
126 + aria-expanded={open}
127 + aria-controls={listId}
128 + aria-autocomplete="list"
129 + aria-activedescendant={open && hits[active] ? `${id}-opt-${hits[active].id}` : undefined}
130 + autoComplete="off"
131 + autoFocus={autoFocus}
132 + enterKeyHint="go"
133 + className={cn('w-full rounded-sm border border-rule bg-surface pl-8 pr-8 text-sm outline-none placeholder:text-ink-3 focus:border-accent', size === 'sm' ? 'h-11 md:h-9' : 'h-11 md:h-10')}
134 + />
135 + {q ? (
136 + <button type="button" onClick={() => { setQ(''); setHits([]); }} className="absolute right-0 grid h-11 w-9 place-items-center text-ink-3 hover:text-ink md:h-9" aria-label={t('search.clear')}>
137 + <X size={14} aria-hidden />
138 + </button>
139 + ) : null}
140 + </div>
141 + {open && (hits.length > 0 || loading) ? (
142 + <ul id={listId} role="listbox" className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-sm border border-rule bg-surface py-1 shadow-pop">
143 + {loading && hits.length === 0 ? <li className="px-3 py-2 text-xs text-ink-3">{t('search.loading')}</li> : null}
144 + {hits.map((h, i) => (
145 + <li key={h.id} id={`${id}-opt-${h.id}`} role="option" aria-selected={i === active}>
146 + <button
147 + type="button"
148 + onMouseEnter={() => setActive(i)}
149 + onClick={() => pick(h)}
150 + className={cn('flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm md:min-h-[36px]', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60')}
151 + >
152 + {h.country?.flag ? <span aria-hidden className="w-5 text-center text-base leading-none">{h.country.flag}</span> : null}
153 + <span className="min-w-0 flex-1">
154 + <span className="block truncate text-ink">{h.name}</span>
155 + {h.hint ? <span className="block truncate text-2xs text-ink-3">{h.hint}</span> : null}
156 + </span>
157 + </button>
158 + </li>
159 + ))}
160 + </ul>
161 + ) : null}
162 + </div>
163 + );
164 +}
added apps/web/src/components/explore/page-header.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +
5 +/** Editorial page header: optional breadcrumb + eyebrow, display title, lede, right-side actions. */
6 +export function PageHeader({
7 + title,
8 + lede,
9 + eyebrow,
10 + crumbs,
11 + actions,
12 + meta,
13 + className,
14 +}: {
15 + title: ReactNode;
16 + lede?: ReactNode;
17 + eyebrow?: ReactNode;
18 + crumbs?: Array<{ href: string; label: ReactNode }>;
19 + actions?: ReactNode;
20 + meta?: ReactNode;
21 + className?: string;
22 +}) {
23 + return (
24 + <header className={cn('pb-4 pt-6 md:pt-10', className)}>
25 + {crumbs?.length ? (
26 + <nav aria-label="Breadcrumb" className="text-xs text-ink-3">
27 + {crumbs.map((c, i) => (
28 + <span key={c.href}>
29 + {i > 0 ? <span className="mx-1.5">/</span> : null}
30 + <Link href={c.href} className="-my-3 inline-flex min-h-[44px] items-center hover:text-accent md:my-0 md:min-h-0">
31 + {c.label}
32 + </Link>
33 + </span>
34 + ))}
35 + </nav>
36 + ) : null}
37 + <div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
38 + <div className="min-w-0 max-w-3xl">
39 + {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}
40 + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{title}</h1>
41 + {lede ? <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{lede}</p> : null}
42 + {meta ? <div className="tnum mt-1 text-xs text-ink-3">{meta}</div> : null}
43 + </div>
44 + {actions ? <div className="flex flex-wrap items-center gap-2 text-sm">{actions}</div> : null}
45 + </div>
46 + </header>
47 + );
48 +}
49 +
50 +/** Quiet secondary action link/button look shared by the explore pages. */
51 +export const ACTION_CLS = 'inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-rule px-3 text-sm text-ink hover:bg-surface-2 md:min-h-[36px]';
52 +export const PRIMARY_ACTION_CLS = 'inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-ink px-3 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[36px]';
added apps/web/src/components/explore/similarity-playground.tsx +90 −0
@@ -0,0 +1,90 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useEffect, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { clientExplore } from '@/lib/client-api-explore';
6 +import { cn } from '@/lib/cn';
7 +import { fixed } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { SimilarResponse, SimilarityMode } from '@/lib/types';
10 +import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';
11 +
12 +const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social'];
13 +
14 +/** Pick a country → peers per mode from `/countries/{id}/similar?mode=`. Mode tabs, score bars (0–100). */
15 +export function SimilarityPlayground({ initial }: { initial?: PickedEntity | null }) {
16 + const [country, setCountry] = useState<PickedEntity | null>(initial ?? null);
17 + const [mode, setMode] = useState<SimilarityMode>('overall');
18 + const [data, setData] = useState<Record<string, SimilarResponse | null>>({});
19 + const [loading, setLoading] = useState(false);
20 +
21 + useEffect(() => {
22 + if (!country) return;
23 + const key = `${country.id}:${mode}`;
24 + if (data[key] !== undefined) return;
25 + const ctrl = new AbortController();
26 + setLoading(true);
27 + clientExplore
28 + .similar(country.id, mode, 10, ctrl.signal)
29 + .then((r) => setData((d) => ({ ...d, [key]: r })))
30 + .catch(() => setData((d) => ({ ...d, [key]: null })))
31 + .finally(() => {
32 + if (!ctrl.signal.aborted) setLoading(false);
33 + });
34 + return () => ctrl.abort();
35 + }, [country, mode, data]);
36 +
37 + const current = country ? data[`${country.id}:${mode}`] : undefined;
38 + const peers = current?.peers ?? [];
39 +
40 + return (
41 + <div className="min-w-0">
42 + <div className="flex flex-wrap items-center gap-2">
43 + <EntityPicker type="country" placeholder={t('explore.similar.pick')} onPick={setCountry} className="w-full max-w-sm" />
44 + {country ? (
45 + <Link href={routes.country(country.slug)} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-ink md:min-h-[36px]">
46 + <span aria-hidden className="text-lg leading-none">{country.flag}</span>
47 + <span className="font-medium">{country.name}</span>
48 + <span className="text-ink-3">→</span>
49 + </Link>
50 + ) : null}
51 + </div>
52 + {country ? (
53 + <>
54 + <div role="tablist" aria-label={t('explore.similar.peers', { name: country.name })} className="scrollbar-none -mx-4 mt-3 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0">
55 + {MODES.map((m) => (
56 + <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-11 shrink-0 items-center rounded-sm px-3 text-sm md:h-9', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
57 + {t(`country.similar.mode.${m}` as const)}
58 + </button>
59 + ))}
60 + </div>
61 + <div className={cn('mt-3 min-h-[240px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}>
62 + {current === null || (current && peers.length === 0) ? (
63 + <p className="py-4 text-sm text-ink-3">{t('explore.similar.none', { name: country.name })}</p>
64 + ) : (
65 + <ol className="divide-y divide-rule">
66 + {peers.map((p) => (
67 + <li key={p.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_3rem] items-center gap-x-2 py-1 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,14rem)_3rem] sm:py-1.5">
68 + <span className="tnum text-xs text-ink-3">{p.rank}</span>
69 + <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-1.5 text-sm sm:min-h-0">
70 + <span aria-hidden className="text-base leading-none">{p.country.flag}</span>
71 + <span className="truncate">{p.country.name}</span>
72 + </Link>
73 + <div className="col-span-full mb-1 h-2 rounded-xs bg-surface-2 sm:col-span-1 sm:mb-0" aria-hidden>
74 + <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />
75 + </div>
76 + <span className="tnum text-right text-sm font-medium text-ink" aria-label={`${t('explore.similar.score')} ${fixed(p.score ?? 0, 0)}`}>
77 + {p.score != null ? fixed(p.score, 0) : t('common.na')}
78 + </span>
79 + </li>
80 + ))}
81 + </ol>
82 + )}
83 + </div>
84 + </>
85 + ) : (
86 + <p className="mt-4 text-sm text-ink-3">{t('explore.similar.empty')}</p>
87 + )}
88 + </div>
89 + );
90 +}
modified apps/web/src/components/home/region-chips.tsx +2 −2
@@ -8,13 +8,13 @@ export function RegionChips() {
8 8 return (
9 9 <ul className="scrollbar-none -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
10 10 <li>
11 − <Link href={routes.countries()} className="inline-flex h-9 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink hover:border-accent hover:text-accent">
11 + <Link href={routes.countries()} className="inline-flex h-11 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink md:h-9 hover:border-accent hover:text-accent">
12 12 {t('home.explore.allCountries')}
13 13 </Link>
14 14 </li>
15 15 {WB_REGIONS.map((r) => (
16 16 <li key={r.id}>
17 − <Link href={`${routes.countries()}?region=${r.id}`} className="inline-flex h-9 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent">
17 + <Link href={`${routes.countries()}?region=${r.id}`} className="inline-flex h-11 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 md:h-9 hover:border-accent hover:text-accent">
18 18 {r.short}
19 19 </Link>
20 20 </li>
added apps/web/src/components/indicators/directory.tsx +161 −0
@@ -0,0 +1,161 @@
1 +'use client';
2 +import { X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useEffect, useMemo, useRef, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { cn } from '@/lib/cn';
8 +import { formatDate, grouped } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import { TOPICS, topicById } from '@/lib/topics';
11 +import type { IndicatorSummary } from '@/lib/types';
12 +import { FreshnessBadge } from '@/components/data/freshness-badge';
13 +
14 +/**
15 + * Indicator directory: ~260 rows server-rendered, filtered client-side. Topic anchor nav (sticky column on
16 + * desktop, horizontal chip bar on mobile) + text filter. `?topic=` selects one topic (membership from the API,
17 + * an indicator can belong to several topics); no topic = every topic as an anchored section.
18 + */
19 +export function IndicatorDirectory({ items, topicMembers, initialTopic }: { items: IndicatorSummary[]; topicMembers: Record<string, string[]>; initialTopic: string | null }) {
20 + const router = useRouter();
21 + const [topic, setTopic] = useState<string | null>(initialTopic);
22 + const [q, setQ] = useState('');
23 + const navRef = useRef<HTMLElement>(null);
24 +
25 + useEffect(() => {
26 + const el = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
27 + el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior });
28 + }, [topic]);
29 +
30 + const bySlug = useMemo(() => new Map(items.map((i) => [i.slug, i])), [items]);
31 + const ql = q.trim().toLowerCase();
32 + const matches = (i: IndicatorSummary) => !ql || [i.name, i.short_name, i.slug, i.unit, i.subtopic, i.description].some((s) => (s ?? '').toLowerCase().includes(ql));
33 +
34 + const sections = useMemo(() => {
35 + const order = topic ? TOPICS.filter((tp) => tp.id === topic) : TOPICS;
36 + return order
37 + .map((tp) => {
38 + const slugs = topicMembers[tp.id] ?? items.filter((i) => i.topic === tp.id).map((i) => i.slug);
39 + const rows = slugs.map((s) => bySlug.get(s)).filter((i): i is IndicatorSummary => !!i && matches(i));
40 + return { tp, rows, total: slugs.length };
41 + })
42 + .filter((s) => s.rows.length > 0 || !ql);
43 + // eslint-disable-next-line react-hooks/exhaustive-deps
44 + }, [topic, ql, items, topicMembers, bySlug]);
45 +
46 + const shown = sections.reduce((a, s) => a + s.rows.length, 0);
47 +
48 + const choose = (id: string | null) => {
49 + setTopic(id);
50 + router.replace(routes.indicators(id ?? undefined), { scroll: false });
51 + };
52 +
53 + const nav = (
54 + <nav ref={navRef} aria-label={t('indicators.topics')} className="lg:sticky lg:top-20">
55 + <ul className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 pb-1 lg:mx-0 lg:flex-col lg:gap-0 lg:overflow-visible lg:px-0">
56 + <li className="shrink-0">
57 + <button type="button" aria-current={topic == null} onClick={() => choose(null)} className={cn('inline-flex h-11 w-full items-center justify-between gap-2 whitespace-nowrap rounded-sm px-3 text-sm lg:h-9 lg:px-2', topic == null ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
58 + {t('indicators.allTopics')}
59 + <span className={cn('tnum text-2xs', topic == null ? 'text-paper/70' : 'text-ink-3')}>{grouped(items.length)}</span>
60 + </button>
61 + </li>
62 + {TOPICS.map((tp) => {
63 + const n = (topicMembers[tp.id] ?? []).length || items.filter((i) => i.topic === tp.id).length;
64 + const active = topic === tp.id;
65 + return (
66 + <li key={tp.id} className="shrink-0">
67 + <button type="button" aria-current={active} onClick={() => choose(tp.id)} className={cn('inline-flex h-11 w-full items-center justify-between gap-2 whitespace-nowrap rounded-sm px-3 text-sm lg:h-9 lg:px-2', active ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
68 + {tp.short}
69 + <span className={cn('tnum text-2xs', active ? 'text-paper/70' : 'text-ink-3')}>{n}</span>
70 + </button>
71 + </li>
72 + );
73 + })}
74 + </ul>
75 + </nav>
76 + );
77 +
78 + return (
79 + <div className="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-10">
80 + <div className="lg:self-start">{nav}</div>
81 + <div className="min-w-0">
82 + <div className="flex flex-wrap items-center gap-2 py-3">
83 + <div className="relative min-w-0 flex-1 basis-56">
84 + <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('indicators.search')} aria-label={t('indicators.search')} className="h-11 w-full rounded-sm border border-rule bg-surface px-3 text-sm outline-none placeholder:text-ink-3 focus:border-accent md:h-10" />
85 + {q ? (
86 + <button type="button" onClick={() => setQ('')} className="absolute right-0 top-0 grid h-11 w-10 place-items-center text-ink-3 hover:text-ink md:h-10" aria-label={t('search.clear')}>
87 + <X size={14} aria-hidden />
88 + </button>
89 + ) : null}
90 + </div>
91 + <span className="tnum ml-auto text-xs text-ink-3">{t('indicators.count', { n: grouped(shown), total: grouped(items.length) })}</span>
92 + </div>
93 +
94 + {shown === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('indicators.noMatch')}</p> : null}
95 +
96 + {sections.map(({ tp, rows }) =>
97 + rows.length === 0 ? null : (
98 + <section key={tp.id} id={`topic-${tp.id}`} className="scroll-mt-28 pt-4 pb-6">
99 + <div className="flex items-baseline justify-between gap-3 border-b border-rule pb-1.5">
100 + <h2 className="display text-xl text-ink md:text-2xl">
101 + {topic ? tp.name : <button type="button" onClick={() => choose(tp.id)} className="link-quiet text-left">{tp.name}</button>}
102 + </h2>
103 + <span className="tnum text-xs text-ink-3">{t('indicators.inTopic', { n: rows.length, topic: tp.short })}</span>
104 + </div>
105 + {topic ? <p className="mt-1 text-sm text-ink-2">{tp.blurb}</p> : null}
106 + <ul className="divide-y divide-rule">
107 + {rows.map((i) => (
108 + <IndicatorRowItem key={`${tp.id}-${i.slug}`} i={i} />
109 + ))}
110 + </ul>
111 + </section>
112 + ),
113 + )}
114 + </div>
115 + </div>
116 + );
117 +}
118 +
119 +function IndicatorRowItem({ i }: { i: IndicatorSummary }) {
120 + const cov = i.n_countries != null ? (i.first_year && i.last_year ? t('indicators.coverage', { n: grouped(i.n_countries), y0: i.first_year, y1: i.last_year }) : t('indicators.coverageShort', { n: grouped(i.n_countries) })) : t('common.noData');
121 + const tp = topicById(i.topic ?? '');
122 + return (
123 + <li className="py-2.5">
124 + <div className="grid gap-x-6 gap-y-1 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
125 + <div className="min-w-0">
126 + <Link href={routes.indicator(i.slug)} className="link-quiet -my-1 inline-flex min-h-[44px] items-center py-1 text-sm font-medium text-ink md:min-h-0">
127 + {i.name ?? i.slug}
128 + {i.featured ? <span className="ml-2 rounded-xs border border-rule px-1 text-2xs font-normal uppercase tracking-wide text-ink-3">{t('indicators.featured')}</span> : null}
129 + </Link>
130 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-3">
131 + {i.unit ? <span>{i.unit}</span> : null}
132 + {i.subtopic ? <span>· {i.subtopic}</span> : tp ? <span>· {tp.short}</span> : null}
133 + <span className="tnum">· {cov}</span>
134 + {i.primary_source_id ? (
135 + <Link href={routes.source(i.primary_source_id)} className="-my-3 inline-flex min-h-[44px] items-center md:-my-1 md:min-h-0">
136 + <span className="rounded-xs border border-rule px-1 py-px text-2xs uppercase tracking-wide hover:border-accent hover:text-accent">
137 + {i.primary_source_id}
138 + </span>
139 + </Link>
140 + ) : null}
141 + {i.latest_source_updated_at ? <FreshnessBadge retrievedAt={i.latest_source_updated_at} /> : null}
142 + {i.latest_source_updated_at ? <span className="sr-only">{t('indicators.freshness', { date: formatDate(i.latest_source_updated_at) })}</span> : null}
143 + </div>
144 + </div>
145 + <div className="flex flex-wrap gap-x-3 text-xs">
146 + <Link href={routes.indicator(i.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
147 + {t('indicators.openLink')}
148 + </Link>
149 + {i.ranking_eligible !== false ? (
150 + <Link href={routes.ranking(i.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
151 + {t('indicators.rankingLink')}
152 + </Link>
153 + ) : null}
154 + <Link href={`${routes.compare()}?indicator=${encodeURIComponent(i.slug)}`} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">
155 + {t('indicators.compareLink')}
156 + </Link>
157 + </div>
158 + </div>
159 + </li>
160 + );
161 +}
added apps/web/src/components/indicators/indicator-compare.tsx +117 −0
@@ -0,0 +1,117 @@
1 +'use client';
2 +import { X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useMemo, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientExplore } from '@/lib/client-api-explore';
7 +import { cn } from '@/lib/cn';
8 +import { routes } from '@/lib/site';
9 +import type { FormatSpec, Series } from '@/lib/types';
10 +import { LineChart, type LineSeries } from '@/components/charts/line-chart';
11 +import { pointsFromSeries } from '@/components/charts/scales';
12 +import { seriesVar } from '@/components/charts/palette';
13 +import { EmptyState } from '@/components/data/empty-state';
14 +import type { ProvenancePayload } from '@/components/data/provenance-context';
15 +import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';
16 +
17 +export interface CompareCountry {
18 + id: string;
19 + slug: string;
20 + name: string;
21 + flag: string | null;
22 +}
23 +
24 +const MAX = 5;
25 +
26 +/**
27 + * Country comparison teaser on an indicator page: chips for the selected countries (≤ 5), a typeahead to add
28 + * one, and a multi-series LineChart from `/series?country=&indicator=`. Colour slot follows the country's
29 + * position in the selection (stable while it stays selected).
30 + */
31 +export function IndicatorCompare({ slug, spec, initialCountries, initialSeries, payload }: { slug: string; spec: FormatSpec; initialCountries: CompareCountry[]; initialSeries: Series[]; payload: ProvenancePayload | null }) {
32 + const [countries, setCountries] = useState<CompareCountry[]>(initialCountries);
33 + const [seriesById, setSeriesById] = useState<Record<string, Series | null>>(() => Object.fromEntries(initialSeries.map((s) => [s.country.id, s])));
34 + const [loading, setLoading] = useState(false);
35 + const abortRef = useRef<AbortController | null>(null);
36 +
37 + useEffect(() => {
38 + const missing = countries.filter((c) => seriesById[c.id] === undefined).map((c) => c.id);
39 + if (missing.length === 0) return;
40 + abortRef.current?.abort();
41 + const ctrl = new AbortController();
42 + abortRef.current = ctrl;
43 + setLoading(true);
44 + clientExplore
45 + .seriesBundle(missing, slug, ctrl.signal)
46 + .then((r) => {
47 + const next: Record<string, Series | null> = {};
48 + for (const id of missing) next[id] = r.series.find((s) => s.country.id === id) ?? null;
49 + setSeriesById((m) => ({ ...m, ...next }));
50 + })
51 + .catch((e) => {
52 + if ((e as Error).name !== 'AbortError') setSeriesById((m) => ({ ...m, ...Object.fromEntries(missing.map((id) => [id, null])) }));
53 + })
54 + .finally(() => {
55 + if (!ctrl.signal.aborted) setLoading(false);
56 + });
57 + return () => ctrl.abort();
58 + }, [countries, slug, seriesById]);
59 +
60 + const add = (e: PickedEntity) => {
61 + if (countries.length >= MAX || countries.some((c) => c.id === e.id)) return;
62 + setCountries((cs) => [...cs, { id: e.id, slug: e.slug, name: e.name, flag: e.flag ?? null }]);
63 + };
64 + const remove = (id: string) => setCountries((cs) => cs.filter((c) => c.id !== id));
65 +
66 + const lines: LineSeries[] = useMemo(() => {
67 + const out: LineSeries[] = [];
68 + countries.forEach((c, i) => {
69 + const s = seriesById[c.id];
70 + if (!s) return;
71 + const points = pointsFromSeries(s.values);
72 + if (points.length) out.push({ id: c.id, name: c.name, points, colorIndex: i });
73 + });
74 + return out;
75 + }, [countries, seriesById]);
76 + const firstProv = countries.map((c) => seriesById[c.id]?.provenance).find(Boolean) ?? null;
77 +
78 + return (
79 + <div className="min-w-0">
80 + <div className="mb-3 flex flex-wrap items-center gap-2">
81 + {countries.map((c, i) => (
82 + <span key={c.id} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule pl-2.5 pr-1 text-sm text-ink md:h-9">
83 + <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-xs" style={{ background: seriesVar(i) }} />
84 + {c.flag ? <span aria-hidden>{c.flag}</span> : null}
85 + <Link href={routes.country(c.slug)} className="link-quiet inline-flex h-11 items-center md:h-9">
86 + {c.name}
87 + </Link>
88 + <button type="button" onClick={() => remove(c.id)} className="grid h-11 w-11 place-items-center text-ink-3 hover:text-ink md:h-7 md:w-8" aria-label={t('indicator.compare.remove', { name: c.name })}>
89 + <X size={13} aria-hidden />
90 + </button>
91 + </span>
92 + ))}
93 + {countries.length < MAX ? (
94 + <EntityPicker type="country" placeholder={countries.length ? t('indicator.compare.add') : t('indicator.compare.search')} onPick={add} exclude={countries.map((c) => c.id)} className="w-56" size="sm" />
95 + ) : (
96 + <span className="text-xs text-ink-3">{t('indicator.compare.max')}</span>
97 + )}
98 + </div>
99 + <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 300 }}>
100 + {lines.length === 0 && !loading ? (
101 + <EmptyState compact title={t('indicator.compare.none')} />
102 + ) : lines.length ? (
103 + <LineChart series={lines} spec={spec} height={260} provenance={firstProv} payload={payload} defaultWidth={720} endLabels={false} />
104 + ) : (
105 + <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 260 }}>
106 + {t('common.loading')}
107 + </div>
108 + )}
109 + </div>
110 + {countries.length >= 2 ? (
111 + <Link href={routes.compare(...countries.map((c) => c.slug))} className="mt-2 inline-flex min-h-[40px] items-center text-sm text-accent hover:underline">
112 + {t('indicator.compare.open')} →
113 + </Link>
114 + ) : null}
115 + </div>
116 + );
117 +}
added apps/web/src/components/indicators/indicator-map.tsx +134 −0
@@ -0,0 +1,134 @@
1 +'use client';
2 +import { useEffect, useMemo, useRef, useState } from 'react';
3 +import { t } from '@/i18n';
4 +import { clientExplore } from '@/lib/client-api-explore';
5 +import { formatValue, grouped } from '@/lib/format';
6 +import type { FormatSpec, MapResponse } from '@/lib/types';
7 +import { ChoroplethView, type ChoroplethFeature } from '@/components/charts/choropleth-view';
8 +import { EmptyState } from '@/components/data/empty-state';
9 +import { SourceLine } from '@/components/charts/source-line';
10 +import type { ProvenancePayload } from '@/components/data/provenance-context';
11 +
12 +/** Geometry the server computes once (no values): one entry per drawn country. */
13 +export interface BaseFeature {
14 + iso3: string | null;
15 + name: string;
16 + slug: string | null;
17 + flag: string | null;
18 + d: string;
19 +}
20 +
21 +function classIndex(value: number, breaks: number[]): number {
22 + let i = 0;
23 + while (i < breaks.length && value >= breaks[i]!) i++;
24 + return i;
25 +}
26 +
27 +/**
28 + * Indicator world map with a year slider + select over `years` (years with data). The server renders the
29 + * latest common year; changing the year fetches `/indicators/{slug}/map?year=` client-side and re-classes
30 + * the same geometry. Legend from the API quantile breaks, hatched no-data, click/tap → country.
31 + */
32 +export function IndicatorMap({ slug, geometry, sphere, initial, years, spec, payload }: { slug: string; geometry: BaseFeature[]; sphere: string; initial: MapResponse; years: number[]; spec: FormatSpec; payload: ProvenancePayload | null }) {
33 + const [year, setYear] = useState<number>(initial.year_used ?? years[years.length - 1] ?? new Date().getUTCFullYear());
34 + const [cache, setCache] = useState<Record<number, MapResponse>>(() => (initial.year_used != null ? { [initial.year_used]: initial } : {}));
35 + const [loading, setLoading] = useState(false);
36 + const [error, setError] = useState(false);
37 + const abortRef = useRef<AbortController | null>(null);
38 +
39 + useEffect(() => {
40 + if (cache[year]) return;
41 + abortRef.current?.abort();
42 + const ctrl = new AbortController();
43 + abortRef.current = ctrl;
44 + setLoading(true);
45 + setError(false);
46 + const timer = setTimeout(() => {
47 + clientExplore
48 + .indicatorMap(slug, year, ctrl.signal)
49 + .then((m) => setCache((c) => ({ ...c, [year]: m })))
50 + .catch((e) => {
51 + if ((e as Error).name !== 'AbortError') setError(true);
52 + })
53 + .finally(() => {
54 + if (!ctrl.signal.aborted) setLoading(false);
55 + });
56 + }, 120);
57 + return () => clearTimeout(timer);
58 + }, [year, slug, cache]);
59 +
60 + const map = cache[year] ?? null;
61 + const yearIdx = Math.max(0, years.indexOf(year));
62 +
63 + const { features, legend, k } = useMemo(() => {
64 + const breaks = (map?.legend?.breaks ?? []).slice(0, 6);
65 + const kk = breaks.length + 1;
66 + const feats: ChoroplethFeature[] = geometry.map((g) => {
67 + const v = g.iso3 && map ? map.values[g.iso3] : undefined;
68 + return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classIndex(v, breaks) : null };
69 + });
70 + const leg = map
71 + ? Array.from({ length: kk }, (_, i) => {
72 + const lo = i === 0 ? map.legend.min : breaks[i - 1]!;
73 + const hi = i === kk - 1 ? map.legend.max : breaks[i]!;
74 + return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };
75 + })
76 + : [];
77 + return { features: feats, legend: leg, k: kk };
78 + }, [map, geometry, spec]);
79 +
80 + const summary = map ? t('chart.summary.map', { name: spec.name ?? slug, year, n: map.n, min: formatValue(map.legend.min, spec), max: formatValue(map.legend.max, spec) }) : t('chart.noData');
81 + const title = t('chart.map.legend', { name: spec.name ?? slug, year });
82 + const minY = years[0] ?? year;
83 + const maxY = years[years.length - 1] ?? year;
84 +
85 + return (
86 + <div className="min-w-0">
87 + <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
88 + <label className="flex min-w-0 flex-1 basis-64 items-center gap-3">
89 + <span className="shrink-0 text-xs font-medium text-ink-2">{t('indicator.map.year')}</span>
90 + <input
91 + type="range"
92 + min={0}
93 + max={Math.max(0, years.length - 1)}
94 + step={1}
95 + value={yearIdx}
96 + onChange={(e) => setYear(years[Number(e.target.value)] ?? year)}
97 + aria-label={t('indicator.map.year')}
98 + aria-valuetext={String(year)}
99 + className="h-11 min-w-0 flex-1 accent-[var(--accent)] md:h-9"
100 + style={{ touchAction: 'pan-y' }}
101 + />
102 + <span className="tnum w-12 shrink-0 text-right text-sm font-semibold text-ink" aria-hidden>
103 + {year}
104 + </span>
105 + </label>
106 + <select value={year} onChange={(e) => setYear(Number(e.target.value))} aria-label={t('indicator.map.year')} className="h-11 rounded-sm border border-rule bg-surface px-2 text-sm text-ink outline-none focus:border-accent md:h-9">
107 + {[...years].reverse().map((y) => (
108 + <option key={y} value={y}>
109 + {y}
110 + </option>
111 + ))}
112 + </select>
113 + <span className="tnum text-xs text-ink-3">
114 + {minY}–{maxY}
115 + {map ? ` · ${t('indicator.map.n', { n: grouped(map.n) })}` : ''}
116 + </span>
117 + </div>
118 +
119 + <div className={loading ? 'opacity-60 transition-opacity' : 'transition-opacity'} aria-busy={loading}>
120 + {error ? (
121 + <EmptyState compact title={t('common.errorHint')} />
122 + ) : map && map.n === 0 ? (
123 + <EmptyState compact title={t('indicator.map.none', { year })} />
124 + ) : (
125 + <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={title} />
126 + )}
127 + </div>
128 + {map?.nearest && map.years ? <p className="mt-1 text-2xs text-ink-3">{t('indicator.map.nearest', { n: Object.values(map.years).filter((y) => y !== map.year_used).length })}</p> : null}
129 + <div className="mt-1">
130 + <SourceLine provenance={map?.provenance ?? initial.provenance} payload={payload} />
131 + </div>
132 + </div>
133 + );
134 +}
added apps/web/src/components/indicators/indicator-trend.tsx +121 −0
@@ -0,0 +1,121 @@
1 +'use client';
2 +import { useEffect, useMemo, useState } from 'react';
3 +import { t } from '@/i18n';
4 +import { clientExplore } from '@/lib/client-api-explore';
5 +import { cn } from '@/lib/cn';
6 +import type { FormatSpec } from '@/lib/types';
7 +import type { TrendResponse } from '@/lib/types-explore';
8 +import { LineChart, type LineSeries } from '@/components/charts/line-chart';
9 +import type { SeriesPoint } from '@/components/charts/scales';
10 +import { EmptyState } from '@/components/data/empty-state';
11 +import type { ProvenancePayload } from '@/components/data/provenance-context';
12 +
13 +export interface TrendGroupOption {
14 + id: string;
15 + label: string;
16 +}
17 +
18 +/** Fixed group choices: World, organisations, World Bank regions, income groups (ids = group slugs). */
19 +export const TREND_GROUPS: TrendGroupOption[] = [
20 + { id: 'world', label: 'World' },
21 + { id: 'oecd', label: 'OECD' },
22 + { id: 'european-union', label: 'European Union' },
23 + { id: 'g7', label: 'G7' },
24 + { id: 'g20', label: 'G20' },
25 + { id: 'brics', label: 'BRICS' },
26 + { id: 'north-america', label: 'North America' },
27 + { id: 'latin-america-caribbean', label: 'Latin America & Caribbean' },
28 + { id: 'europe-central-asia', label: 'Europe & Central Asia' },
29 + { id: 'middle-east-north-africa', label: 'Middle East & North Africa' },
30 + { id: 'south-asia', label: 'South Asia' },
31 + { id: 'east-asia-pacific', label: 'East Asia & Pacific' },
32 + { id: 'sub-saharan-africa', label: 'Sub-Saharan Africa' },
33 + { id: 'high-income', label: 'High income' },
34 + { id: 'upper-middle-income', label: 'Upper middle income' },
35 + { id: 'lower-middle-income', label: 'Lower middle income' },
36 + { id: 'low-income', label: 'Low income' },
37 +];
38 +
39 +function pts(points: TrendResponse['points'], key: 'median' | 'mean' | 'weighted_mean' | 'sum'): SeriesPoint[] {
40 + return points.filter((p) => p[key] != null).map((p) => ({ period: `${p.year}-01-01`, year: p.year, value: p[key] }));
41 +}
42 +
43 +/**
44 + * Aggregate trend for a group: the API's `preferred` aggregate (sum / population-weighted mean / median) as
45 + * the first series, the median as a second series when it differs. Group selector fetches other groups
46 + * client-side (`/indicators/{slug}/trend?group=`) and caches them.
47 + */
48 +export function IndicatorTrend({ slug, initial, spec, payload }: { slug: string; initial: TrendResponse | null; spec: FormatSpec; payload: ProvenancePayload | null }) {
49 + const [group, setGroup] = useState('world');
50 + const [cache, setCache] = useState<Record<string, TrendResponse | null>>({ world: initial });
51 + const [loading, setLoading] = useState(false);
52 +
53 + useEffect(() => {
54 + if (cache[group] !== undefined) return;
55 + const ctrl = new AbortController();
56 + setLoading(true);
57 + clientExplore
58 + .indicatorTrend(slug, group, ctrl.signal)
59 + .then((r) => setCache((c) => ({ ...c, [group]: r })))
60 + .catch(() => setCache((c) => ({ ...c, [group]: null })))
61 + .finally(() => {
62 + if (!ctrl.signal.aborted) setLoading(false);
63 + });
64 + return () => ctrl.abort();
65 + }, [group, slug, cache]);
66 +
67 + const data = cache[group];
68 + const series: LineSeries[] = useMemo(() => {
69 + if (!data) return [];
70 + const pref = (data.preferred as 'sum' | 'weighted_mean' | 'median') ?? 'median';
71 + const out: LineSeries[] = [];
72 + const prefPts = pts(data.points, pref);
73 + if (prefPts.length) out.push({ id: pref, name: t(`indicator.trend.${pref}` as 'indicator.trend.median'), points: prefPts, colorIndex: 0 });
74 + if (pref !== 'median' && pref !== 'sum') {
75 + const med = pts(data.points, 'median');
76 + if (med.length) out.push({ id: 'median', name: t('indicator.trend.median'), points: med, colorIndex: 1 });
77 + }
78 + return out;
79 + }, [data]);
80 +
81 + const ns = data?.points.map((p) => p.n) ?? [];
82 + const groupLabel = TREND_GROUPS.find((g) => g.id === group)?.label ?? data?.group.name ?? group;
83 +
84 + return (
85 + <div className="min-w-0">
86 + <div className="mb-3 flex flex-wrap items-center gap-2">
87 + <label className="inline-flex h-11 items-center gap-2 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
88 + <span className="text-xs font-medium">{t('indicator.trend.group')}</span>
89 + <select value={group} onChange={(e) => setGroup(e.target.value)} className="bg-transparent pr-1 text-ink outline-none" aria-label={t('indicator.trend.group')}>
90 + {TREND_GROUPS.map((g) => (
91 + <option key={g.id} value={g.id}>
92 + {g.label}
93 + </option>
94 + ))}
95 + </select>
96 + </label>
97 + <ul className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0" aria-hidden>
98 + {TREND_GROUPS.slice(0, 6).map((g) => (
99 + <li key={g.id} className="shrink-0">
100 + <button type="button" tabIndex={-1} onClick={() => setGroup(g.id)} className={cn('inline-flex h-11 items-center rounded-sm px-2.5 text-xs md:h-9', group === g.id ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
101 + {g.label}
102 + </button>
103 + </li>
104 + ))}
105 + </ul>
106 + {ns.length ? <span className="tnum ml-auto text-2xs text-ink-3">{t('indicator.trend.n', { min: Math.min(...ns), max: Math.max(...ns) })}</span> : null}
107 + </div>
108 + <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 300 }}>
109 + {data === null || (data && series.length === 0) ? (
110 + <EmptyState compact title={t('indicator.trend.none', { group: groupLabel })} />
111 + ) : data ? (
112 + <LineChart series={series} spec={spec} subject={`${groupLabel} ${(spec.name ?? '').toLowerCase()}`} height={260} title={`${groupLabel} · ${spec.name ?? ''}`} subtitle={data.weights ? t('indicator.trend.weighted_mean') : t(`indicator.trend.${data.preferred}` as 'indicator.trend.median')} provenance={data.provenance[0] ?? null} payload={payload} defaultWidth={720} endLabels={false} />
113 + ) : (
114 + <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 260 }}>
115 + {t('common.loading')}
116 + </div>
117 + )}
118 + </div>
119 + </div>
120 + );
121 +}
added apps/web/src/components/indicators/map-geometry.ts +17 −0
@@ -0,0 +1,17 @@
1 +import 'server-only';
2 +import { isoLookupFromCountries, worldPaths } from '@/lib/map-geo';
3 +import type { CountrySummary } from '@/lib/types';
4 +import type { BaseFeature } from './indicator-map';
5 +
6 +/** Server-side: world geometry joined with the country list (slug, flag) — values are added client-side. */
7 +export function baseFeatures(countries: CountrySummary[]): { features: BaseFeature[]; sphere: string } {
8 + const { paths, sphere } = worldPaths();
9 + const byId = new Map(countries.map((c) => [c.id, c]));
10 + const numeric = isoLookupFromCountries(countries as Array<{ id: string; iso_numeric?: string | null }>);
11 + const features = paths.map((p) => {
12 + const iso3 = p.iso3 ?? (p.numeric ? numeric.get(p.numeric) ?? null : null);
13 + const c = iso3 ? byId.get(iso3) : undefined;
14 + return { iso3, name: c?.name ?? p.name, slug: c?.slug ?? null, flag: c?.flag ?? null, d: p.d };
15 + });
16 + return { features, sphere };
17 +}
modified apps/web/src/components/layout/site-footer.tsx +1 −1
@@ -58,7 +58,7 @@ function FooterColumn({ title, items }: { title: string; items: Array<{ href: st
58 58 <ul className="space-y-1">
59 59 {items.map((it) => (
60 60 <li key={it.href}>
61 − <Link href={it.href} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent">
61 + <Link href={it.href} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent md:min-h-[32px]">
62 62 {it.label}
63 63 </Link>
64 64 </li>
added apps/web/src/components/methodology/toc.tsx +36 −0
@@ -0,0 +1,36 @@
1 +'use client';
2 +import { useEffect, useState } from 'react';
3 +import { t } from '@/i18n';
4 +import { cn } from '@/lib/cn';
5 +
6 +/** Sticky table of contents (desktop) with the current section highlighted via IntersectionObserver. */
7 +export function Toc({ items }: { items: Array<{ id: string; label: string }> }) {
8 + const [active, setActive] = useState<string | null>(items[0]?.id ?? null);
9 + useEffect(() => {
10 + if (typeof IntersectionObserver === 'undefined') return;
11 + const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);
12 + const io = new IntersectionObserver(
13 + (entries) => {
14 + const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
15 + if (vis[0]) setActive(vis[0].target.id);
16 + },
17 + { rootMargin: '-96px 0px -60% 0px', threshold: 0 },
18 + );
19 + els.forEach((e) => io.observe(e));
20 + return () => io.disconnect();
21 + }, [items]);
22 + return (
23 + <nav aria-label={t('method.toc')} className="lg:sticky lg:top-20">
24 + <div className="eyebrow mb-1.5 hidden lg:block">{t('method.toc')}</div>
25 + <ol className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 lg:mx-0 lg:flex-col lg:gap-0 lg:overflow-visible lg:px-0">
26 + {items.map((i) => (
27 + <li key={i.id} className="shrink-0">
28 + <a href={`#${i.id}`} aria-current={active === i.id ? 'location' : undefined} className={cn('inline-flex h-11 items-center whitespace-nowrap rounded-sm px-3 text-sm lg:h-8 lg:px-2 lg:text-xs', active === i.id ? 'bg-ink text-paper lg:bg-transparent lg:font-semibold lg:text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink lg:hover:bg-transparent')}>
29 + {i.label}
30 + </a>
31 + </li>
32 + ))}
33 + </ol>
34 + </nav>
35 + );
36 +}
added apps/web/src/components/rankings/rank-history-chart.tsx +151 −0
@@ -0,0 +1,151 @@
1 +'use client';
2 +import { line as d3Line } from 'd3-shape';
3 +import { scaleLinear } from 'd3-scale';
4 +import { useCallback, useMemo, useState, type PointerEvent } from 'react';
5 +import { t } from '@/i18n';
6 +import { ordinal } from '@/lib/format';
7 +import { ChartFrame, type TableData } from '@/components/charts/chart-frame';
8 +import { CHART, MARK, seriesVar } from '@/components/charts/palette';
9 +import { yearTicks } from '@/components/charts/scales';
10 +import { ChartTooltip, TooltipRow } from '@/components/charts/tooltip';
11 +import { useMeasure } from '@/components/charts/use-measure';
12 +
13 +export interface RankSeries {
14 + id: string;
15 + name: string;
16 + flag?: string | null;
17 + colorIndex: number;
18 + points: Array<{ year: number; rank: number; n: number | null }>;
19 +}
20 +
21 +/**
22 + * Rank-by-year line chart with an inverted y axis (rank 1 at the top). One colour per country by position,
23 + * end dots + direct labels (≤ 5 series), crosshair tooltip listing every country's rank at the hovered year,
24 + * accessible summary and data table via ChartFrame.
25 + */
26 +export function RankHistoryChart({ series, height = 260, defaultWidth = 640 }: { series: RankSeries[]; height?: number; defaultWidth?: number }) {
27 + const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);
28 + const [hover, setHover] = useState<number | null>(null);
29 + const margin = { top: 22, right: series.length <= 5 ? 96 : 16, bottom: 24, left: 40 };
30 +
31 + const model = useMemo(() => {
32 + const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => Number.isFinite(p.rank) && p.rank >= 1) }));
33 + const years = clean.flatMap((s) => s.points.map((p) => p.year));
34 + const xDom: [number, number] = years.length ? [Math.min(...years), Math.max(...years)] : [2000, 2024];
35 + if (xDom[0] === xDom[1]) xDom[1] = xDom[0] + 1;
36 + const maxRank = Math.max(5, ...clean.flatMap((s) => s.points.map((p) => p.rank)));
37 + const innerW = Math.max(10, width - margin.left - margin.right);
38 + const innerH = Math.max(10, height - margin.top - margin.bottom);
39 + const x = scaleLinear().domain(xDom).range([0, innerW]);
40 + const y = scaleLinear().domain([1, maxRank * 1.05]).range([0, innerH]); // inverted: rank 1 at the top
41 + const yTicks = Array.from(new Set([1, ...y.ticks(4).filter((tk) => tk >= 1 && Number.isInteger(tk))]));
42 + const xTicks = yearTicks(xDom, Math.max(3, Math.floor(innerW / 60)));
43 + const gen = d3Line<{ year: number; rank: number }>()
44 + .x((p) => x(p.year))
45 + .y((p) => y(p.rank));
46 + const layers = clean.map((s) => {
47 + const last = s.points[s.points.length - 1] ?? null;
48 + return { s, color: seriesVar(s.colorIndex), d: gen(s.points) ?? '', last: last ? { x: x(last.year), y: y(last.rank), p: last } : null };
49 + });
50 + // Nudge overlapping end labels apart (12 px steps).
51 + const labels = layers
52 + .filter((l) => l.last)
53 + .sort((a, b) => a.last!.y - b.last!.y)
54 + .map((l) => ({ id: l.s.id, y: l.last!.y }));
55 + for (let i = 1; i < labels.length; i++) if (labels[i]!.y - labels[i - 1]!.y < 12) labels[i]!.y = labels[i - 1]!.y + 12;
56 + const labelY = new Map(labels.map((l) => [l.id, l.y]));
57 + return { clean, x, y, innerW, innerH, xTicks, yTicks, layers, labelY, xDom };
58 + }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom]);
59 +
60 + const onMove = useCallback(
61 + (e: PointerEvent<SVGRectElement>) => {
62 + const rect = e.currentTarget.getBoundingClientRect();
63 + const xv = model.x.invert(e.clientX - rect.left);
64 + setHover(Math.round(xv));
65 + },
66 + [model],
67 + );
68 +
69 + const hoverRows = hover == null ? null : model.layers.map((l) => ({ l, p: l.s.points.find((p) => p.year === hover) ?? null })).filter((r) => r.p);
70 + const names = series.map((s) => s.name).join(', ');
71 + const summary = t('ranking.history.summary', { names, y0: model.xDom[0], y1: model.xDom[1] });
72 + const table: TableData = useMemo(() => {
73 + const years = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.year)))).sort((a, b) => a - b);
74 + return {
75 + columns: [{ key: 'year', label: t('common.year') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))],
76 + rows: years.map((yr) => {
77 + const row: Record<string, string> = { year: String(yr) };
78 + for (const s of model.clean) {
79 + const p = s.points.find((q) => q.year === yr);
80 + row[s.id] = p ? `${p.rank}${p.n ? ` / ${p.n}` : ''}` : t('common.na');
81 + }
82 + return row;
83 + }),
84 + };
85 + }, [model]);
86 + const empty = model.clean.every((s) => s.points.length === 0);
87 +
88 + return (
89 + <ChartFrame summary={summary} table={empty ? undefined : table} minHeight={height}>
90 + <div ref={ref} className="relative w-full" style={{ height }}>
91 + {empty ? (
92 + <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>
93 + ) : (
94 + <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>
95 + <title>{t('ranking.history.title')}</title>
96 + <desc>{summary}</desc>
97 + <g transform={`translate(${margin.left},${margin.top})`}>
98 + <g className="grid">
99 + {model.yTicks.map((tk) => (
100 + <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />
101 + ))}
102 + </g>
103 + <g className="axis">
104 + <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />
105 + {model.xTicks.map((yr) => (
106 + <text key={yr} x={model.x(yr)} y={model.innerH + 16} textAnchor="middle">
107 + {yr}
108 + </text>
109 + ))}
110 + {model.yTicks.map((tk) => (
111 + <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">
112 + {tk}
113 + </text>
114 + ))}
115 + <text x={-6} y={-12} textAnchor="end" className="label">
116 + {t('ranking.history.axis')}
117 + </text>
118 + </g>
119 + {model.layers.map((l) => (
120 + <g key={l.s.id}>
121 + <path className="series-line" d={l.d} stroke={l.color} />
122 + {l.last ? (
123 + <>
124 + <circle className="ring" cx={l.last.x} cy={l.last.y} r={MARK.dotR + MARK.ringW / 2} fill={l.color} />
125 + {series.length <= 5 ? (
126 + <text className="label" x={l.last.x + 8} y={model.labelY.get(l.s.id) ?? l.last.y} dy="0.32em">
127 + {l.s.name} · {l.last.p.rank}
128 + </text>
129 + ) : null}
130 + </>
131 + ) : null}
132 + </g>
133 + ))}
134 + {hover != null ? <line x1={model.x(hover)} x2={model.x(hover)} y1={0} y2={model.innerH} stroke={CHART.ruleStrong} strokeWidth={1} /> : null}
135 + {hoverRows?.map((r) => <circle key={r.l.s.id} className="ring" cx={model.x(r.p!.year)} cy={model.y(r.p!.rank)} r={MARK.dotR} fill={r.l.color} />)}
136 + <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} />
137 + </g>
138 + </svg>
139 + )}
140 + {hover != null && hoverRows && hoverRows.length ? (
141 + <ChartTooltip x={margin.left + model.x(hover)} y={margin.top} width={width}>
142 + <div className="mb-0.5 text-2xs text-ink-3">{hover}</div>
143 + {hoverRows.map((r) => (
144 + <TooltipRow key={r.l.s.id} color={r.l.color} label={r.l.s.name} value={`${ordinal(r.p!.rank)}${r.p!.n ? ` ${t('ranking.history.of', { n: r.p!.n })}` : ''}`} />
145 + ))}
146 + </ChartTooltip>
147 + ) : null}
148 + </div>
149 + </ChartFrame>
150 + );
151 +}
added apps/web/src/components/rankings/rank-history-panel.tsx +82 −0
@@ -0,0 +1,82 @@
1 +'use client';
2 +import { X } from 'lucide-react';
3 +import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4 +import { useEffect, useMemo, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientCompare } from '@/lib/client-api-compare';
7 +import type { CountryLite, RankHistoryResponse } from '@/lib/types-compare';
8 +import { seriesVar } from '@/components/charts/palette';
9 +import { CountryTypeahead } from '@/components/compare/country-picker';
10 +import { RankHistoryChart, type RankSeries } from './rank-history-chart';
11 +
12 +export const MAX_HISTORY = 5;
13 +
14 +/**
15 + * "Rank over time" panel: up to 5 countries (default top 3 + highlighted), rank-by-year chart. The selection is
16 + * written to `?history=ISO3,ISO3` (router.replace); new selections fetch `/rankings/{slug}/history` client-side.
17 + */
18 +export function RankHistoryPanel({ slug, countries, initialIds, initial }: { slug: string; countries: CountryLite[]; initialIds: string[]; initial: RankHistoryResponse | null }) {
19 + const router = useRouter();
20 + const pathname = usePathname();
21 + const params = useSearchParams();
22 + const [ids, setIds] = useState<string[]>(initialIds);
23 + const [data, setData] = useState<RankHistoryResponse | null>(initial);
24 + const [loading, setLoading] = useState(false);
25 + const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]);
26 + const exclude = useMemo(() => new Set(ids.map((id) => byId.get(id)?.slug ?? id.toLowerCase())), [ids, byId]);
27 +
28 + useEffect(() => {
29 + const key = ids.join(',');
30 + const have = data ? data.countries.map((c) => c.id).join(',') : '';
31 + if (!ids.length || key === have) return;
32 + const ctrl = new AbortController();
33 + setLoading(true);
34 + clientCompare
35 + .rankHistory(slug, ids, ctrl.signal)
36 + .then((r) => setData(r))
37 + .catch((e) => {
38 + if ((e as Error).name !== 'AbortError') setData(null);
39 + })
40 + .finally(() => setLoading(false));
41 + return () => ctrl.abort();
42 + }, [ids, slug, data]);
43 +
44 + const apply = (next: string[]) => {
45 + setIds(next);
46 + const p = new URLSearchParams(params.toString());
47 + if (next.length) p.set('history', next.join(','));
48 + else p.delete('history');
49 + const s = p.toString();
50 + router.replace(`${pathname}${s ? `?${s}` : ''}`, { scroll: false });
51 + };
52 +
53 + const series: RankSeries[] = ids.map((id, i) => {
54 + const c = byId.get(id) ?? data?.countries.find((x) => x.id === id);
55 + const pts = (data?.series[id] ?? []).filter((p) => p.rank != null).map((p) => ({ year: p.year, rank: p.rank!, n: p.n }));
56 + return { id, name: c?.name ?? id, flag: c?.flag ?? null, colorIndex: i, points: pts };
57 + });
58 +
59 + return (
60 + <div>
61 + <div className="flex flex-wrap items-center gap-1.5">
62 + {ids.map((id, i) => {
63 + const c = byId.get(id);
64 + return (
65 + <span key={id} className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">
66 + <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />
67 + <span aria-hidden>{c?.flag}</span>
68 + <span className="max-w-[9rem] truncate text-ink">{c?.name ?? id}</span>
69 + <button type="button" onClick={() => apply(ids.filter((x) => x !== id))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('ranking.history.remove', { name: c?.name ?? id })}>
70 + <X size={14} aria-hidden />
71 + </button>
72 + </span>
73 + );
74 + })}
75 + {ids.length < MAX_HISTORY ? <CountryTypeahead countries={countries} exclude={exclude} onPick={(c) => apply([...ids, c.id])} placeholder={t('ranking.history.pick')} className="w-full sm:w-64" /> : <span className="text-xs text-ink-3">{t('ranking.history.full', { max: MAX_HISTORY })}</span>}
76 + </div>
77 + <div className={loading ? 'mt-3 opacity-60 transition-opacity' : 'mt-3'} aria-busy={loading}>
78 + {ids.length === 0 ? <p className="py-6 text-sm text-ink-3">{t('ranking.history.empty')}</p> : <RankHistoryChart series={series} />}
79 + </div>
80 + </div>
81 + );
82 +}
added apps/web/src/components/rankings/ranking-view.tsx +295 −0
@@ -0,0 +1,295 @@
1 +'use client';
2 +import { ArrowDownWideNarrow, ArrowUpNarrowWide, Search, SlidersHorizontal, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname, useRouter } from 'next/navigation';
5 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { cn } from '@/lib/cn';
8 +import { formatValue, grouped, isNum, ordinal } from '@/lib/format';
9 +import { rankingQuery, type RankingState } from '@/lib/ranking-state';
10 +import { routes } from '@/lib/site';
11 +import type { RankingResponse, RankingRow } from '@/lib/types';
12 +import type { CountryLite, RegionItem } from '@/lib/types-compare';
13 +import { MARK } from '@/components/charts/palette';
14 +import { pointsFromSpark } from '@/components/charts/scales';
15 +import { Sparkline } from '@/components/charts/sparkline';
16 +import { CountryTypeahead } from '@/components/compare/country-picker';
17 +import { BottomSheet } from '@/components/data/bottom-sheet';
18 +import { ChangeChip } from '@/components/data/change-chip';
19 +import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context';
20 +
21 +const PAGE = 25;
22 +const GROUP_KINDS = ['region', 'continent', 'income', 'org'] as const;
23 +
24 +/**
25 + * Interactive ranking: controls (year · group · sort · highlight · search; a compact sticky bar + bottom
26 + * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per
27 + * page with "Show more". Year/group/sort/highlight/q live in the URL (server re-renders the data).
28 + */
29 +export function RankingView({ data, regions, countries, state }: { data: RankingResponse & { label?: string }; regions: RegionItem[]; countries: CountryLite[]; state: RankingState }) {
30 + const router = useRouter();
31 + const pathname = usePathname();
32 + const { open } = useProvenance();
33 + const [q, setQ] = useState(state.q);
34 + const [shown, setShown] = useState(PAGE);
35 + const [sheet, setSheet] = useState(false);
36 + const [jump, setJump] = useState(false);
37 + const listRef = useRef<HTMLOListElement>(null);
38 + const ind = data.indicator;
39 + const hib = ind.higher_is_better;
40 + const sort = (data.sort as 'asc' | 'desc') ?? 'desc';
41 +
42 + const setState = useCallback(
43 + (patch: Partial<RankingState>) => router.replace(`${pathname}${rankingQuery({ ...state, ...patch })}`, { scroll: false }),
44 + [router, pathname, state],
45 + );
46 +
47 + // Search is client-side; keep the URL in sync (debounced) so a reload restores it.
48 + useEffect(() => {
49 + if (q === state.q) return;
50 + const id = setTimeout(() => setState({ q }), 400);
51 + return () => clearTimeout(id);
52 + }, [q, state.q, setState]);
53 +
54 + const rows = data.rows;
55 + const max = useMemo(() => Math.max(0, ...rows.map((r) => (isNum(r.value) ? Math.abs(r.value) : 0))), [rows]);
56 + const ql = q.trim().toLowerCase();
57 + const filtered = useMemo(() => (ql ? rows.filter((r) => (r.country.name ?? '').toLowerCase().includes(ql) || r.country.id.toLowerCase() === ql || (r.country.region_name ?? '').toLowerCase().includes(ql)) : rows), [rows, ql]);
58 + const highlightRow = state.highlight ? rows.find((r) => (r.country.slug ?? r.country.id.toLowerCase()) === state.highlight) ?? null : null;
59 + const highlightCountry = state.highlight ? countries.find((c) => c.slug === state.highlight) ?? null : null;
60 + const visible = filtered.slice(0, shown);
61 +
62 + // Scroll to the highlighted row after a user-initiated highlight.
63 + useEffect(() => {
64 + if (!jump || !highlightRow) return;
65 + const idx = filtered.findIndex((r) => r.country.id === highlightRow.country.id);
66 + if (idx >= shown) setShown(Math.ceil((idx + 1) / PAGE) * PAGE);
67 + const el = listRef.current?.querySelector<HTMLElement>(`[data-country="${highlightRow.country.id}"]`);
68 + if (el) {
69 + el.scrollIntoView({ block: 'center', behavior: 'smooth' });
70 + setJump(false);
71 + }
72 + }, [jump, highlightRow, filtered, shown]);
73 +
74 + const payloadOf = (r: RankingRow): ProvenancePayload => ({
75 + indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, higher_is_better: hib },
76 + value: { value: r.value, formatted: r.formatted, period: r.year ? `${r.year}-01-01` : null, year: r.year, unit: ind.unit, provenance: r.provenance },
77 + country: { id: r.country.id, slug: r.country.slug, name: r.country.name ?? r.country.id, flag: r.country.flag },
78 + downloadHref: routes.indicatorDownload(ind.slug),
79 + });
80 +
81 + const groupsByKind = GROUP_KINDS.map((k) => ({ kind: k, items: regions.filter((g) => g.kind === k).sort((a, b) => a.name.localeCompare(b.name)) })).filter((g) => g.items.length);
82 + const activeFilters = (state.group !== 'world' ? 1 : 0) + (state.sort ? 1 : 0) + (state.highlight ? 1 : 0);
83 + const years = [...data.years_available].sort((a, b) => b - a);
84 +
85 + const yearSelect = (
86 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
87 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.year')}</span>
88 + <select value={data.year_used ?? ''} onChange={(e) => setState({ year: Number(e.target.value) })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.year')}>
89 + {years.map((y) => (
90 + <option key={y} value={y}>
91 + {y}
92 + </option>
93 + ))}
94 + </select>
95 + </label>
96 + );
97 + const groupSelect = (
98 + <label className="inline-flex h-11 max-w-full items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
99 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.group')}</span>
100 + <select value={state.group} onChange={(e) => setState({ group: e.target.value })} className="max-w-[12rem] truncate bg-transparent text-ink outline-none" aria-label={t('ranking.group')}>
101 + <option value="world">{t('ranking.group.world')}</option>
102 + {groupsByKind.map((g) => (
103 + <optgroup key={g.kind} label={t(`ranking.group.${g.kind}` as 'ranking.group.region')}>
104 + {g.items.map((it) => (
105 + <option key={it.slug} value={it.slug}>
106 + {it.name}
107 + </option>
108 + ))}
109 + </optgroup>
110 + ))}
111 + </select>
112 + </label>
113 + );
114 + const sortToggle = (
115 + <button type="button" onClick={() => setState({ sort: sort === 'desc' ? 'asc' : 'desc' })} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 hover:text-ink md:h-9" aria-label={t('ranking.sort')}>
116 + {sort === 'desc' ? <ArrowDownWideNarrow size={15} aria-hidden /> : <ArrowUpNarrowWide size={15} aria-hidden />}
117 + {sort === 'desc' ? t('ranking.sort.desc') : t('ranking.sort.asc')}
118 + </button>
119 + );
120 + const highlightControl = highlightCountry ? (
121 + <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-accent bg-accent-soft pl-2.5 text-sm text-accent md:h-9">
122 + <span aria-hidden>{highlightCountry.flag}</span>
123 + <span className="max-w-[9rem] truncate">{highlightCountry.name}</span>
124 + <button type="button" onClick={() => setState({ highlight: null })} className="grid h-11 w-9 place-items-center md:h-9 md:w-7" aria-label={t('ranking.highlight.clear')}>
125 + <X size={14} aria-hidden />
126 + </button>
127 + </span>
128 + ) : (
129 + <CountryTypeahead
130 + countries={countries}
131 + exclude={new Set()}
132 + onPick={(c) => {
133 + setState({ highlight: c.slug });
134 + setJump(true);
135 + }}
136 + placeholder={t('ranking.highlight.placeholder')}
137 + className="w-full sm:w-56"
138 + />
139 + );
140 + const searchBox = (
141 + <div className="relative min-w-0 flex-1">
142 + <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />
143 + <input type="search" value={q} onChange={(e) => { setQ(e.target.value); setShown(PAGE); }} placeholder={t('ranking.search')} aria-label={t('ranking.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-9" />
144 + {q ? (
145 + <button type="button" onClick={() => setQ('')} className="absolute right-0 top-0 grid h-11 w-10 place-items-center text-ink-3 hover:text-ink md:h-9" aria-label={t('search.clear')}>
146 + <X size={14} aria-hidden />
147 + </button>
148 + ) : null}
149 + </div>
150 + );
151 +
152 + const rankNote = hib == null ? (sort === 'desc' ? t('ranking.rankNote.higher') : t('ranking.rankNote.lowest')) : t('ranking.rankNote.better', { direction: hib ? t('ranking.direction.higher') : t('ranking.direction.lower') });
153 +
154 + return (
155 + <div>
156 + {/* Sticky compact bar (phones) */}
157 + <div className="sticky top-[52px] z-20 -mx-4 flex items-center gap-2 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 md:hidden">
158 + {yearSelect}
159 + {searchBox}
160 + <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-11 shrink-0 items-center gap-1 rounded-sm border px-2.5 text-sm', activeFilters ? 'border-accent text-accent' : 'border-rule text-ink-2')} aria-label={t('ranking.filters')}>
161 + <SlidersHorizontal size={15} aria-hidden />
162 + {activeFilters ? <span className="tnum">{activeFilters}</span> : null}
163 + </button>
164 + </div>
165 + {/* Desktop controls */}
166 + <div className="hidden flex-wrap items-center gap-2 py-3 md:flex">
167 + {yearSelect}
168 + {groupSelect}
169 + {sortToggle}
170 + {highlightControl}
171 + <div className="ml-auto w-64">{searchBox}</div>
172 + </div>
173 +
174 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2 text-xs text-ink-3">
175 + <span>{rankNote}</span>
176 + <span className="tnum">
177 + {t('ranking.showing', { shown: grouped(Math.min(shown, filtered.length)), n: grouped(filtered.length) })}
178 + {data.year != null && data.year_used != null && data.year !== data.year_used ? ` · ${t('ranking.yearUsed', { year: data.year_used, requested: data.year })}` : ''}
179 + </span>
180 + </div>
181 +
182 + {/* Pinned highlighted row */}
183 + {highlightRow ? (
184 + <div className="mb-2 border-y border-accent/40 bg-accent-soft/40">
185 + <Row r={highlightRow} max={max} spec={ind} highlight onValue={() => open(payloadOf(highlightRow))} />
186 + <button type="button" onClick={() => setJump(true)} className="inline-flex min-h-[36px] items-center px-1 text-xs text-accent hover:underline">
187 + {t('ranking.jumpToHighlight', { name: highlightRow.country.name ?? highlightRow.country.id })} ↓
188 + </button>
189 + </div>
190 + ) : state.highlight && highlightCountry ? (
191 + <p className="mb-2 border-y border-dashed border-rule-strong py-2 text-xs text-ink-3">{t('ranking.highlight.notInGroup', { name: highlightCountry.name, year: data.year_used ?? '' })}</p>
192 + ) : null}
193 +
194 + {/* Header (sm+) */}
195 + <div className="hidden grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem] gap-x-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid">
196 + <span className="text-right">{t('ranking.col.rank')}</span>
197 + <span>{t('ranking.col.country')}</span>
198 + <span />
199 + <span className="text-right">{t('ranking.col.value')}</span>
200 + <span className="text-right">{t('ranking.col.change1y')}</span>
201 + <span className="text-right">{t('ranking.col.change10y')}</span>
202 + <span className="text-right">{t('ranking.col.trend')}</span>
203 + </div>
204 +
205 + {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{ql ? t('ranking.noMatch', { q }) : t('ranking.empty')}</p> : null}
206 + <ol ref={listRef} className="divide-y divide-rule" role="list">
207 + {visible.map((r) => (
208 + <Row key={r.country.id} r={r} max={max} spec={ind} highlight={highlightRow?.country.id === r.country.id} onValue={() => open(payloadOf(r))} />
209 + ))}
210 + </ol>
211 + {filtered.length > shown ? (
212 + <div className="flex justify-center py-4">
213 + <button type="button" onClick={() => setShown((s) => s + PAGE)} className="inline-flex h-11 items-center rounded-sm border border-rule px-5 text-sm text-ink hover:bg-surface-2 md:h-10">
214 + {t('ranking.showMore', { n: Math.min(PAGE, filtered.length - shown) })}
215 + </button>
216 + </div>
217 + ) : null}
218 +
219 + <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('ranking.filters')}>
220 + <div className="space-y-4">
221 + <div>
222 + <div className="eyebrow mb-1.5">{t('ranking.group')}</div>
223 + {groupSelect}
224 + </div>
225 + <div>
226 + <div className="eyebrow mb-1.5">{t('ranking.sort')}</div>
227 + {sortToggle}
228 + </div>
229 + <div>
230 + <div className="eyebrow mb-1.5">{t('ranking.highlight')}</div>
231 + {highlightControl}
232 + </div>
233 + </div>
234 + <div className="mt-6 flex justify-between">
235 + <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => setState({ group: 'world', sort: null, highlight: null })}>
236 + {t('common.reset')}
237 + </button>
238 + <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>
239 + {t('common.apply')}
240 + </button>
241 + </div>
242 + </BottomSheet>
243 + </div>
244 + );
245 +}
246 +
247 +function Row({ r, max, spec, highlight, onValue }: { r: RankingRow; max: number; spec: RankingResponse['indicator']; highlight?: boolean; onValue: () => void }) {
248 + const pct = isNum(r.value) && max > 0 ? Math.max(0, (Math.abs(r.value) / max) * 100) : 0;
249 + const points = pointsFromSpark(r.sparkline);
250 + const dir = r.change_1y?.abs == null ? null : r.change_1y.abs > 0 ? 'up' : r.change_1y.abs < 0 ? 'down' : 'flat';
251 + const value = r.formatted ?? formatValue(r.value, spec);
252 + const worldRank = isNum(r.rank_world) && isNum(r.n_world) ? t('ranking.worldRank', { rank: ordinal(r.rank_world), n: grouped(r.n_world) }) : null;
253 + return (
254 + <li data-country={r.country.id} className={cn('py-2 sm:py-1.5', highlight && 'bg-accent-soft/40')}>
255 + <div className="grid grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-x-3 sm:grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem]">
256 + <span className={cn('tnum text-right text-sm', highlight ? 'font-semibold text-accent' : 'text-ink-3')}>{r.rank}</span>
257 + <Link href={routes.country(r.country.slug ?? r.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-2 sm:min-h-[36px]" title={worldRank ?? undefined}>
258 + <span aria-hidden className="text-lg leading-none">
259 + {r.country.flag}
260 + </span>
261 + <span className="min-w-0">
262 + <span className={cn('block truncate text-sm', highlight ? 'font-semibold text-ink' : 'text-ink')}>{r.country.name}</span>
263 + <span className="block truncate text-2xs text-ink-3 sm:hidden">{worldRank ?? r.country.region_name ?? ''}</span>
264 + </span>
265 + </Link>
266 + <div className="hidden min-w-0 items-center sm:flex" aria-hidden>
267 + <div className="h-3.5 min-w-0 flex-1" style={{ maxHeight: MARK.barMax }}>
268 + <div className={cn('h-full', highlight ? 'bg-accent' : 'bg-series-1')} style={{ width: `${pct}%`, minWidth: isNum(r.value) ? 2 : 0, borderRadius: `0 ${MARK.barRadius}px ${MARK.barRadius}px 0`, opacity: highlight ? 1 : 0.85 }} />
269 + </div>
270 + </div>
271 + <button type="button" onClick={onValue} className="tnum -mx-1 inline-flex min-h-[44px] items-center justify-end rounded-sm px-1 text-right text-sm font-medium text-ink hover:bg-surface-2 sm:min-h-[36px]" aria-label={`${r.country.name}: ${value} (${r.year ?? ''}). ${t('common.openProvenance')}`}>
272 + {value}
273 + </button>
274 + <span className="hidden justify-end sm:flex">
275 + <ChangeChip change={r.change_1y} spec={spec} showVs={false} />
276 + </span>
277 + <span className="hidden justify-end sm:flex">
278 + <ChangeChip change={r.change_10y} spec={spec} showVs={false} />
279 + </span>
280 + <span className="hidden justify-end sm:flex">{points.length >= 2 ? <Sparkline points={points} width={64} height={20} direction={dir} className="opacity-90" /> : <span className="inline-block h-5 w-16" />}</span>
281 + </div>
282 + {/* Phone: bar + changes + sparkline on a second line */}
283 + <div className="mt-1 grid grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-x-3 sm:hidden">
284 + <span />
285 + <div className="h-2 min-w-0 overflow-hidden rounded-xs bg-surface-2" aria-hidden>
286 + <div className={cn('h-full rounded-r-xs', highlight ? 'bg-accent' : 'bg-series-1')} style={{ width: `${pct}%`, opacity: highlight ? 1 : 0.85 }} />
287 + </div>
288 + <span className="flex items-center gap-2 text-2xs">
289 + <ChangeChip change={r.change_1y} spec={spec} showVs={false} />
290 + {points.length >= 2 ? <Sparkline points={points} width={44} height={16} direction={dir} className="opacity-90" /> : null}
291 + </span>
292 + </div>
293 + </li>
294 + );
295 +}
added apps/web/src/components/rankings/rankings-directory.tsx +108 −0
@@ -0,0 +1,108 @@
1 +'use client';
2 +import { ChevronRight, Search, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useMemo, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { grouped } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import { TOPICS } from '@/lib/topics';
9 +import type { RankingRow } from '@/lib/types';
10 +import type { RankableIndicator } from '@/lib/types-compare';
11 +import { Top3Preview } from './top3-preview';
12 +
13 +/**
14 + * /rankings directory: featured first, then every rankable indicator grouped by topic. Each entry shows
15 + * "n countries · year" and a top-3 preview. The search box filters client-side on name / unit / topic.
16 + */
17 +export function RankingsDirectory({ items, previews }: { items: RankableIndicator[]; previews: Record<string, RankingRow[] | null> }) {
18 + const [q, setQ] = useState('');
19 + const ql = q.trim().toLowerCase();
20 + const filtered = useMemo(() => (ql ? items.filter((i) => (i.name ?? '').toLowerCase().includes(ql) || (i.short_name ?? '').toLowerCase().includes(ql) || (i.unit ?? '').toLowerCase().includes(ql) || (i.topic ?? '').includes(ql) || (i.subtopic ?? '').toLowerCase().includes(ql)) : items), [items, ql]);
21 + const featured = filtered.filter((i) => i.featured);
22 + const byTopic = TOPICS.map((tp) => ({ tp, items: filtered.filter((i) => i.topic === tp.id).sort((a, b) => Number(!!b.featured) - Number(!!a.featured) || (a.name ?? '').localeCompare(b.name ?? '')) })).filter((g) => g.items.length);
23 + const other = filtered.filter((i) => !TOPICS.some((tp) => tp.id === i.topic));
24 +
25 + return (
26 + <div>
27 + <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 sm:px-6 md:static md:mx-0 md:border-0 md:bg-transparent md:px-0 md:py-3">
28 + <div className="flex items-center gap-3">
29 + <div className="relative min-w-0 flex-1 md:max-w-md">
30 + <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />
31 + <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('rankings.search')} aria-label={t('rankings.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-10" />
32 + {q ? (
33 + <button type="button" onClick={() => setQ('')} className="absolute right-1 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>
34 + <X size={14} aria-hidden />
35 + </button>
36 + ) : null}
37 + </div>
38 + <span className="tnum shrink-0 text-xs text-ink-3">{ql ? t('rankings.countOf', { n: grouped(filtered.length), total: grouped(items.length) }) : t('rankings.count', { n: grouped(items.length) })}</span>
39 + </div>
40 + </div>
41 +
42 + {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('rankings.noMatch', { q })}</p> : null}
43 +
44 + {featured.length && !ql ? (
45 + <section className="pt-4 pb-6 md:pt-6 md:pb-8" aria-labelledby="featured-h">
46 + <h2 id="featured-h" className="display text-xl text-ink md:text-2xl">
47 + {t('rankings.featured')}
48 + </h2>
49 + <p className="mt-1 text-sm text-ink-2">{t('rankings.featured.sub')}</p>
50 + <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
51 + {featured.map((i) => (
52 + <Entry key={i.slug} item={i} preview={previews[i.slug]} />
53 + ))}
54 + </ul>
55 + </section>
56 + ) : null}
57 +
58 + {byTopic.map(({ tp, items: rows }) => (
59 + <section key={tp.id} id={`topic-${tp.id}`} className="hairline scroll-mt-28 pt-5 pb-6 md:pt-6 md:pb-8" aria-labelledby={`topic-${tp.id}-h`}>
60 + <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
61 + <h2 id={`topic-${tp.id}-h`} className="display text-xl text-ink md:text-2xl">
62 + {tp.name}
63 + </h2>
64 + <span className="tnum text-xs text-ink-3">{t('rankings.count', { n: rows.length })}</span>
65 + </div>
66 + <ul className="mt-2 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
67 + {rows.map((i) => (
68 + <Entry key={i.slug} item={i} preview={previews[i.slug]} />
69 + ))}
70 + </ul>
71 + </section>
72 + ))}
73 +
74 + {other.length ? (
75 + <section className="hairline pt-5 pb-6">
76 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
77 + {other.map((i) => (
78 + <Entry key={i.slug} item={i} preview={previews[i.slug]} />
79 + ))}
80 + </ul>
81 + </section>
82 + ) : null}
83 + </div>
84 + );
85 +}
86 +
87 +function Entry({ item, preview }: { item: RankableIndicator; preview: RankingRow[] | null | undefined }) {
88 + const name = item.name ?? item.slug;
89 + return (
90 + <li className="border-t border-rule">
91 + <Link href={routes.ranking(item.slug)} className="group block min-h-[72px] py-2.5" aria-label={t('rankings.open', { name })}>
92 + <span className="flex items-start justify-between gap-2">
93 + <span className="min-w-0">
94 + <span className="block text-sm font-medium leading-snug text-ink group-hover:text-accent">{name}</span>
95 + <span className="tnum block text-2xs text-ink-3">
96 + {t('rankings.meta', { n: grouped(item.ranking_n ?? item.n_countries ?? 0), year: item.ranking_year ?? item.last_year ?? '' })}
97 + {item.unit ? ` · ${item.unit}` : ''}
98 + </span>
99 + </span>
100 + <ChevronRight size={14} aria-hidden className="mt-0.5 shrink-0 text-ink-3 group-hover:text-accent" />
101 + </span>
102 + <span className="mt-1 block">
103 + <Top3Preview slug={item.slug} spec={item} initial={preview} />
104 + </span>
105 + </Link>
106 + </li>
107 + );
108 +}
added apps/web/src/components/rankings/top3-preview.tsx +88 −0
@@ -0,0 +1,88 @@
1 +'use client';
2 +import { useEffect, useRef, useState } from 'react';
3 +import { t } from '@/i18n';
4 +import { clientCompare } from '@/lib/client-api-compare';
5 +import { formatValue } from '@/lib/format';
6 +import type { IndicatorCard, RankingRow } from '@/lib/types';
7 +
8 +/** Small client-side fetch queue: at most 3 previews in flight, and no more requests after a 429. */
9 +const queue: Array<() => void> = [];
10 +let inFlight = 0;
11 +let blocked = false;
12 +function schedule(job: () => Promise<void>) {
13 + const run = () => {
14 + inFlight++;
15 + job().finally(() => {
16 + inFlight--;
17 + queue.shift()?.();
18 + });
19 + };
20 + if (inFlight < 3) run();
21 + else queue.push(run);
22 +}
23 +
24 +/**
25 + * "Top 3" mini preview (flag · value) for a ranking. `initial` rows come from the server for featured
26 + * indicators; otherwise the preview loads when it scrolls into view (IntersectionObserver, throttled queue).
27 + */
28 +export function Top3Preview({ slug, spec, initial }: { slug: string; spec: IndicatorCard; initial?: RankingRow[] | null }) {
29 + const ref = useRef<HTMLDivElement>(null);
30 + const [rows, setRows] = useState<RankingRow[] | null | undefined>(initial);
31 + const [visible, setVisible] = useState(initial !== undefined);
32 +
33 + useEffect(() => {
34 + if (visible || !ref.current) return;
35 + const el = ref.current;
36 + if (typeof IntersectionObserver === 'undefined') return;
37 + const io = new IntersectionObserver(
38 + (entries) => {
39 + if (entries.some((e) => e.isIntersecting)) {
40 + setVisible(true);
41 + io.disconnect();
42 + }
43 + },
44 + { rootMargin: '200px 0px' },
45 + );
46 + io.observe(el);
47 + return () => io.disconnect();
48 + }, [visible]);
49 +
50 + useEffect(() => {
51 + if (!visible || rows !== undefined || blocked) return;
52 + let cancelled = false;
53 + schedule(async () => {
54 + if (cancelled || blocked) return;
55 + try {
56 + const r = await clientCompare.rankingTop(slug, 3);
57 + if (!cancelled) setRows(r.rows);
58 + } catch (e) {
59 + if ((e as { status?: number }).status === 429) blocked = true;
60 + if (!cancelled) setRows(null);
61 + }
62 + });
63 + return () => {
64 + cancelled = true;
65 + };
66 + }, [visible, rows, slug]);
67 +
68 + return (
69 + <div ref={ref} className="min-h-[1.25rem] text-xs text-ink-2" aria-label={t('rankings.top3')}>
70 + {rows === undefined ? (
71 + <span className="text-ink-3">{visible ? t('rankings.preview.loading') : ''}</span>
72 + ) : rows === null || rows.length === 0 ? (
73 + <span className="text-ink-3">{t('rankings.preview.na')}</span>
74 + ) : (
75 + <ol className="flex flex-wrap gap-x-3 gap-y-0.5">
76 + {rows.slice(0, 3).map((r) => (
77 + <li key={r.country.id} className="inline-flex items-center gap-1">
78 + <span className="tnum text-ink-3">{r.rank}</span>
79 + <span aria-hidden>{r.country.flag}</span>
80 + <span className="sr-only">{r.country.name}</span>
81 + <span className="tnum text-ink">{r.formatted ?? formatValue(r.value, spec)}</span>
82 + </li>
83 + ))}
84 + </ol>
85 + )}
86 + </div>
87 + );
88 +}
added apps/web/src/components/regions/member-map.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import { t } from '@/i18n';
2 +import { cn } from '@/lib/cn';
3 +import { MAP_HEIGHT, MAP_WIDTH, isoLookupFromCountries, worldPaths } from '@/lib/map-geo';
4 +import type { CountrySummary } from '@/lib/types';
5 +
6 +/**
7 + * Server-rendered world map highlighting a group's members (accent) against the other countries (surface-2).
8 + * Static SVG — no hover layer needed, the members table below carries the detail.
9 + */
10 +export function MemberMap({ members, countries, name, className }: { members: string[]; countries: CountrySummary[]; name: string; className?: string }) {
11 + const { paths, sphere } = worldPaths();
12 + const numeric = isoLookupFromCountries(countries as Array<{ id: string; iso_numeric?: string | null }>);
13 + const set = new Set(members);
14 + const summary = t('region.map.summary', { n: members.length, name });
15 + return (
16 + <figure className={cn('min-w-0', className)}>
17 + <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}` }}>
18 + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={summary}>
19 + <title>{summary}</title>
20 + <path d={sphere} fill="var(--surface)" stroke="var(--rule)" strokeWidth={1} />
21 + <g stroke="var(--surface)" strokeWidth={0.6} strokeLinejoin="round">
22 + {paths.map((p, i) => {
23 + const iso3 = p.iso3 ?? (p.numeric ? numeric.get(p.numeric) ?? null : null);
24 + const on = iso3 ? set.has(iso3) : false;
25 + return <path key={iso3 ?? `${p.name}-${i}`} d={p.d} fill={on ? 'var(--accent)' : 'var(--nodata)'} fillOpacity={on ? 0.9 : 0.55} />;
26 + })}
27 + </g>
28 + </svg>
29 + </div>
30 + <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 text-2xs text-ink-2">
31 + <span className="inline-flex items-center gap-1">
32 + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs bg-accent" /> {name}
33 + </span>
34 + <span className="inline-flex items-center gap-1">
35 + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs bg-nodata" /> {t('chart.legend.noData')}
36 + </span>
37 + </figcaption>
38 + </figure>
39 + );
40 +}
added apps/web/src/components/regions/member-ranking.tsx +83 −0
@@ -0,0 +1,83 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useEffect, useMemo, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { clientExplore } from '@/lib/client-api-explore';
6 +import { cn } from '@/lib/cn';
7 +import { routes } from '@/lib/site';
8 +import type { IndicatorCard, RankingResponse } from '@/lib/types';
9 +import type { RegionResponse } from '@/lib/types-explore';
10 +import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars';
11 +import { EmptyState } from '@/components/data/empty-state';
12 +
13 +/**
14 + * Member ranking for a group: indicator picker (headline indicators + a few more) → RankedBars of the members.
15 + * The default indicator comes server-side from `/regions/{slug}` (`ranking`); others are fetched from
16 + * `/rankings/{indicator}?group={slug}` client-side.
17 + */
18 +export function MemberRanking({ groupSlug, groupName, initial, options }: { groupSlug: string; groupName: string; initial: RegionResponse['ranking']; options: IndicatorCard[] }) {
19 + const [indicator, setIndicator] = useState(initial.indicator.slug);
20 + const [cache, setCache] = useState<Record<string, RankingResponse | null>>({});
21 + const [loading, setLoading] = useState(false);
22 + const all = useMemo(() => {
23 + const seen = new Set<string>();
24 + return [initial.indicator, ...options].filter((o) => (seen.has(o.slug) ? false : (seen.add(o.slug), true)));
25 + }, [initial.indicator, options]);
26 +
27 + useEffect(() => {
28 + if (indicator === initial.indicator.slug || cache[indicator] !== undefined) return;
29 + const ctrl = new AbortController();
30 + setLoading(true);
31 + clientExplore
32 + .ranking(indicator, groupSlug, 80, ctrl.signal)
33 + .then((r) => setCache((c) => ({ ...c, [indicator]: r })))
34 + .catch(() => setCache((c) => ({ ...c, [indicator]: null })))
35 + .finally(() => {
36 + if (!ctrl.signal.aborted) setLoading(false);
37 + });
38 + return () => ctrl.abort();
39 + }, [indicator, groupSlug, initial.indicator.slug, cache]);
40 +
41 + const isDefault = indicator === initial.indicator.slug;
42 + // `undefined` = not fetched yet (loading), `null` = fetch failed.
43 + const fetched = isDefault ? undefined : cache[indicator];
44 + const spec: IndicatorCard = fetched ? fetched.indicator : all.find((o) => o.slug === indicator) ?? initial.indicator;
45 + const rows: RankedBarRow[] = isDefault
46 + ? initial.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.rank_world && r.n_world ? t('region.ranking.worldRank', { rank: r.rank_world, n: r.n_world }) : null))
47 + : fetched
48 + ? fetched.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.change_1y?.formatted ?? null))
49 + : [];
50 + const prov = isDefault ? initial.rows[0]?.provenance ?? null : fetched?.rows[0]?.provenance ?? null;
51 + const empty = (!isDefault && fetched === null) || (rows.length === 0 && !loading && (isDefault || fetched !== undefined));
52 +
53 + return (
54 + <div className="min-w-0">
55 + <div className="mb-3 flex flex-wrap items-center gap-2">
56 + <label className="inline-flex h-11 max-w-full items-center gap-2 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
57 + <span className="shrink-0 text-xs font-medium">{t('region.ranking.indicator')}</span>
58 + <select value={indicator} onChange={(e) => setIndicator(e.target.value)} className="min-w-0 max-w-[16rem] bg-transparent pr-1 text-ink outline-none" aria-label={t('region.ranking.indicator')}>
59 + {all.map((o) => (
60 + <option key={o.slug} value={o.slug}>
61 + {o.short_name ?? o.name}
62 + </option>
63 + ))}
64 + </select>
65 + </label>
66 + <Link href={routes.ranking(indicator, { group: groupSlug })} className="ml-auto inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]">
67 + {t('region.ranking.full', { group: groupName })} →
68 + </Link>
69 + </div>
70 + <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 200 }}>
71 + {empty ? (
72 + <EmptyState compact title={t('region.ranking.none')} />
73 + ) : rows.length ? (
74 + <RankedBars rows={rows} spec={spec} provenance={prov} />
75 + ) : (
76 + <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 160 }}>
77 + {t('common.loading')}
78 + </div>
79 + )}
80 + </div>
81 + </div>
82 + );
83 +}
added apps/web/src/components/regions/members-table.tsx +132 −0
@@ -0,0 +1,132 @@
1 +'use client';
2 +import { ArrowDown, ArrowUp } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useMemo, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { cn } from '@/lib/cn';
7 +import { formatValue, isNum } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { IndicatorCard } from '@/lib/types';
10 +import type { RegionMember } from '@/lib/types-explore';
11 +
12 +/**
13 + * Sortable members table (flag, name + up to 5 headline values). Table on sm+, list rows on phones (each row
14 + * shows the country and the first three values). Sorting is client-side; default = population desc.
15 + */
16 +export function MembersTable({ members, indicators }: { members: RegionMember[]; indicators: IndicatorCard[] }) {
17 + const cols = indicators.slice(0, 5);
18 + const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: cols[0]?.slug ?? 'name', dir: 'desc' });
19 +
20 + const rows = useMemo(() => {
21 + const copy = [...members];
22 + const num = (m: RegionMember, k: string) => {
23 + const v = m.values[k]?.value;
24 + return isNum(v) ? v : null;
25 + };
26 + copy.sort((a, b) => {
27 + if (sort.key === 'name') return (sort.dir === 'asc' ? 1 : -1) * (a.name ?? '').localeCompare(b.name ?? '');
28 + const va = num(a, sort.key);
29 + const vb = num(b, sort.key);
30 + if (va == null && vb == null) return (a.name ?? '').localeCompare(b.name ?? '');
31 + if (va == null) return 1;
32 + if (vb == null) return -1;
33 + return sort.dir === 'asc' ? va - vb : vb - va;
34 + });
35 + return copy;
36 + }, [members, sort]);
37 +
38 + const toggle = (key: string) => setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }));
39 + const Th = ({ k, label, numeric }: { k: string; label: string; numeric?: boolean }) => {
40 + const active = sort.key === k;
41 + return (
42 + <th scope="col" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-1.5 pr-3 font-medium', numeric && 'text-right')}>
43 + <button type="button" onClick={() => toggle(k)} className={cn('inline-flex min-h-[32px] items-center gap-1 rounded-xs hover:text-ink', active ? 'text-ink' : 'text-ink-3', numeric && 'justify-end')}>
44 + {label}
45 + {active ? sort.dir === 'asc' ? <ArrowUp size={12} aria-hidden /> : <ArrowDown size={12} aria-hidden /> : null}
46 + </button>
47 + </th>
48 + );
49 + };
50 +
51 + return (
52 + <div className="min-w-0">
53 + {/* Mobile sort control */}
54 + <div className="mb-2 flex items-center gap-2 sm:hidden">
55 + <label className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">
56 + <span className="text-xs">{t('common.sortBy')}</span>
57 + <select value={sort.key} onChange={(e) => setSort({ key: e.target.value, dir: e.target.value === 'name' ? 'asc' : 'desc' })} className="bg-transparent pr-1 text-ink outline-none">
58 + <option value="name">{t('common.name')}</option>
59 + {cols.map((c) => (
60 + <option key={c.slug} value={c.slug}>
61 + {c.short_name ?? c.name}
62 + </option>
63 + ))}
64 + </select>
65 + </label>
66 + <button type="button" onClick={() => setSort((s) => ({ ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' }))} className="tap inline-flex items-center justify-center rounded-sm border border-rule text-ink-2" aria-label={sort.dir === 'asc' ? t('table.sortAsc') : t('table.sortDesc')}>
67 + {sort.dir === 'asc' ? <ArrowUp size={15} aria-hidden /> : <ArrowDown size={15} aria-hidden />}
68 + </button>
69 + </div>
70 +
71 + <table className="hidden w-full border-collapse text-sm sm:table">
72 + <thead>
73 + <tr className="border-b border-rule text-left text-xs">
74 + <Th k="name" label={t('region.members.country')} />
75 + {cols.map((c) => (
76 + <Th key={c.slug} k={c.slug} label={c.short_name ?? c.name ?? c.slug} numeric />
77 + ))}
78 + </tr>
79 + </thead>
80 + <tbody className="divide-y divide-rule">
81 + {rows.map((m) => (
82 + <tr key={m.id} className="hover:bg-surface-2/60">
83 + <td className="py-2 pr-3">
84 + <Link href={routes.country(m.slug ?? m.id)} className="link-quiet inline-flex min-h-[32px] items-center gap-2 text-ink">
85 + <span aria-hidden className="text-base leading-none">{m.flag}</span>
86 + <span className="truncate">{m.name}</span>
87 + </Link>
88 + </td>
89 + {cols.map((c) => {
90 + const v = m.values[c.slug];
91 + return (
92 + <td key={c.slug} className="tnum py-2 pr-3 text-right text-ink">
93 + {v && isNum(v.value) ? (
94 + <>
95 + {formatValue(v.value, c)}
96 + {v.year ? <span className="ml-1 text-2xs text-ink-3">{v.year}</span> : null}
97 + </>
98 + ) : (
99 + <span className="text-ink-3">{t('region.members.noValue')}</span>
100 + )}
101 + </td>
102 + );
103 + })}
104 + </tr>
105 + ))}
106 + </tbody>
107 + </table>
108 +
109 + <ul className="divide-y divide-rule sm:hidden">
110 + {rows.map((m) => (
111 + <li key={m.id} className="py-2.5">
112 + <Link href={routes.country(m.slug ?? m.id)} className="link-quiet flex min-h-[44px] items-center gap-2 text-sm font-medium text-ink">
113 + <span aria-hidden className="text-lg leading-none">{m.flag}</span>
114 + <span className="truncate">{m.name}</span>
115 + </Link>
116 + <dl className="mt-1 grid grid-cols-3 gap-x-3">
117 + {cols.slice(0, 3).map((c) => {
118 + const v = m.values[c.slug];
119 + return (
120 + <div key={c.slug} className="min-w-0">
121 + <dt className="truncate text-2xs text-ink-3">{c.short_name ?? c.name}</dt>
122 + <dd className="tnum truncate text-sm text-ink">{v && isNum(v.value) ? formatValue(v.value, c) : t('region.members.noValue')}</dd>
123 + </div>
124 + );
125 + })}
126 + </dl>
127 + </li>
128 + ))}
129 + </ul>
130 + </div>
131 + );
132 +}
added apps/web/src/i18n/en.compare.ts +198 −0
@@ -0,0 +1,198 @@
1 +/** Strings for the Compare and Rankings experiences (merged into `en.ts`). */
2 +export const enCompare = {
3 + // --- compare landing
4 + 'compare.title': 'Compare countries',
5 + 'compare.metaTitle': 'Compare countries side by side',
6 + 'compare.description': 'Pick 2 to 8 countries and compare them on 260 indicators: economy, population, health, housing, energy, climate and more — every number traceable to its source.',
7 + 'compare.sub': 'Pick two to eight countries. Every indicator, side by side, with full history and sources.',
8 + 'compare.builder.title': 'Build a comparison',
9 + 'compare.builder.hint': 'Add between {min} and {max} countries.',
10 + 'compare.builder.search': 'Add a country…',
11 + 'compare.builder.selected': 'Selected',
12 + 'compare.builder.empty': 'No country selected yet. Start typing above or pick a preset.',
13 + 'compare.builder.go': 'Compare',
14 + 'compare.builder.goN': 'Compare {n} countries',
15 + 'compare.builder.needMore': 'Add at least {n} more',
16 + 'compare.builder.full': 'Maximum of {max} countries reached.',
17 + 'compare.builder.clear': 'Clear',
18 + 'compare.builder.open': 'Choose countries',
19 + 'compare.builder.noMatch': 'No country matches “{q}”.',
20 + 'compare.builder.moveUp': 'Move {name} up',
21 + 'compare.builder.moveDown': 'Move {name} down',
22 + 'compare.builder.remove': 'Remove {name}',
23 + 'compare.builder.drag': 'Drag to reorder',
24 + 'compare.presets.title': 'Presets',
25 + 'compare.presets.sub': 'Common groupings, one click away.',
26 + 'compare.preset.g7': 'G7',
27 + 'compare.preset.nordics': 'Nordics',
28 + 'compare.preset.brics': 'BRICS',
29 + 'compare.preset.usmca': 'USMCA',
30 + 'compare.preset.anglosphere': 'Anglosphere',
31 + 'compare.preset.asian-tigers': 'Asian Tigers',
32 + 'compare.preset.peers': '{name} vs peers',
33 + 'compare.preset.peersHint': 'Nearest statistical neighbours of {name}.',
34 + 'compare.presets.pairs': 'Popular pairs',
35 + 'compare.presets.count': '{n} countries',
36 +
37 + // --- compare view
38 + 'compare.vs': 'vs',
39 + 'compare.pageTitle': '{names} — Compare countries',
40 + 'compare.pageDescription': 'Side-by-side comparison of {names}: {list} and 250+ more indicators with full history, ranks and sources.',
41 + 'compare.headingTab': '{names} · {tab}',
42 + 'compare.add': 'Add',
43 + 'compare.addCountry': 'Add a country',
44 + 'compare.removeCountry': 'Remove {name} from the comparison',
45 + 'compare.reorder': 'Reorder',
46 + 'compare.minCountries': 'A comparison needs at least two countries.',
47 + 'compare.notFound': 'Comparison not found',
48 + 'compare.notFoundHint': 'At least two valid countries are needed, e.g. /compare/canada/united-states.',
49 + 'compare.tab.snapshot': 'Snapshot',
50 + 'compare.tab.economy': 'Economy',
51 + 'compare.tab.population': 'Population',
52 + 'compare.tab.health': 'Health',
53 + 'compare.tab.housing': 'Housing',
54 + 'compare.tab.energy': 'Energy',
55 + 'compare.tab.climate': 'Climate',
56 + 'compare.tab.digital': 'Digital',
57 + 'compare.tab.custom': 'Custom',
58 + 'compare.tabs': 'Comparison views',
59 + 'compare.options': 'Options',
60 + 'compare.range': 'Years',
61 + 'compare.range.from': 'From',
62 + 'compare.range.to': 'To',
63 + 'compare.range.all': 'All',
64 + 'compare.range.last': 'Last {n} years',
65 + 'compare.range.preset': 'Range',
66 + 'compare.valueMode': 'Values',
67 + 'compare.mode.absolute': 'Absolute',
68 + 'compare.mode.per-capita': 'Per capita',
69 + 'compare.mode.pct': '% change',
70 + 'compare.mode.index100': 'Index = 100',
71 + 'compare.mode.hint.index100': 'Each series rebased to 100 at the first year of the range.',
72 + 'compare.mode.hint.pct': 'Year-over-year change in percent.',
73 + 'compare.mode.hint.per-capita': 'Totals divided by population; per-capita and share indicators are unchanged.',
74 + 'compare.log': 'Log scale',
75 + 'compare.download': 'Download CSV',
76 + 'compare.share': 'Share',
77 + 'compare.copied': 'Link copied',
78 + 'compare.snapshot.title': 'Snapshot',
79 + 'compare.snapshot.sub': 'Latest value per country. Best value highlighted where “better” is defined.',
80 + 'compare.snapshot.subTopic': '{topic}: latest value per country. Best value highlighted where “better” is defined.',
81 + 'compare.snapshot.best': 'Best',
82 + 'compare.snapshot.rank': '#{rank}',
83 + 'compare.snapshot.rankOf': 'Ranked {rank} of {n} worldwide',
84 + 'compare.snapshot.openChart': 'Chart {name}',
85 + 'compare.snapshot.showChart': 'Show chart',
86 + 'compare.snapshot.empty': 'No indicator has data for these countries.',
87 + 'compare.snapshot.indicatorCol': 'Indicator',
88 + 'compare.snapshot.hib.higher': 'higher is better',
89 + 'compare.snapshot.hib.lower': 'lower is better',
90 + 'compare.charts.title': '{topic}',
91 + 'compare.charts.sub': '{n} indicators with data · one colour per country.',
92 + 'compare.charts.none': 'No {topic} indicator has data for these countries.',
93 + 'compare.charts.loading': 'Loading chart…',
94 + 'compare.charts.error': 'This chart could not be loaded.',
95 + 'compare.charts.latest': 'Latest',
96 + 'compare.charts.sources': 'Sources',
97 + 'compare.charts.sourceFor': 'Source for {name}',
98 + 'compare.charts.indexBase': 'Index, {year} = 100',
99 + 'compare.charts.pctUnit': '% change vs previous year',
100 + 'compare.hero.title': 'Selected indicator',
101 + 'compare.hero.close': 'Close the selected indicator',
102 + 'compare.hero.openIndicator': 'Indicator page',
103 + 'compare.hero.ranking': 'Ranking',
104 + 'compare.custom.title': 'Custom comparison',
105 + 'compare.custom.sub': 'Pick up to {max} indicators from any topic.',
106 + 'compare.custom.pick': 'Choose indicators',
107 + 'compare.custom.add': 'Add an indicator',
108 + 'compare.custom.search': 'Search 260 indicators…',
109 + 'compare.custom.empty': 'No indicator selected. Choose up to {max} indicators to chart them for these countries.',
110 + 'compare.custom.full': 'Maximum of {max} indicators reached.',
111 + 'compare.custom.remove': 'Remove {name}',
112 + 'compare.custom.noMatch': 'No indicator matches “{q}”.',
113 + 'compare.custom.selected': '{n} of {max} selected',
114 + 'compare.legend': 'Countries',
115 + 'compare.colourNote': 'Colours follow the countries in the order above, on every chart of this page.',
116 +
117 + // --- rankings landing
118 + 'rankings.title': 'Rankings',
119 + 'rankings.metaTitle': 'Country rankings',
120 + 'rankings.description': 'Rank every country on {n} indicators: GDP, GDP per capita, population, life expectancy, CO₂ emissions, unemployment and more — with history, regions and sources.',
121 + 'rankings.sub': '{n} rankable indicators. Every ranking has a year selector, region and group filters, and a rank-by-year history.',
122 + 'rankings.search': 'Filter rankings…',
123 + 'rankings.featured': 'Featured',
124 + 'rankings.featured.sub': 'The most consulted rankings.',
125 + 'rankings.byTopic': 'By topic',
126 + 'rankings.count': '{n} rankings',
127 + 'rankings.countOf': '{n} of {total} rankings',
128 + 'rankings.noMatch': 'No ranking matches “{q}”.',
129 + 'rankings.meta': '{n} countries · {year}',
130 + 'rankings.top3': 'Top 3',
131 + 'rankings.preview.loading': 'Loading…',
132 + 'rankings.preview.na': 'Preview unavailable',
133 + 'rankings.open': 'Open the {name} ranking',
134 +
135 + // --- ranking page
136 + 'ranking.pageTitle': '{name} by country ({year})',
137 + 'ranking.pageTitleGroup': '{name} in {group} ({year})',
138 + 'ranking.pageDescription': '{name} ranking of {n} countries in {year}: {top} lead. Change over 1 and 10 years, map, rank history and sources.',
139 + 'ranking.heading': '{name} by country',
140 + 'ranking.headingGroup': '{name} — {group}',
141 + 'ranking.definition': '{unit} · {n} countries ranked · {year}',
142 + 'ranking.notFound': 'Unknown ranking',
143 + 'ranking.notFoundHint': 'No rankable indicator matches “{slug}”. Browse the list of rankings.',
144 + 'ranking.year': 'Year',
145 + 'ranking.yearUsed': 'Nearest available year: {year} (requested {requested}).',
146 + 'ranking.group': 'Group',
147 + 'ranking.group.world': 'World',
148 + 'ranking.group.region': 'Regions',
149 + 'ranking.group.continent': 'Continents',
150 + 'ranking.group.income': 'Income groups',
151 + 'ranking.group.org': 'Organisations',
152 + 'ranking.sort': 'Sort',
153 + 'ranking.sort.desc': 'Highest first',
154 + 'ranking.sort.asc': 'Lowest first',
155 + 'ranking.highlight': 'Highlight a country',
156 + 'ranking.highlight.placeholder': 'Highlight a country…',
157 + 'ranking.highlight.clear': 'Clear highlight',
158 + 'ranking.highlight.notInGroup': '{name} is not in this group or has no data for {year}.',
159 + 'ranking.search': 'Search within the ranking…',
160 + 'ranking.filters': 'Filters',
161 + 'ranking.filtersActive': '{n} active',
162 + 'ranking.showMore': 'Show {n} more',
163 + 'ranking.showing': '{shown} of {n}',
164 + 'ranking.noMatch': 'No country matches “{q}”.',
165 + 'ranking.empty': 'No countries ranked for this selection.',
166 + 'ranking.col.rank': 'Rank',
167 + 'ranking.col.country': 'Country',
168 + 'ranking.col.value': 'Value',
169 + 'ranking.col.change1y': '1 y',
170 + 'ranking.col.change10y': '10 y',
171 + 'ranking.col.trend': 'Trend',
172 + 'ranking.col.year': 'Year',
173 + 'ranking.rankNote.better': 'Rank 1 = best ({direction} is better for this indicator).',
174 + 'ranking.rankNote.higher': 'Rank 1 = highest value; “better” is not defined for this indicator.',
175 + 'ranking.rankNote.lowest': 'Rank 1 = lowest value.',
176 + 'ranking.direction.higher': 'higher',
177 + 'ranking.direction.lower': 'lower',
178 + 'ranking.worldRank': 'World rank {rank} of {n}',
179 + 'ranking.map.title': 'On the map',
180 + 'ranking.map.sub': '{name}, {year}. Quantile classes; hatched = no data.',
181 + 'ranking.map.none': 'Map unavailable for this year.',
182 + 'ranking.history.title': 'Rank over time',
183 + 'ranking.history.sub': 'Position in the world ranking by year, up to {max} countries. Rank 1 is at the top.',
184 + 'ranking.history.pick': 'Add a country…',
185 + 'ranking.history.full': 'Maximum of {max} countries.',
186 + 'ranking.history.empty': 'Pick a country to see its rank over time.',
187 + 'ranking.history.axis': 'Rank',
188 + 'ranking.history.summary': 'Rank by year for {names}, {y0}–{y1}.',
189 + 'ranking.history.remove': 'Remove {name}',
190 + 'ranking.history.of': 'of {n}',
191 + 'ranking.openIndicator': 'Open indicator page',
192 + 'ranking.download': 'Download CSV',
193 + 'ranking.compareTop': 'Compare the top {n}',
194 + 'ranking.value.forecast': 'projection',
195 + 'ranking.pctRank': 'Top {pct} %',
196 + 'ranking.years': 'Years',
197 + 'ranking.jumpToHighlight': 'Jump to {name}',
198 +} as const;
added apps/web/src/i18n/en.explore.ts +550 −0
@@ -0,0 +1,550 @@
1 +/**
2 + * Strings for the Indicators / Regions / Explore / Changes / Data / Sources / Methodology / API / Admin pages.
3 + * Merged into `en.ts` with a single spread. Same conventions: flat dotted keys, `{name}` placeholders.
4 + */
5 +export const enExplore = {
6 + // --- indicators directory
7 + 'indicators.title': 'Indicators',
8 + 'indicators.sub': '{n} indicators across 19 topics. Every one has a definition, a world map, a trend and its sources.',
9 + 'indicators.search': 'Filter indicators…',
10 + 'indicators.count': '{n} of {total}',
11 + 'indicators.noMatch': 'No indicator matches.',
12 + 'indicators.allTopics': 'All topics',
13 + 'indicators.topics': 'Topics',
14 + 'indicators.coverage': '{n} countries · {y0}–{y1}',
15 + 'indicators.coverageShort': '{n} countries',
16 + 'indicators.featured': 'Featured',
17 + 'indicators.rankingLink': 'Ranking',
18 + 'indicators.compareLink': 'Compare',
19 + 'indicators.openLink': 'Open',
20 + 'indicators.inTopic': '{n} in {topic}',
21 + 'indicators.freshness': 'Source updated {date}',
22 +
23 + // --- indicator page
24 + 'indicator.title': '{name} by Country',
25 + 'indicator.description': '{name} by country: world map, global trend, top and bottom countries, sources and downloads. Unit: {unit}. Data for {n} countries, {y0}–{y1}.',
26 + 'indicator.notFound': 'Unknown indicator',
27 + 'indicator.definition': 'Definition',
28 + 'indicator.methodology': 'Methodology',
29 + 'indicator.unit': 'Unit',
30 + 'indicator.frequency': 'Frequency',
31 + 'indicator.frequency.A': 'Annual',
32 + 'indicator.frequency.Q': 'Quarterly',
33 + 'indicator.frequency.M': 'Monthly',
34 + 'indicator.freshness': 'Freshness',
35 + 'indicator.freshness.latest': 'Latest observation',
36 + 'indicator.freshness.source': 'Source updated',
37 + 'indicator.freshness.refreshed': 'CountryAtlas refreshed',
38 + 'indicator.world': 'World {kind}',
39 + 'indicator.world.sum': 'World total',
40 + 'indicator.world.weighted_mean': 'World average (population-weighted)',
41 + 'indicator.world.median': 'World median',
42 + 'indicator.world.n': 'across {n} countries, {year}',
43 + 'indicator.coverage': '{n} of {total} countries ({pct})',
44 + 'indicator.map.title': 'World map',
45 + 'indicator.map.sub': 'One value per country for the selected year. Hatched = no data.',
46 + 'indicator.map.year': 'Year',
47 + 'indicator.map.nearest': 'Nearest year used for {n} countries',
48 + 'indicator.map.none': 'No country has a value for {year}.',
49 + 'indicator.map.n': '{n} countries',
50 + 'indicator.trend.title': 'Trend',
51 + 'indicator.trend.sub': 'Aggregate across the countries of a group, year by year. Computed by CountryAtlas from country values — no published aggregate is used.',
52 + 'indicator.trend.group': 'Group',
53 + 'indicator.trend.median': 'Median',
54 + 'indicator.trend.mean': 'Mean',
55 + 'indicator.trend.weighted_mean': 'Population-weighted mean',
56 + 'indicator.trend.sum': 'Sum',
57 + 'indicator.trend.n': 'countries per year: {min}–{max}',
58 + 'indicator.trend.none': 'Not enough countries to aggregate this indicator for {group}.',
59 + 'indicator.top.title': 'Highest and lowest',
60 + 'indicator.top.sub': 'The five leaders and the five trailing countries, {year}.',
61 + 'indicator.top.best': 'Top 5',
62 + 'indicator.top.worst': 'Bottom 5',
63 + 'indicator.top.highest': 'Highest 5',
64 + 'indicator.top.lowest': 'Lowest 5',
65 + 'indicator.top.fullRanking': 'Full ranking',
66 + 'indicator.compare.title': 'Compare countries',
67 + 'indicator.compare.sub': 'Pick up to five countries. Defaults to the three largest economies with data.',
68 + 'indicator.compare.add': 'Add your country',
69 + 'indicator.compare.search': 'Search a country…',
70 + 'indicator.compare.remove': 'Remove {name}',
71 + 'indicator.compare.max': 'Up to 5 countries',
72 + 'indicator.compare.open': 'Open the full comparison',
73 + 'indicator.compare.none': 'No series for the selected countries.',
74 + 'indicator.sources.title': 'Sources & provenance',
75 + 'indicator.sources.sub': 'Every value on this page comes from one of these series, chosen per country by priority.',
76 + 'indicator.sources.rule':
77 + 'Series-level selection: for each country the whole series comes from the highest-priority source that has any non-forecast value for it. If a lower-priority source is more than three years fresher, the freshest source wins instead. Non-chosen values are kept as alternatives, never discarded.',
78 + 'indicator.sources.priority': 'Priority',
79 + 'indicator.sources.source': 'Source',
80 + 'indicator.sources.dataset': 'Dataset',
81 + 'indicator.sources.series': 'Series',
82 + 'indicator.sources.coverage': 'Coverage',
83 + 'indicator.sources.status': 'Status',
84 + 'indicator.sources.open': 'Open at source',
85 + 'indicator.sources.transform': 'transform {expr}',
86 + 'indicator.sources.restricted': 'limited to {n} countries',
87 + 'indicator.nodata.title': 'Countries without data',
88 + 'indicator.nodata.sub': '{n} of {total} countries have no value for {year}.',
89 + 'indicator.nodata.none': 'Every country in the registry has a value for {year}.',
90 + 'indicator.nodata.why':
91 + 'Typical reasons: the source does not survey small territories or micro-states; the statistical office reports with a lag of one to three years; the series is only compiled for member countries of the source organisation (OECD, EU, Eurostat); or a value exists but failed validation and is quarantined rather than shown.',
92 + 'indicator.nodata.more': '{n} more',
93 + 'indicator.downloads.title': 'Downloads & API',
94 + 'indicator.downloads.sub': 'Every observation for this indicator, all countries and years, with provenance columns.',
95 + 'indicator.downloads.csv': 'Download CSV',
96 + 'indicator.downloads.json': 'Download JSON',
97 + 'indicator.downloads.api': 'API example',
98 + 'indicator.downloads.apiHint': 'One country, full history. Replace CAN by any ISO3 code or slug.',
99 + 'indicator.related.title': 'Related indicators',
100 + 'indicator.related.sub': 'Same subtopic: {subtopic}.',
101 + 'indicator.related.none': 'No other indicator in this subtopic.',
102 + 'indicator.topic': 'Topic',
103 + 'indicator.subtopic': 'Subtopic',
104 + 'indicator.higherIsBetter.true': 'Higher is better',
105 + 'indicator.higherIsBetter.false': 'Lower is better',
106 + 'indicator.higherIsBetter.null': 'No direction',
107 + 'indicator.aggregation': 'Aggregation',
108 + 'indicator.bounds': 'Valid range',
109 + 'indicator.copy': 'Copy',
110 + 'indicator.copied': 'Copied',
111 + 'indicator.og.alt': '{name} by country on CountryAtlas',
112 +
113 + // --- regions
114 + 'regions.title': 'Regions & groups',
115 + 'regions.sub': 'World, World Bank regions, continents, income groups and organisations. Aggregates are computed by CountryAtlas from member countries.',
116 + 'regions.kind.world': 'World',
117 + 'regions.kind.region': 'Regions',
118 + 'regions.kind.continent': 'Continents',
119 + 'regions.kind.income': 'Income groups',
120 + 'regions.kind.org': 'Organisations',
121 + 'regions.kind.custom': 'Other groups',
122 + 'regions.kindLabel.world': 'World',
123 + 'regions.kindLabel.region': 'World Bank region',
124 + 'regions.kindLabel.continent': 'Continent',
125 + 'regions.kindLabel.income': 'Income group',
126 + 'regions.kindLabel.org': 'Organisation',
127 + 'regions.kindLabel.custom': 'Group',
128 + 'regions.members': '{n} members',
129 + 'regions.population': 'Population',
130 + 'regions.gdp': 'GDP',
131 + 'regions.lifeExpectancy': 'Life expectancy',
132 + 'regions.sum': 'sum',
133 + 'regions.median': 'median',
134 + 'regions.weighted_mean': 'pop.-weighted mean',
135 + 'region.title': '{name} — Countries & Statistics',
136 + 'region.description': '{name}: {n} member countries with population, GDP, life expectancy and every CountryAtlas indicator; member ranking and aggregates.',
137 + 'region.notFound': 'Unknown region or group',
138 + 'region.aggregates.title': 'Aggregates',
139 + 'region.aggregates.sub': 'Computed across member countries in this snapshot. The label says how: sum, median or population-weighted mean.',
140 + 'region.aggregates.n': '{n} countries · {year}',
141 + 'region.members.title': 'Members',
142 + 'region.members.sub': '{n} countries and territories, latest values. Tap a column to sort.',
143 + 'region.members.country': 'Country',
144 + 'region.members.noValue': '—',
145 + 'region.ranking.title': 'Member ranking',
146 + 'region.ranking.sub': 'Members ranked on one indicator; rank 1 is the best value when the indicator has a direction, otherwise the highest.',
147 + 'region.ranking.indicator': 'Indicator',
148 + 'region.ranking.none': 'No member has a value for this indicator.',
149 + 'region.ranking.worldRank': 'World rank {rank} of {n}',
150 + 'region.ranking.full': 'Full ranking for {group}',
151 + 'region.map.title': 'Member map',
152 + 'region.map.sub': 'Highlighted: the {n} members of {name}.',
153 + 'region.map.summary': 'World map highlighting the {n} members of {name}.',
154 + 'region.compareAll': 'Compare all members',
155 + 'region.compareFirst': 'Compare the {n} largest',
156 + 'region.description.label': 'About',
157 +
158 + // --- explore
159 + 'explore.title': 'Explore',
160 + 'explore.sub': 'Three ways in: a country, a topic or a question. Every shortcut opens a real view with live data.',
161 + 'explore.country.title': 'Start from a country',
162 + 'explore.country.sub': 'Search any of 218 countries and territories, or browse a region.',
163 + 'explore.country.search': 'Search a country…',
164 + 'explore.topic.title': 'Start from a topic',
165 + 'explore.topic.sub': '19 topics, {n} indicators.',
166 + 'explore.question.title': 'Start from a question',
167 + 'explore.question.sub': 'Curated shortcuts, each backed by a live figure from the current snapshot.',
168 + 'explore.question.leader': '{country} leads with {value} ({year})',
169 + 'explore.question.open': 'Open →',
170 + 'explore.q.age': 'Which countries are ageing fastest?',
171 + 'explore.q.age.hint': 'Median age, highest first',
172 + 'explore.q.energy': 'Who leads the energy transition?',
173 + 'explore.q.energy.hint': 'Renewable share of electricity',
174 + 'explore.q.housing': 'Where is housing least affordable?',
175 + 'explore.q.housing.hint': 'House price to income ratio',
176 + 'explore.q.growth': 'Which economies grow fastest?',
177 + 'explore.q.growth.hint': 'Real GDP growth',
178 + 'explore.q.life': 'Where do people live longest?',
179 + 'explore.q.life.hint': 'Life expectancy at birth',
180 + 'explore.q.inflation': 'Where is inflation highest?',
181 + 'explore.q.inflation.hint': 'Consumer price inflation',
182 + 'explore.q.debt': 'Who carries the most public debt?',
183 + 'explore.q.debt.hint': 'General government gross debt, % of GDP',
184 + 'explore.q.co2': 'Who emits the most CO₂ per person?',
185 + 'explore.q.co2.hint': 'CO₂ emissions per capita',
186 + 'explore.q.internet': 'Where is everyone online?',
187 + 'explore.q.internet.hint': 'Internet users, % of population',
188 + 'explore.q.unemployment': 'Where is unemployment lowest?',
189 + 'explore.q.unemployment.hint': 'Unemployment rate, lowest first',
190 + 'explore.q.fertility': 'Where are families largest?',
191 + 'explore.q.fertility.hint': 'Fertility rate, births per woman',
192 + 'explore.q.rd': 'Who invests most in research?',
193 + 'explore.q.rd.hint': 'R&D expenditure, % of GDP',
194 + 'explore.q.g7': 'Compare the G7',
195 + 'explore.q.g7.hint': 'Seven economies side by side',
196 + 'explore.q.brics': 'Compare the BRICS founders',
197 + 'explore.q.brics.hint': 'Brazil, Russia, India, China, South Africa',
198 + 'explore.q.nordic': 'Compare the Nordic countries',
199 + 'explore.q.nordic.hint': 'Denmark, Finland, Iceland, Norway, Sweden',
200 + 'explore.q.members': '{n} members',
201 + 'explore.changes.title': 'Biggest recent changes',
202 + 'explore.changes.sub': 'Detected deterministically from the data — no editorial picks.',
203 + 'explore.changes.all': 'Full feed',
204 + 'explore.similar.title': 'Country similarity playground',
205 + 'explore.similar.sub': 'Pick a country and see its nearest peers on each dimension. Scores are standardised distances, 100 = identical.',
206 + 'explore.similar.pick': 'Pick a country…',
207 + 'explore.similar.peers': 'Peers of {name}',
208 + 'explore.similar.none': 'Not enough data to compute peers for {name}.',
209 + 'explore.similar.empty': 'Choose a country to start.',
210 + 'explore.similar.score': 'Score',
211 +
212 + // --- changes feed
213 + 'changes.title': 'Recent changes',
214 + 'changes.sub': 'Every notable movement in the latest snapshot, across all countries and indicators.',
215 + 'changes.how':
216 + 'Detected deterministically from the data: year-over-year moves beyond a robust threshold, record highs and lows, multi-year highs and lows, sign flips, accelerations. Severity is a computed 0–1 score. There are no editorial picks and no generated text — headlines are templates filled with the computed numbers.',
217 + 'changes.filter.kind': 'Kind',
218 + 'changes.filter.indicator': 'Indicator',
219 + 'changes.filter.region': 'Region',
220 + 'changes.filter.severity': 'Minimum severity',
221 + 'changes.filter.severity.any': 'Any',
222 + 'changes.filter.severity.notable': 'Notable and above',
223 + 'changes.filter.severity.major': 'Major only',
224 + 'changes.filter.search': 'Search an indicator…',
225 + 'changes.filter.active': '{n} active',
226 + 'changes.count': '{n} changes',
227 + 'changes.none': 'No change matches these filters.',
228 + 'changes.period': '{period}',
229 + 'changes.series': 'Series',
230 + 'changes.loading': 'Loading changes…',
231 + 'changes.error': 'The change feed is unavailable right now.',
232 + 'changes.methodology': 'How changes are detected',
233 +
234 + // --- data hub
235 + 'data.title': 'Data downloads',
236 + 'data.sub': 'Every number on the site as CSV or JSON, with provenance columns. Free to use with attribution.',
237 + 'data.country.title': 'Per-country dataset',
238 + 'data.country.sub': 'All indicators, all years, for one country.',
239 + 'data.country.search': 'Search a country…',
240 + 'data.indicator.title': 'Per-indicator dataset',
241 + 'data.indicator.sub': 'All countries, all years, for one indicator.',
242 + 'data.indicator.search': 'Search an indicator…',
243 + 'data.pick': 'Pick one to enable the downloads.',
244 + 'data.selected': 'Selected',
245 + 'data.bulk.title': 'Bulk data',
246 + 'data.bulk.sub': 'How to get everything.',
247 + 'data.bulk.text':
248 + 'There is no single archive yet. The fastest way to the whole snapshot is the API: list indicators with /api/v1/indicators, then fetch /api/v1/indicators/{slug}/download.csv for each. Please keep to the rate limit (120 requests per minute) and cache locally; the snapshot changes once a day at most. For a full copy of the database, write to the address below.',
249 + 'data.columns.title': 'Columns',
250 + 'data.columns.sub': 'The same columns in every export. CSV files start with one comment line naming the snapshot.',
251 + 'data.api.title': 'API',
252 + 'data.api.sub': 'Programmatic access to everything on the site.',
253 + 'data.api.text': 'Every page is built from the public JSON API. Base URL {base}. Read the endpoint list and copy examples on the API page.',
254 + 'data.api.link': 'API documentation',
255 + 'data.licence.title': 'Licence & attribution',
256 + 'data.licence.sub': 'Please credit the original sources, not only CountryAtlas.',
257 + 'data.licence.compilation': 'The CountryAtlas compilation (harmonised series, derived metrics, rankings, change detection) is released under CC BY 4.0.',
258 + 'data.licence.sources': 'Each observation carries its source licence in the export (column licence). Check it before redistributing: most sources are CC BY 4.0, but the WHO Global Health Observatory is CC BY-NC-SA 3.0 IGO (no commercial use, share alike) and some portals apply their own terms.',
259 + 'data.licence.example': 'Suggested attribution',
260 + 'data.licence.exampleText': 'Data: {sources} via CountryAtlas (countryatlas.co), CC BY 4.0 compilation. Retrieved {date}.',
261 + 'data.licence.source': 'Source',
262 + 'data.licence.licence': 'Licence',
263 + 'data.licence.attribution': 'Attribution',
264 + 'data.snapshot': 'Current snapshot {run}, built {date}.',
265 +
266 + // --- sources
267 + 'sources.title': 'Sources',
268 + 'sources.sub': '{n} data sources feed the atlas. Every value on the site names the one it came from.',
269 + 'sources.rule':
270 + 'How sources are combined: each indicator lists its sources in priority order (1 = preferred). For each country the whole series is taken from the highest-priority source that has any non-forecast value for it — so a country never mixes two sources within one series. If a lower-priority source is more than three years fresher, the fresher source wins instead. Values from the non-chosen sources are kept as alternatives, never merged or discarded; forecasts (IMF projections) are flagged and never enter latest values or rankings.',
271 + 'sources.observations': '{n} observations',
272 + 'sources.indicators': '{n} indicators',
273 + 'sources.lastImport': 'Last import {date}',
274 + 'sources.licence': 'Licence',
275 + 'sources.contact': 'Questions about a source?',
276 + 'sources.organisation': 'Organisation',
277 + 'sources.website': 'Website',
278 + 'sources.apiBase': 'API base',
279 + 'sources.attribution': 'Attribution text',
280 + 'source.title': '{name} — Data Source',
281 + 'source.description': '{name} on CountryAtlas: {n} indicators, licence {licence}, datasets, series mapped, import history and freshness.',
282 + 'source.notFound': 'Unknown source',
283 + 'source.about': 'About this source',
284 + 'source.freshness.title': 'Freshness',
285 + 'source.freshness.sub': 'When the source last published, when CountryAtlas last fetched it, and when the current snapshot was built.',
286 + 'source.freshness.sourceUpdated': 'Source updated',
287 + 'source.freshness.retrieved': 'Last successful import',
288 + 'source.freshness.built': 'Snapshot built',
289 + 'source.datasets.title': 'Datasets',
290 + 'source.datasets.sub': 'Datasets of this source present in the snapshot.',
291 + 'source.datasets.dataset': 'Dataset',
292 + 'source.datasets.observations': 'Observations',
293 + 'source.datasets.indicators': 'Indicators',
294 + 'source.datasets.countries': 'Countries',
295 + 'source.datasets.years': 'Years',
296 + 'source.series.title': 'Series mapped',
297 + 'source.series.sub': '{n} series feed CountryAtlas indicators. Priority 1 means this source is preferred for that indicator.',
298 + 'source.series.indicator': 'Indicator',
299 + 'source.series.code': 'Series code',
300 + 'source.series.priority': 'Priority',
301 + 'source.series.coverage': 'Coverage',
302 + 'source.series.lastYear': 'Last year',
303 + 'source.series.status': 'Status',
304 + 'source.runs.title': 'Import history',
305 + 'source.runs.sub': 'Latest attempt per series ({n} shown). Status, rows kept and validation counts.',
306 + 'source.runs.started': 'Started',
307 + 'source.runs.dataset': 'Dataset → indicator',
308 + 'source.runs.status': 'Status',
309 + 'source.runs.rows': 'Rows',
310 + 'source.runs.warnings': 'Warnings',
311 + 'source.runs.errors': 'Errors',
312 + 'source.runs.duration': 'Duration',
313 + 'source.runs.none': 'No import run recorded for this source.',
314 + 'source.open': 'Open {name}',
315 + 'source.run.ok': 'OK',
316 + 'source.run.partial': 'Partial',
317 + 'source.run.failed': 'Failed',
318 + 'source.run.quarantined': 'Quarantined',
319 + 'source.run.skipped': 'Skipped',
320 +
321 + // --- methodology
322 + 'method.title': 'Methodology',
323 + 'method.sub': 'How CountryAtlas builds every number: registries, source selection, validation, derived metrics and their limits.',
324 + 'method.toc': 'On this page',
325 + 'method.principles': 'Principles',
326 + 'method.countries': 'Country registry',
327 + 'method.countries.text':
328 + 'The registry lists 218 countries and territories with ISO 3166-1 alpha-2, alpha-3 and numeric codes, a URL slug, a short display name, capital, continent, World Bank region and income classification (FY2026), currency, area, borders and languages. Every dataset is mapped to this registry before anything else happens; a row whose country code is unknown is logged and dropped, and source aggregates (World, OECD…) are never stored as countries. Groups — World Bank regions, continents, income groups and organisations — are lists of registry members, so a group aggregate always means “across these member countries”.',
329 + 'method.indicators': 'Indicator registry & units',
330 + 'method.indicators.text':
331 + 'Each of the {n} indicators has a stable slug, a name, a topic and subtopic, a unit and short unit, a display format, a precision, a frequency, an aggregation rule (sum for additive quantities such as GDP or population; none otherwise), whether higher is better, whether it is eligible for rankings, optional bounds and a jump threshold for validation. Units are never converted at display time: the value stored is the value shown, in the registry unit.',
332 + 'method.indicators.formats': 'Formats',
333 + 'method.indicators.frequencies': 'Frequencies',
334 + 'method.indicators.aggregations': 'Aggregations',
335 + 'method.indicators.units': 'Most common units',
336 + 'method.sources': 'Sources, priority and series-level selection',
337 + 'method.sources.text2':
338 + 'Selection is made per series, not per point: for each country and indicator the whole history comes from one source, so a line never switches vintage halfway. The chosen source is the highest-priority one with any non-forecast value for that country, unless a lower-priority source is more than three years fresher, in which case the fresher one wins. The decision is recorded on every row. The non-chosen values remain available as alternatives.',
339 + 'method.sources.mapped': 'Series mapped per connector',
340 + 'method.sources.urls': 'Deep-link patterns',
341 + 'method.validation': 'Validation rules and statuses',
342 + 'method.validation.text':
343 + 'Every imported row passes deterministic checks. Unusual values are flagged, never deleted. A whole dataset is quarantined — and the previous good file kept — when it looks broken as a whole (duplicates, unit change, partial download).',
344 + 'method.validation.statuses': 'Statuses',
345 + 'method.validation.status.verified': 'Passed every check and matches a second source or a previous vintage.',
346 + 'method.validation.status.imported': 'Passed every check; no independent confirmation.',
347 + 'method.validation.status.warning': 'Kept, but flagged: an extreme jump versus the country’s own history.',
348 + 'method.validation.status.stale': 'The latest observation is older than the freshness window for its frequency.',
349 + 'method.validation.status.quarantined': 'Failed a hard check; stored for audit, excluded from every derived table.',
350 + 'method.validation.stale': 'Freshness windows',
351 + 'method.validation.staleText': 'A series is stale when its latest period ends more than {a} days ago (annual), {q} days (quarterly) or {m} days (monthly).',
352 + 'method.freshness': 'Freshness',
353 + 'method.freshness.text':
354 + 'Three dates travel with every value. Source updated is the vintage the publisher advertises; retrieved is when the pipeline fetched the file; built is when the snapshot was assembled. Pages show all three where they matter, and the footer of every page names the snapshot. A new snapshot is built once a day; the API cache is keyed on the snapshot id, so nothing stale survives a rebuild.',
355 + 'method.rankings': 'Rankings',
356 + 'method.rankings.text':
357 + 'Rankings compare countries (never territories or aggregates) for the same year. Rank 1 is the best value when the indicator declares a direction — the lowest infant mortality, the highest life expectancy — and simply the highest value when it does not. Group rankings recompute ranks within the group. Ranks are computed only for years with at least 20 countries; when a country’s latest year is more than two years behind the indicator’s latest year, its rank is flagged as stale.',
358 + 'method.changes': 'Changes & events',
359 + 'method.changes.text':
360 + 'Changes are detected per country and indicator with fixed, published rules. A year-over-year move counts when it exceeds a robust threshold (twice the median absolute deviation of the series’ yearly differences) and an indicator-specific floor. Record highs and lows, 10/20/30-year highs and lows, sign flips and accelerations are detected on the whole history. Severity is min(1, |z| / 4) blended with how far the move clears the floor, then weighted by the indicator’s importance. Recent detections form the changes feed; the whole history forms each country’s timeline.',
361 + 'method.changes.floors': 'Floors',
362 + 'method.similarity': 'Similarity',
363 + 'method.similarity.text':
364 + 'Peers are computed per mode from a fixed feature list. Heavy-tailed features (GDP per capita, population, area) are log-transformed, every feature is z-scored across countries using latest values, and the weighted Euclidean distance d between two countries becomes a score 100 × exp(−d / d₀), where d₀ is the median pairwise distance of the mode. A country needs at least 70 % of a mode’s features to be scored; each peer lists which features contributed most, so every match is explainable.',
365 + 'method.similarity.modes': 'Modes',
366 + 'method.insights': 'Insights',
367 + 'method.insights.text':
368 + 'Key facts on country pages are template sentences filled with numbers computed from the data (change since a year, rank in a group, distance to the median, average growth). No language model writes or rewrites them; the raw numbers are stored next to the text.',
369 + 'method.dna': 'Country DNA',
370 + 'method.dna.text':
371 + 'Nine descriptive dimensions, each the percentile rank (0–100) of the country among all countries for one representative indicator or the mean of two or three. It is a fingerprint for reading a country at a glance, not a score and not a ranking.',
372 + 'method.limitations': 'Limitations',
373 + 'method.limitations.text':
374 + 'Group aggregates are computed from the countries present in the snapshot and can differ from published aggregates that use different membership, weights or imputed values. Coverage is uneven: small territories and conflict-affected countries have sparse series. Definitions differ between sources — a series is never mixed, but two countries may be served by two sources. Forecasts are shown only where the chosen source publishes them. Rankings of quarterly or monthly indicators use the last period of the year. Percentile-based fingerprints and similarity scores are sensitive to which countries report a feature.',
375 + 'method.versioning': 'Versioning & revisions',
376 + 'method.versioning.text':
377 + 'Every snapshot has a run id (shown in the footer and in every API response). A rebuild never overwrites a value silently: a changed value is recorded as a revision with the previous value and both vintages. Raw source files are kept per run so any number can be traced back to the exact payload it came from. Indicator slugs, group ids and API paths are stable; deprecations are announced on the API page before removal.',
378 + 'method.licence': 'Licence',
379 + 'method.licence.text': 'The CountryAtlas compilation is CC BY 4.0. Each value keeps its source licence in the provenance object; see the Sources page for the exact terms per source.',
380 + 'method.formatting': 'Display formatting',
381 + 'method.periods': 'Periods',
382 + 'method.rule.severity': 'Severity',
383 + 'method.rule.code': 'Rule',
384 + 'method.rule.effect': 'Effect',
385 +
386 + // --- api landing
387 + 'apiPage.title': 'API',
388 + 'apiPage.sub': 'A public, read-only JSON API — the same one that builds every page of this site.',
389 + 'apiPage.intro':
390 + 'Every country, indicator, ranking, comparison, map and change on CountryAtlas is served by this API. Responses are plain JSON, every value carries a provenance object, and identifiers are human-readable slugs. No key is needed.',
391 + 'apiPage.base': 'Base URL',
392 + 'apiPage.swagger': 'Interactive docs (Swagger)',
393 + 'apiPage.openapi': 'OpenAPI specification',
394 + 'apiPage.endpoints.title': 'Endpoints',
395 + 'apiPage.endpoints.sub': 'All GET, all JSON. Countries accept ISO3 or slug; indicators, topics and groups accept their slug.',
396 + 'apiPage.endpoint': 'Endpoint',
397 + 'apiPage.describes': 'Returns',
398 + 'apiPage.examples.title': 'Examples',
399 + 'apiPage.examples.sub': 'Real responses from the current snapshot, trimmed for length. Copy the command and run it.',
400 + 'apiPage.examples.live': 'Live response (trimmed)',
401 + 'apiPage.examples.unavailable': 'Example unavailable right now — the API did not answer.',
402 + 'apiPage.provenance.title': 'The provenance object',
403 + 'apiPage.provenance.sub': 'Attached to every value the API returns.',
404 + 'apiPage.provenance.text': 'Headline metrics, series points, ranking rows, map values and change items all carry a provenance object: the source connector and display name, dataset and series code, when the pipeline retrieved it, when the source last updated it, a deep link to the source page, the transformation applied (if any) and the source licence. Series responses also list every source used with the number of values from each.',
405 + 'apiPage.limits.title': 'Rate limits & caching',
406 + 'apiPage.limits.text': '120 requests per minute per IP (429 with a Retry-After header beyond that). Responses are cached per snapshot; the X-CountryAtlas-Run header names the snapshot and X-Cache says whether the cache served you. Please cache on your side too — data changes at most once a day.',
407 + 'apiPage.errors.title': 'Errors',
408 + 'apiPage.errors.text': 'RFC 7807 problem+json: 404 for an unknown country, indicator, topic or group (with a hint in detail), 400 for a bad combination, 422 for an invalid parameter, 429 when rate-limited, 503 while the first snapshot is being built.',
409 + 'apiPage.licence.title': 'Licence',
410 + 'apiPage.licence.text': 'The compilation is CC BY 4.0; each value keeps its source licence in provenance.licence. Attribute the original sources and CountryAtlas.',
411 + 'apiPage.contact': 'Questions or higher limits?',
412 + 'apiPage.field': 'Field',
413 + 'apiPage.meaning': 'Meaning',
414 + 'apiPage.ep.health': 'Snapshot status, counts, version.',
415 + 'apiPage.ep.countries': 'Country directory with filters and sort.',
416 + 'apiPage.ep.country': 'Country overview: header, groups, coverage, headline metrics, topics.',
417 + 'apiPage.ep.countryTopic': 'All indicators of a topic for one country, grouped by subtopic.',
418 + 'apiPage.ep.countrySeries': 'Full history of one indicator for one country.',
419 + 'apiPage.ep.countryChanges': 'Recent detected changes for a country.',
420 + 'apiPage.ep.countryEvents': 'Whole-history timeline of a country.',
421 + 'apiPage.ep.countrySimilar': 'Nearest peers per mode with contributions.',
422 + 'apiPage.ep.countryInsights': 'Computed key facts.',
423 + 'apiPage.ep.countryDna': 'Nine-dimension fingerprint.',
424 + 'apiPage.ep.countryDownload': 'Per-country dataset (CSV or JSON).',
425 + 'apiPage.ep.indicators': 'Indicator directory with topic, text and source filters.',
426 + 'apiPage.ep.indicator': 'Definition, sources, coverage, world value, top and bottom five.',
427 + 'apiPage.ep.indicatorMap': 'Choropleth values for one year with quantile legend.',
428 + 'apiPage.ep.indicatorTrend': 'Aggregate trend for a group (median, mean, weighted mean, sum).',
429 + 'apiPage.ep.indicatorDownload': 'Per-indicator dataset (CSV or JSON).',
430 + 'apiPage.ep.series': 'Series bundle: several countries × several indicators.',
431 + 'apiPage.ep.rankings': 'Rankable indicators.',
432 + 'apiPage.ep.ranking': 'Ranking for one indicator, any year, any group.',
433 + 'apiPage.ep.rankingHistory': 'Rank by year for chosen countries.',
434 + 'apiPage.ep.compare': 'Comparison bundle with absolute, per-capita, indexed or % modes.',
435 + 'apiPage.ep.compareSnapshot': 'Latest values table for a topic across countries.',
436 + 'apiPage.ep.regions': 'Groups: regions, continents, income groups, organisations.',
437 + 'apiPage.ep.region': 'Group page: members, aggregates, member ranking.',
438 + 'apiPage.ep.search': 'Typed search across countries, indicators, topics, regions, sources.',
439 + 'apiPage.ep.home': 'Global snapshot, curated lists, recent changes.',
440 + 'apiPage.ep.changes': 'Global feed of detected changes with filters.',
441 + 'apiPage.ep.sources': 'Data sources with counts, licence and freshness.',
442 + 'apiPage.ep.source': 'Source detail: indicators mapped, datasets, import runs.',
443 + 'apiPage.ep.methodology': 'Registry-derived methodology payload.',
444 + 'apiPage.ex.country': 'A country overview',
445 + 'apiPage.ex.series': 'One series with history and provenance',
446 + 'apiPage.ex.ranking': 'A ranking, three rows',
447 + 'apiPage.ex.map': 'Map values for one year',
448 + 'apiPage.ex.search': 'Search',
449 + 'apiPage.ex.changes': 'Recent changes',
450 +
451 + // --- admin
452 + 'admin.title': 'Admin',
453 + 'admin.login.title': 'Admin sign-in',
454 + 'admin.login.sub': 'Internal operations panel. Enter the admin token to continue.',
455 + 'admin.login.token': 'Admin token',
456 + 'admin.login.submit': 'Sign in',
457 + 'admin.login.error': 'Invalid token.',
458 + 'admin.login.notConfigured': 'CA_ADMIN_TOKEN is not configured on this web server; the panel is disabled.',
459 + 'admin.logout': 'Sign out',
460 + 'admin.nav.overview': 'Overview',
461 + 'admin.nav.coverage': 'Coverage',
462 + 'admin.nav.runs': 'Runs',
463 + 'admin.nav.issues': 'Issues',
464 + 'admin.nav.raw': 'Raw files',
465 + 'admin.overview.title': 'Overview',
466 + 'admin.snapshot': 'Snapshot',
467 + 'admin.db': 'Database',
468 + 'admin.db.size': 'Size',
469 + 'admin.db.missing': 'No snapshot file',
470 + 'admin.scheduler': 'Scheduler',
471 + 'admin.scheduler.alive': 'Running (pid {pid})',
472 + 'admin.scheduler.dead': 'Not running (pid {pid} not found)',
473 + 'admin.scheduler.unknown': 'No pid file',
474 + 'admin.scheduler.last': 'Last run',
475 + 'admin.scheduler.next': 'Next run',
476 + 'admin.scheduler.refreshAt': 'Daily refresh at {time}',
477 + 'admin.cache': 'Response cache',
478 + 'admin.counts': 'Table counts',
479 + 'admin.statuses': 'Observation statuses',
480 + 'admin.connectors.title': 'Connector health',
481 + 'admin.connectors.sub': 'Latest attempt per series, aggregated by connector.',
482 + 'admin.connectors.connector': 'Connector',
483 + 'admin.connectors.runs': 'Runs',
484 + 'admin.connectors.last': 'Last finished',
485 + 'admin.connectors.status': 'Last status',
486 + 'admin.connectors.ok': 'OK',
487 + 'admin.connectors.failed': 'Failed',
488 + 'admin.connectors.rows': 'Rows valid',
489 + 'admin.connectors.warnings': 'Warnings',
490 + 'admin.connectors.errors': 'Errors',
491 + 'admin.stale.title': 'Sources freshness',
492 + 'admin.stale.sub': 'Oldest source vintage first. n_stale counts observations flagged stale.',
493 + 'admin.stale.source': 'Source',
494 + 'admin.stale.observations': 'Observations',
495 + 'admin.stale.updated': 'Source updated',
496 + 'admin.stale.retrieved': 'Retrieved',
497 + 'admin.stale.n': 'Stale',
498 + 'admin.actions.refresh': 'Refresh now',
499 + 'admin.actions.refreshHint': 'Sends SIGUSR1 to the scheduler.',
500 + 'admin.actions.clearCache': 'Clear cache',
501 + 'admin.actions.done': 'Done: {msg}',
502 + 'admin.actions.failed': 'Failed: {msg}',
503 + 'admin.coverage.title': 'Coverage matrix',
504 + 'admin.coverage.sub': '{n} indicators × countries. Click a header to sort.',
505 + 'admin.coverage.indicators': 'Indicators',
506 + 'admin.coverage.countries': 'Countries',
507 + 'admin.coverage.indicator': 'Indicator',
508 + 'admin.coverage.topic': 'Topic',
509 + 'admin.coverage.source': 'Source',
510 + 'admin.coverage.nCountries': 'Countries',
511 + 'admin.coverage.nObs': 'Obs.',
512 + 'admin.coverage.years': 'Years',
513 + 'admin.coverage.latestYear': 'Latest',
514 + 'admin.coverage.nLatest': 'With latest',
515 + 'admin.coverage.updated': 'Source updated',
516 + 'admin.coverage.country': 'Country',
517 + 'admin.coverage.pct': 'Coverage',
518 + 'admin.runs.title': 'Import runs',
519 + 'admin.runs.sub': 'Latest attempt per series ({n}). Filter by connector or status via the URL (?connector=&status=).',
520 + 'admin.runs.run': 'Run',
521 + 'admin.runs.raw': 'Raw files',
522 + 'admin.issues.title': 'Validation issues',
523 + 'admin.issues.sub': '{n} issues shown. Filter by severity.',
524 + 'admin.issues.all': 'All severities',
525 + 'admin.issues.summary': 'Summary',
526 + 'admin.issues.severity': 'Severity',
527 + 'admin.issues.code': 'Code',
528 + 'admin.issues.connector': 'Connector',
529 + 'admin.issues.indicator': 'Indicator',
530 + 'admin.issues.country': 'Country',
531 + 'admin.issues.period': 'Period',
532 + 'admin.issues.message': 'Message',
533 + 'admin.issues.value': 'Value',
534 + 'admin.issues.none': 'No validation issue recorded.',
535 + 'admin.raw.title': 'Raw files',
536 + 'admin.raw.sub': 'Files stored on disk for one import run.',
537 + 'admin.raw.runId': 'Run id',
538 + 'admin.raw.load': 'Load',
539 + 'admin.raw.pick': 'Enter a run id (see Runs) to list its raw files.',
540 + 'admin.raw.file': 'File',
541 + 'admin.raw.size': 'Size',
542 + 'admin.raw.modified': 'Modified',
543 + 'admin.raw.missing': 'missing',
544 + 'admin.raw.none': 'No file recorded for this run.',
545 + 'admin.unavailable': 'The admin API did not answer ({status}). Check CA_ADMIN_TOKEN on the API process and ADMIN_API_URL.',
546 + 'admin.empty': 'The API is running but no snapshot exists yet.',
547 + 'admin.na': '—',
548 + 'admin.key': 'Key',
549 + 'admin.value': 'Value',
550 +} as const;
modified apps/web/src/i18n/en.ts +5 −0
@@ -3,6 +3,9 @@
3 3 * Placeholders use `{name}`. Keys are flat, dot-namespaced; add new sections at the end of the block
4 4 * they belong to so diffs stay readable.
5 5 */
6 +import { enCompare } from './en.compare';
7 +import { enExplore } from './en.explore';
8 +
6 9 export const en = {
7 10 // --- site
8 11 'site.name': 'CountryAtlas',
@@ -363,6 +366,8 @@ export const en = {
363 366 // --- opengraph
364 367 'og.alt': '{name} — key statistics on CountryAtlas',
365 368 'og.site': 'countryatlas.co',
369 + ...enCompare,
370 + ...enExplore,
366 371 } as const;
367 372
368 373 export type DictKey = keyof typeof en;
added apps/web/src/lib/admin-api.ts +55 −0
@@ -0,0 +1,55 @@
1 +import 'server-only';
2 +import { ApiError, API_URL } from './api';
3 +import { adminToken } from './admin-auth';
4 +import type { Problem } from './types';
5 +import type { AdminCoverageResponse, AdminIssuesResponse, AdminOverview, AdminRawResponse, AdminRunsResponse } from './types-explore';
6 +
7 +/**
8 + * Admin endpoints (`X-Admin-Token`). Server components / actions only; never cached. `ADMIN_API_URL` lets
9 + * the panel target a second API instance (e.g. one started with a token while the main one has none).
10 + */
11 +const ADMIN_BASE = `${(process.env.ADMIN_API_URL ?? API_URL).replace(/\/$/, '')}/api/v1/admin`;
12 +
13 +type Query = Record<string, string | number | null | undefined>;
14 +
15 +export async function adminRequest<T>(path: string, query?: Query, init: { method?: 'GET' | 'POST' } = {}): Promise<T> {
16 + const token = adminToken();
17 + if (!token) throw new ApiError(503, path, { title: 'Admin disabled', status: 503, detail: 'CA_ADMIN_TOKEN is not configured on the web server.' });
18 + const p = new URLSearchParams();
19 + for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
20 + const qs = p.toString();
21 + const url = `${ADMIN_BASE}${path}${qs ? `?${qs}` : ''}`;
22 + let res: Response;
23 + try {
24 + res = await fetch(url, { method: init.method ?? 'GET', headers: { accept: 'application/json', 'X-Admin-Token': token }, cache: 'no-store' });
25 + } catch (e) {
26 + throw new ApiError(0, path, null, `Admin API unreachable at ${ADMIN_BASE} (${(e as Error).message})`);
27 + }
28 + if (!res.ok) {
29 + let problem: Problem | null = null;
30 + try {
31 + const body = (await res.json()) as unknown;
32 + if (body && typeof body === 'object' && 'status' in body && 'title' in body) problem = body as Problem;
33 + } catch {
34 + /* non-JSON */
35 + }
36 + throw new ApiError(res.status, path, problem);
37 + }
38 + return (await res.json()) as T;
39 +}
40 +
41 +export const adminApi = {
42 + overview: () => adminRequest<AdminOverview>('/overview'),
43 + runs: (q: { limit?: number; connector?: string; status?: string } = {}) => adminRequest<AdminRunsResponse>('/runs', { limit: 200, ...q }),
44 + issues: (q: { limit?: number; severity?: string; connector?: string; indicator?: string; code?: string; run_id?: string } = {}) => adminRequest<AdminIssuesResponse>('/issues', { limit: 300, ...q }),
45 + coverage: () => adminRequest<AdminCoverageResponse>('/coverage'),
46 + raw: (runId: string) => adminRequest<AdminRawResponse>('/raw', { run_id: runId }),
47 + refresh: () => adminRequest<{ ok: boolean; pid: number; signal: string; sent_at: string }>('/refresh', undefined, { method: 'POST' }),
48 + clearCache: () => adminRequest<{ ok: boolean; before: Record<string, number> }>('/cache/clear', undefined, { method: 'POST' }),
49 +};
50 +
51 +/** Turn an admin fetch failure into a short message for the UI. */
52 +export function adminErrorMessage(e: unknown): string {
53 + if (e instanceof ApiError) return `${e.status || 'network'} — ${e.problem?.detail ?? e.problem?.title ?? e.message}`;
54 + return e instanceof Error ? e.message : String(e);
55 +}
added apps/web/src/lib/admin-auth.ts +49 −0
@@ -0,0 +1,49 @@
1 +import 'server-only';
2 +import { createHash, timingSafeEqual } from 'node:crypto';
3 +import { cookies } from 'next/headers';
4 +
5 +/**
6 + * Admin gate. The browser never sees the token: the login form posts it to a server action which compares it
7 + * (constant time) with `process.env.CA_ADMIN_TOKEN` and sets an httpOnly cookie holding a SHA-256 of the
8 + * token. Pages check the cookie (again constant time) and server actions call the API with the real token.
9 + */
10 +export const ADMIN_COOKIE = 'ca_admin';
11 +export const ADMIN_COOKIE_MAX_AGE = 12 * 3600;
12 +
13 +export function adminToken(): string | null {
14 + const v = process.env.CA_ADMIN_TOKEN?.trim();
15 + return v ? v : null;
16 +}
17 +
18 +export function isAdminConfigured(): boolean {
19 + return adminToken() !== null;
20 +}
21 +
22 +function sha256(s: string): Buffer {
23 + return createHash('sha256').update(s, 'utf8').digest();
24 +}
25 +
26 +/** Constant-time comparison of two strings (hashed first so lengths never leak). */
27 +export function safeEqual(a: string, b: string): boolean {
28 + return timingSafeEqual(sha256(a), sha256(b));
29 +}
30 +
31 +/** Cookie value = sha256(token) hex, salted with a fixed context string so it is never equal to the token. */
32 +export function cookieValueFor(token: string): string {
33 + return createHash('sha256').update(`countryatlas-admin:${token}`, 'utf8').digest('hex');
34 +}
35 +
36 +export function verifyToken(input: string): boolean {
37 + const expected = adminToken();
38 + if (!expected) return false;
39 + return safeEqual(input, expected);
40 +}
41 +
42 +export async function isAdminAuthed(): Promise<boolean> {
43 + const expected = adminToken();
44 + if (!expected) return false;
45 + const store = await cookies();
46 + const c = store.get(ADMIN_COOKIE)?.value;
47 + if (!c) return false;
48 + return safeEqual(c, cookieValueFor(expected));
49 +}
added apps/web/src/lib/api-compare.ts +31 −0
@@ -0,0 +1,31 @@
1 +import 'server-only';
2 +import { request } from './api';
3 +import type { MapResponse, RankingResponse } from './types';
4 +import type { CompareMode, CompareResponse, CompareSnapshotResponse, IndicatorsResponse, RankHistoryResponse, RankingsListResponse, RegionsResponse } from './types-compare';
5 +
6 +/**
7 + * Server-side endpoints for the compare / rankings pages (same `request<T>()` wrapper as `lib/api.ts`,
8 + * kept in a separate module so the two page groups can be developed concurrently).
9 + * The API caps `/compare` at 8 countries × 8 indicators per call; `/compare/snapshot` at 8 × 40.
10 + */
11 +export const apiCompare = {
12 + compare: (countries: string[], indicators: string[], q: { from?: number | null; to?: number | null; mode?: CompareMode; include_forecast?: boolean } = {}) =>
13 + request<CompareResponse>('/compare', { countries: countries.join(','), indicators: indicators.slice(0, 8).join(','), from: q.from ?? undefined, to: q.to ?? undefined, mode: q.mode ?? 'absolute', include_forecast: q.include_forecast }),
14 +
15 + snapshot: (countries: string[], q: { topic?: string | null; indicators?: string[] | null } = {}) =>
16 + request<CompareSnapshotResponse>('/compare/snapshot', { countries: countries.join(','), topic: q.indicators?.length ? undefined : q.topic ?? undefined, indicators: q.indicators?.length ? q.indicators.join(',') : undefined }),
17 +
18 + rankings: (topic?: string) => request<RankingsListResponse>('/rankings', { topic }),
19 +
20 + ranking: (slug: string, q: { year?: number | null; group?: string | null; sort?: 'asc' | 'desc' | null; limit?: number; offset?: number; sparkline?: boolean } = {}) =>
21 + request<RankingResponse>(`/rankings/${encodeURIComponent(slug)}`, { year: q.year ?? undefined, group: q.group ?? undefined, sort: q.sort ?? undefined, limit: q.limit ?? 300, offset: q.offset, sparkline: q.sparkline ?? true }),
22 +
23 + rankHistory: (slug: string, countries: string[], q: { from?: number; to?: number } = {}) =>
24 + request<RankHistoryResponse>(`/rankings/${encodeURIComponent(slug)}/history`, { countries: countries.join(','), ...q }),
25 +
26 + indicatorMap: (slug: string, q: { year?: number | null; nearest?: boolean } = {}) => request<MapResponse>(`/indicators/${encodeURIComponent(slug)}/map`, { year: q.year ?? undefined, nearest: q.nearest }),
27 +
28 + indicators: (q: { topic?: string; q?: string; featured?: boolean } = {}) => request<IndicatorsResponse>('/indicators', q),
29 +
30 + regions: (kind?: string) => request<RegionsResponse>('/regions', { kind }),
31 +};
added apps/web/src/lib/api-explore.ts +47 −0
@@ -0,0 +1,47 @@
1 +import 'server-only';
2 +import { request } from './api';
3 +import type { MultiSeriesResponse } from './types';
4 +import type {
5 + ChangesFeedResponse,
6 + IndicatorResponse,
7 + IndicatorsResponse,
8 + MethodologyResponse,
9 + RegionResponse,
10 + RegionsResponse,
11 + SourceResponse,
12 + SourcesResponse,
13 + TrendResponse,
14 +} from './types-explore';
15 +
16 +/**
17 + * Server-side endpoints for the indicators / regions / explore / changes / sources / methodology pages.
18 + * Same conventions as `lib/api.ts` (`request<T>` → ApiError, ISR 900 s). Kept in its own file because two
19 + * agents extend the API surface concurrently.
20 + */
21 +export const apiExplore = {
22 + indicators: (q: { topic?: string; q?: string; featured?: boolean; source?: string; with_data?: boolean } = {}) => request<IndicatorsResponse>('/indicators', q),
23 +
24 + indicator: (slug: string) => request<IndicatorResponse>(`/indicators/${encodeURIComponent(slug)}`),
25 +
26 + indicatorTrend: (slug: string, group = 'world', q: { from?: number; to?: number; min_n?: number } = {}) =>
27 + request<TrendResponse>(`/indicators/${encodeURIComponent(slug)}/trend`, { group, ...q }),
28 +
29 + /** `/series?country=A,B&indicator=x,y` bundle (max 20 countries × 12 indicators). */
30 + seriesBundle: (countries: string[], indicators: string[], q: { from?: number; to?: number; include_forecast?: boolean } = {}) =>
31 + request<MultiSeriesResponse>('/series', { country: countries.join(','), indicator: indicators.join(','), ...q }),
32 +
33 + regions: (kind?: string) => request<RegionsResponse>('/regions', { kind }),
34 +
35 + region: (slug: string, q: { indicator?: string; sort?: 'asc' | 'desc' } = {}) => request<RegionResponse>(`/regions/${encodeURIComponent(slug)}`, q),
36 +
37 + changes: (q: { limit?: number; offset?: number; kind?: string; indicator?: string; country?: string; topic?: string; min_severity?: number } = {}) =>
38 + request<ChangesFeedResponse>('/changes', q),
39 +
40 + sources: () => request<SourcesResponse>('/sources'),
41 +
42 + source: (id: string, runsLimit = 30) => request<SourceResponse>(`/sources/${encodeURIComponent(id)}`, { runs_limit: runsLimit }),
43 +
44 + methodology: () => request<MethodologyResponse>('/methodology', undefined, { revalidate: 3600 }),
45 +};
46 +
47 +export type ApiExplore = typeof apiExplore;
added apps/web/src/lib/client-api-compare.ts +38 −0
@@ -0,0 +1,38 @@
1 +'use client';
2 +import type { RankingResponse } from './types';
3 +import type { CompareMode, CompareResponse, IndicatorsResponse, RankHistoryResponse } from './types-compare';
4 +
5 +/** Browser-side fetches for the compare / rankings pages (same-origin `/api/v1`, rewritten to the API). */
6 +export class ClientCompareError extends Error {
7 + constructor(
8 + readonly status: number,
9 + message: string,
10 + ) {
11 + super(message);
12 + this.name = 'ClientCompareError';
13 + }
14 +}
15 +
16 +async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
17 + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal });
18 + if (!res.ok) throw new ClientCompareError(res.status, `API ${res.status}`);
19 + return (await res.json()) as T;
20 +}
21 +
22 +function qs(obj: Record<string, string | number | boolean | null | undefined>): string {
23 + const p = new URLSearchParams();
24 + for (const [k, v] of Object.entries(obj)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
25 + const s = p.toString();
26 + return s ? `?${s}` : '';
27 +}
28 +
29 +export const clientCompare = {
30 + compare: (countries: string[], indicators: string[], q: { from?: number | null; to?: number | null; mode?: CompareMode } = {}, signal?: AbortSignal) =>
31 + get<CompareResponse>(`/compare${qs({ countries: countries.join(','), indicators: indicators.join(','), from: q.from, to: q.to, mode: q.mode ?? 'absolute' })}`, signal),
32 +
33 + indicators: (signal?: AbortSignal) => get<IndicatorsResponse>('/indicators', signal),
34 +
35 + rankingTop: (slug: string, limit = 3, signal?: AbortSignal) => get<RankingResponse>(`/rankings/${encodeURIComponent(slug)}${qs({ limit, sparkline: false })}`, signal),
36 +
37 + rankHistory: (slug: string, countries: string[], signal?: AbortSignal) => get<RankHistoryResponse>(`/rankings/${encodeURIComponent(slug)}/history${qs({ countries: countries.join(',') })}`, signal),
38 +};
added apps/web/src/lib/client-api-explore.ts +41 −0
@@ -0,0 +1,41 @@
1 +'use client';
2 +import { ClientApiError } from './client-api';
3 +import type { MapResponse, MultiSeriesResponse, RankingResponse, SearchResponse, SimilarResponse } from './types';
4 +import type { ChangesFeedResponse, TrendResponse } from './types-explore';
5 +
6 +/**
7 + * Browser-side fetches used by the interactive widgets of the indicator / region / explore / changes pages.
8 + * Same-origin `/api/v1/*` (rewritten to the FastAPI service). Small and typed; server components do the
9 + * first render, these only react to user input.
10 + */
11 +async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
12 + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal });
13 + if (!res.ok) throw new ClientApiError(res.status, `API ${res.status}`);
14 + return (await res.json()) as T;
15 +}
16 +
17 +function qs(query: Record<string, string | number | boolean | null | undefined>): string {
18 + const p = new URLSearchParams();
19 + for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
20 + const s = p.toString();
21 + return s ? `?${s}` : '';
22 +}
23 +
24 +export const clientExplore = {
25 + indicatorMap: (slug: string, year: number | null, signal?: AbortSignal) => get<MapResponse>(`/indicators/${encodeURIComponent(slug)}/map${qs({ year })}`, signal),
26 +
27 + indicatorTrend: (slug: string, group: string, signal?: AbortSignal) => get<TrendResponse>(`/indicators/${encodeURIComponent(slug)}/trend${qs({ group })}`, signal),
28 +
29 + seriesBundle: (countries: string[], indicator: string, signal?: AbortSignal) =>
30 + get<MultiSeriesResponse>(`/series${qs({ country: countries.join(','), indicator })}`, signal),
31 +
32 + ranking: (indicator: string, group: string, limit = 60, signal?: AbortSignal) =>
33 + get<RankingResponse>(`/rankings/${encodeURIComponent(indicator)}${qs({ group, limit, sparkline: false })}`, signal),
34 +
35 + changes: (q: { limit?: number; kind?: string | null; indicator?: string | null; topic?: string | null; min_severity?: number | null }, signal?: AbortSignal) =>
36 + get<ChangesFeedResponse>(`/changes${qs({ limit: 100, ...q })}`, signal),
37 +
38 + search: (q: string, type?: string, limit = 8, signal?: AbortSignal) => get<SearchResponse>(`/search${qs({ q, type, limit })}`, signal),
39 +
40 + similar: (id: string, mode: string, limit = 10, signal?: AbortSignal) => get<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar${qs({ mode, limit })}`, signal),
41 +};
added apps/web/src/lib/compare-presets.ts +15 −0
@@ -0,0 +1,15 @@
1 +/** Preset comparisons for the /compare builder (slugs from the registry; membership as of 2026). */
2 +export interface ComparePreset {
3 + id: string;
4 + /** i18n key suffix → `compare.preset.<id>` */
5 + slugs: string[];
6 +}
7 +
8 +export const COMPARE_GROUP_PRESETS: readonly ComparePreset[] = [
9 + { id: 'g7', slugs: ['canada', 'france', 'germany', 'italy', 'japan', 'united-kingdom', 'united-states'] },
10 + { id: 'nordics', slugs: ['denmark', 'finland', 'iceland', 'norway', 'sweden'] },
11 + { id: 'brics', slugs: ['brazil', 'russia', 'india', 'china', 'south-africa'] },
12 + { id: 'usmca', slugs: ['canada', 'mexico', 'united-states'] },
13 + { id: 'anglosphere', slugs: ['australia', 'canada', 'new-zealand', 'united-kingdom', 'united-states'] },
14 + { id: 'asian-tigers', slugs: ['hong-kong', 'singapore', 'south-korea', 'taiwan'] },
15 +];
added apps/web/src/lib/compare-state.ts +107 −0
@@ -0,0 +1,107 @@
1 +import { COMPARE_MODES, type CompareMode } from './types-compare';
2 +import type { TopicId } from './types';
3 +
4 +/**
5 + * URL contract of `/compare/[...slugs]` — every piece of view state lives in the query string so a view is
6 + * shareable and survives a reload:
7 + *
8 + * /compare/canada/united-states/france
9 + * ?tab=snapshot|economy|population|health|housing|energy|climate|digital|custom (default snapshot)
10 + * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab)
11 + * &indicators=a,b,c custom tab only, ≤ 6 slugs
12 + * &from=1990&to=2025 year range (omitted = full history)
13 + * &mode=absolute|per-capita|index100|pct (default absolute)
14 + * &log=1 log y-scale (charts only)
15 + *
16 + * Canonical URL = path + `tab` (when not snapshot); everything else is volatile.
17 + */
18 +export const COMPARE_TOPIC_TABS = ['economy', 'population', 'health', 'housing', 'energy', 'climate', 'digital'] as const satisfies readonly TopicId[];
19 +export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number];
20 +export type CompareTab = 'snapshot' | CompareTopicTab | 'custom';
21 +export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom'];
22 +
23 +export const MAX_COMPARE_COUNTRIES = 8;
24 +export const MIN_COMPARE_COUNTRIES = 2;
25 +export const MAX_CUSTOM_INDICATORS = 6;
26 +
27 +export interface CompareState {
28 + tab: CompareTab;
29 + indicator: string | null;
30 + indicators: string[];
31 + from: number | null;
32 + to: number | null;
33 + mode: CompareMode;
34 + log: boolean;
35 +}
36 +
37 +export const DEFAULT_COMPARE_STATE: CompareState = { tab: 'snapshot', indicator: null, indicators: [], from: null, to: null, mode: 'absolute', log: false };
38 +
39 +type ParamsLike = { get(name: string): string | null } | Record<string, string | string[] | undefined>;
40 +
41 +function read(params: ParamsLike, key: string): string | null {
42 + if (typeof (params as { get?: unknown }).get === 'function') return (params as { get(name: string): string | null }).get(key);
43 + const v = (params as Record<string, string | string[] | undefined>)[key];
44 + return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
45 +}
46 +
47 +function yearOf(v: string | null): number | null {
48 + if (!v) return null;
49 + const n = Number(v);
50 + return Number.isInteger(n) && n >= 1800 && n <= 2100 ? n : null;
51 +}
52 +
53 +const SLUG = /^[a-z0-9][a-z0-9-]*$/;
54 +
55 +export function isCompareTab(v: string | null | undefined): v is CompareTab {
56 + return !!v && (COMPARE_TABS as readonly string[]).includes(v);
57 +}
58 +
59 +/** Parse from `useSearchParams()` (client) or the `searchParams` prop (server). Invalid values fall back silently. */
60 +export function parseCompareState(params: ParamsLike): CompareState {
61 + const tabRaw = read(params, 'tab');
62 + const indicator = (read(params, 'indicator') ?? '').toLowerCase();
63 + const indicators = (read(params, 'indicators') ?? '')
64 + .split(',')
65 + .map((s) => s.trim().toLowerCase())
66 + .filter((s) => SLUG.test(s))
67 + .slice(0, MAX_CUSTOM_INDICATORS);
68 + let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot';
69 + if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab;
70 + const modeRaw = read(params, 'mode');
71 + const mode = (COMPARE_MODES as readonly string[]).includes(modeRaw ?? '') ? (modeRaw as CompareMode) : 'absolute';
72 + let from = yearOf(read(params, 'from'));
73 + let to = yearOf(read(params, 'to'));
74 + if (from != null && to != null && from > to) [from, to] = [to, from];
75 + return { tab, indicator: SLUG.test(indicator) ? indicator : null, indicators, from, to, mode, log: read(params, 'log') === '1' };
76 +}
77 +
78 +/** Serialize back to a query string (empty string when everything is default). Keys are emitted in a stable order. */
79 +export function compareQuery(state: Partial<CompareState>): string {
80 + const p = new URLSearchParams();
81 + if (state.tab && state.tab !== 'snapshot') p.set('tab', state.tab);
82 + if (state.indicator) p.set('indicator', state.indicator);
83 + if (state.indicators?.length) p.set('indicators', state.indicators.join(','));
84 + if (state.from != null) p.set('from', String(state.from));
85 + if (state.to != null) p.set('to', String(state.to));
86 + if (state.mode && state.mode !== 'absolute') p.set('mode', state.mode);
87 + if (state.log) p.set('log', '1');
88 + const s = p.toString();
89 + return s ? `?${s}` : '';
90 +}
91 +
92 +/** Canonical (indexable) query: only the tab. */
93 +export function compareCanonicalQuery(state: CompareState): string {
94 + return state.tab !== 'snapshot' && state.tab !== 'custom' ? `?tab=${state.tab}` : '';
95 +}
96 +
97 +/** Split `/compare/a/b/c` segments into clean slugs (also accepts `a,b,c` in one segment and ISO3 codes). */
98 +export function splitCompareSlugs(segments: string[]): string[] {
99 + const out: string[] = [];
100 + for (const seg of segments) {
101 + for (const part of decodeURIComponent(seg).split(',')) {
102 + const s = part.trim().toLowerCase();
103 + if (s && /^[a-z0-9-]+$/.test(s) && !out.includes(s)) out.push(s);
104 + }
105 + }
106 + return out.slice(0, MAX_COMPARE_COUNTRIES);
107 +}
added apps/web/src/lib/ranking-state.ts +50 −0
@@ -0,0 +1,50 @@
1 +/**
2 + * URL contract of `/rankings/[indicator]`:
3 + * ?year=2020 ranking year (default: latest; the API falls back to the nearest available year)
4 + * &group=oecd group slug (world · regions · continents · income groups · organisations); default world
5 + * &sort=asc|desc default = the API's ("asc" when lower is better, else "desc")
6 + * &highlight=canada country slug pinned at the top of the list
7 + * &q=fra text filter within the ranking
8 + * &history=CAN,USA,FRA countries of the rank-over-time panel (≤ 5 ISO3)
9 + * Canonical URL = path + `group` (when not world); `year`, `sort`, `highlight`, `q`, `history` are volatile.
10 + */
11 +export interface RankingState {
12 + year: number | null;
13 + group: string;
14 + sort: 'asc' | 'desc' | null;
15 + highlight: string | null;
16 + q: string;
17 + history: string[];
18 +}
19 +
20 +type ParamsLike = { get(name: string): string | null } | Record<string, string | string[] | undefined>;
21 +function read(params: ParamsLike, key: string): string | null {
22 + if (typeof (params as { get?: unknown }).get === 'function') return (params as { get(name: string): string | null }).get(key);
23 + const v = (params as Record<string, string | string[] | undefined>)[key];
24 + return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
25 +}
26 +
27 +export function parseRankingState(params: ParamsLike): RankingState {
28 + const y = Number(read(params, 'year'));
29 + const sort = read(params, 'sort');
30 + const group = (read(params, 'group') ?? 'world').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'world';
31 + const highlight = (read(params, 'highlight') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null;
32 + const history = (read(params, 'history') ?? '')
33 + .split(',')
34 + .map((s) => s.trim().toUpperCase())
35 + .filter((s) => /^[A-Z]{3}$/.test(s))
36 + .slice(0, 5);
37 + return { year: Number.isInteger(y) && y >= 1800 && y <= 2100 ? y : null, group, sort: sort === 'asc' || sort === 'desc' ? sort : null, highlight, q: (read(params, 'q') ?? '').slice(0, 60), history };
38 +}
39 +
40 +export function rankingQuery(state: Partial<RankingState>): string {
41 + const p = new URLSearchParams();
42 + if (state.year != null) p.set('year', String(state.year));
43 + if (state.group && state.group !== 'world') p.set('group', state.group);
44 + if (state.sort) p.set('sort', state.sort);
45 + if (state.highlight) p.set('highlight', state.highlight);
46 + if (state.q) p.set('q', state.q);
47 + if (state.history?.length) p.set('history', state.history.join(','));
48 + const s = p.toString();
49 + return s ? `?${s}` : '';
50 +}
modified apps/web/src/lib/site.ts +14 −1
@@ -14,8 +14,17 @@ export const routes = {
14 14 countryIndicator: (slug: string, topic: string, indicator: string) => `/countries/${slug}/${topic}#${indicator}`,
15 15 countryDownload: (id: string, fmt: 'csv' | 'json' = 'csv') => `/api/v1/countries/${id}/download.${fmt}`,
16 16 compare: (...slugs: string[]) => (slugs.length ? `/compare/${slugs.join('/')}` : '/compare'),
17 + compareOg: (slugs: string[]) => `/compare/og?slugs=${slugs.join(',')}`,
18 + compareDownload: (ids: string[], indicators: string[], q: { from?: number | null; to?: number | null } = {}, fmt: 'csv' | 'json' = 'csv') =>
19 + `/api/v1/compare/download.${fmt}?countries=${ids.join(',')}&indicators=${indicators.join(',')}${q.from != null ? `&from=${q.from}` : ''}${q.to != null ? `&to=${q.to}` : ''}`,
17 20 rankings: () => '/rankings',
18 − ranking: (indicator: string) => `/rankings/${indicator}`,
21 + ranking: (indicator: string, q?: { year?: number | null; group?: string | null }) => {
22 + const p = new URLSearchParams();
23 + if (q?.year != null) p.set('year', String(q.year));
24 + if (q?.group && q.group !== 'world') p.set('group', q.group);
25 + const s = p.toString();
26 + return `/rankings/${indicator}${s ? `?${s}` : ''}`;
27 + },
19 28 indicators: (topic?: string) => (topic ? `/indicators?topic=${encodeURIComponent(topic)}` : '/indicators'),
20 29 indicator: (slug: string) => `/indicators/${slug}`,
21 30 indicatorDownload: (slug: string, fmt: 'csv' | 'json' = 'csv') => `/api/v1/indicators/${slug}/download.${fmt}`,
@@ -29,4 +38,8 @@ export const routes = {
29 38 changes: () => '/changes',
30 39 explore: () => '/explore',
31 40 search: (q: string) => `/search?q=${encodeURIComponent(q)}`,
41 + apiSwagger: () => '/api/v1/docs',
42 + apiOpenapi: () => '/api/v1/openapi.json',
43 + admin: (section?: 'coverage' | 'runs' | 'issues' | 'raw') => (section ? `/admin/${section}` : '/admin'),
44 + adminLogin: () => '/admin/login',
32 45 } as const;
added apps/web/src/lib/types-compare.ts +124 −0
@@ -0,0 +1,124 @@
1 +/**
2 + * TypeScript mirror of the compare / rankings / regions / indicators response models in
3 + * `src/countryatlas/api/schemas.py` (CompareResponse, CompareSnapshotResponse, RankingsListResponse,
4 + * RankHistoryResponse, IndicatorsResponse, RegionsResponse). Kept in its own file so the compare / rankings
5 + * pages and the indicators / regions pages can be built concurrently without touching `types.ts`.
6 + */
7 +import type { CountryCard, IndicatorCard, IndicatorSummary, Meta, MetricValue, Series, TopicId } from './types';
8 +
9 +// ---------------------------------------------------------------------------------------------- compare
10 +
11 +/** `mode` query of `/compare` (docs/API.md § Compare). */
12 +export type CompareMode = 'absolute' | 'per-capita' | 'index100' | 'pct';
13 +export const COMPARE_MODES: readonly CompareMode[] = ['absolute', 'per-capita', 'index100', 'pct'];
14 +
15 +/** Transformation echo added by the router on every series (`extra="allow"`). */
16 +export interface CompareTransform {
17 + mode: CompareMode | string;
18 + base_year?: number | null;
19 + applied: boolean;
20 + unit?: string | null;
21 +}
22 +
23 +export interface CompareSeries extends Series {
24 + transform?: CompareTransform;
25 +}
26 +
27 +export interface CompareResponse {
28 + meta: Meta;
29 + mode: CompareMode | string;
30 + base_year: number | null;
31 + countries: CountryCard[];
32 + indicators: IndicatorCard[];
33 + series: CompareSeries[];
34 +}
35 +
36 +/** One row of `/compare/snapshot`: the indicator, its latest value per country (ISO3 → MetricValue), the best ISO3. */
37 +export interface CompareSnapshotRow {
38 + indicator: IndicatorCard;
39 + values: Record<string, MetricValue>;
40 + /** ISO3 of the best value when `higher_is_better` is known, else null. */
41 + best: string | null;
42 +}
43 +
44 +export interface CompareSnapshotResponse {
45 + meta: Meta;
46 + topic: { id: TopicId | string; name: string; short: string | null; blurb: string | null } | null;
47 + countries: CountryCard[];
48 + rows: CompareSnapshotRow[];
49 +}
50 +
51 +// ---------------------------------------------------------------------------------------------- rankings
52 +
53 +/** `/rankings` item — an IndicatorSummary enriched with the latest ranking year and its size. */
54 +export interface RankableIndicator extends IndicatorSummary {
55 + ranking_year: number | null;
56 + ranking_n: number | null;
57 +}
58 +
59 +export interface RankingsListResponse {
60 + meta: Meta;
61 + n: number;
62 + items: RankableIndicator[];
63 +}
64 +
65 +export interface RankHistoryPoint {
66 + year: number;
67 + rank: number | null;
68 + n: number | null;
69 + value: number | null;
70 + pct_rank: number | null;
71 +}
72 +
73 +export interface RankHistoryResponse {
74 + meta: Meta;
75 + indicator: IndicatorCard;
76 + countries: CountryCard[];
77 + years: number[];
78 + /** ISO3 → points (one per year with a rank). */
79 + series: Record<string, RankHistoryPoint[]>;
80 +}
81 +
82 +// ---------------------------------------------------------------------------------------------- indicators list / regions list
83 +
84 +export interface IndicatorsResponse {
85 + meta: Meta;
86 + n: number;
87 + filters: { topic: string | null; q: string | null; featured: boolean | null; source: string | null };
88 + items: IndicatorSummary[];
89 +}
90 +
91 +/** `/regions` item (schemas.RegionsResponse.items is `dict[str, Any]`; these are the keys the router emits). */
92 +export interface RegionItem {
93 + id: string;
94 + slug: string;
95 + name: string;
96 + kind: 'world' | 'region' | 'continent' | 'income' | 'org' | string;
97 + wb_code: string | null;
98 + n_members: number | null;
99 + description: string | null;
100 + population_latest: number | null;
101 + gdp_latest: number | null;
102 +}
103 +
104 +export interface RegionsResponse {
105 + meta: Meta;
106 + n: number;
107 + items: RegionItem[];
108 +}
109 +
110 +// ---------------------------------------------------------------------------------------------- UI-side helpers
111 +
112 +/** Minimal country reference passed to client components (kept small: the full CountrySummary list is ~60 kB). */
113 +export interface CountryLite {
114 + id: string;
115 + slug: string;
116 + name: string;
117 + flag: string | null;
118 + region: string | null;
119 + income: string | null;
120 +}
121 +
122 +export function toCountryLite(c: CountryCard): CountryLite {
123 + return { id: c.id, slug: c.slug ?? c.id.toLowerCase(), name: c.name ?? c.id, flag: c.flag, region: c.region, income: c.income };
124 +}
added apps/web/src/lib/types-explore.ts +394 −0
@@ -0,0 +1,394 @@
1 +/**
2 + * TypeScript mirror of the API models used by the Indicators / Regions / Explore / Changes / Data / Sources /
3 + * Methodology / API / Admin pages (schemas.py + the routers' actual payloads where pydantic says `dict[str, Any]`:
4 + * regions.py, sources.py, methodology.py, admin.py). Aligned 2026-09-11. Base cards live in `./types`.
5 + */
6 +import type { ChangeItem, CountryCard, Freshness, GroupCard, IndicatorCard, IndicatorSummary, Meta, Provenance } from './types';
7 +
8 +// ---------------------------------------------------------------------------------------------- indicators
9 +
10 +export interface IndicatorsResponse {
11 + meta: Meta;
12 + n: number;
13 + filters: { topic: string | null; q: string | null; featured: boolean | null; source: string | null };
14 + items: IndicatorSummary[];
15 +}
16 +
17 +/** One `indicator_sources` row (priority order). */
18 +export interface IndicatorSource {
19 + source_id: string;
20 + source_name: string | null;
21 + dataset: string | null;
22 + series_code: string | null;
23 + priority: number | null;
24 + transform: string | null;
25 + countries: string[] | null;
26 + notes: string | null;
27 + url: string | null;
28 + licence: string | null;
29 + n_observations: number | null;
30 + n_countries: number | null;
31 + last_year: number | null;
32 + last_status: string | null;
33 + params?: string | null;
34 +}
35 +
36 +export interface WorldLatest {
37 + kind: 'sum' | 'weighted_mean' | 'median' | string;
38 + value: number | null;
39 + formatted: string | null;
40 + year: number | null;
41 + n: number | null;
42 + median: number | null;
43 + weighted_mean: number | null;
44 + mean: number | null;
45 + sum: number | null;
46 + weights: string | null;
47 + note?: string | null;
48 +}
49 +
50 +export interface RankedValue {
51 + country: CountryCard;
52 + value: number | null;
53 + formatted: string | null;
54 + year: number | null;
55 + rank: number | null;
56 + provenance: Provenance | null;
57 +}
58 +
59 +export interface IndicatorDetail extends IndicatorSummary {
60 + methodology: string | null;
61 + bounds: [number | null, number | null] | null;
62 + scale: string | null;
63 + per_capita_of: string | null;
64 + source_priority: string[] | null;
65 +}
66 +
67 +export interface IndicatorCoverage {
68 + n_countries: number | null;
69 + n_countries_total: number;
70 + coverage_pct: number;
71 + n_observations: number | null;
72 + by_year: Array<{ year: number; n: number }>;
73 +}
74 +
75 +export interface IndicatorResponse {
76 + meta: Meta;
77 + indicator: IndicatorDetail;
78 + sources: IndicatorSource[];
79 + coverage: IndicatorCoverage;
80 + world_latest: WorldLatest | null;
81 + freshness: Freshness;
82 + top5: RankedValue[];
83 + bottom5: RankedValue[];
84 + years: { first: number | null; last: number | null; last_actual: number | null; latest_common: number | null };
85 + topics: string[];
86 +}
87 +
88 +export interface TrendPoint {
89 + year: number;
90 + median: number | null;
91 + mean: number | null;
92 + weighted_mean: number | null;
93 + sum: number | null;
94 + n: number;
95 +}
96 +
97 +export interface TrendResponse {
98 + meta: Meta;
99 + indicator: IndicatorCard;
100 + group: GroupCard;
101 + preferred: 'sum' | 'weighted_mean' | 'median' | string;
102 + weights: string | null;
103 + points: TrendPoint[];
104 + provenance: Provenance[];
105 + note?: string | null;
106 +}
107 +
108 +// ---------------------------------------------------------------------------------------------- regions
109 +
110 +export interface RegionItem extends GroupCard {
111 + description: string | null;
112 + population_latest: number | null;
113 + gdp_latest: number | null;
114 +}
115 +
116 +export interface RegionsResponse {
117 + meta: Meta;
118 + n: number;
119 + items: RegionItem[];
120 +}
121 +
122 +export interface RegionAggregate {
123 + indicator: IndicatorCard;
124 + kind: 'sum' | 'median' | 'weighted_mean' | string;
125 + label: string;
126 + value: number | null;
127 + formatted: string | null;
128 + n: number | null;
129 + year: number | null;
130 +}
131 +
132 +export interface MemberValue {
133 + value: number | null;
134 + formatted: string | null;
135 + year: number | null;
136 + rank_world: number | null;
137 + n_world: number | null;
138 + provenance: Provenance | null;
139 +}
140 +
141 +export interface RegionMember extends CountryCard {
142 + values: Record<string, MemberValue | null>;
143 +}
144 +
145 +export interface RegionRankingRow {
146 + rank: number;
147 + country: CountryCard;
148 + value: number | null;
149 + formatted: string | null;
150 + year: number | null;
151 + rank_world: number | null;
152 + n_world: number | null;
153 + change_pct: number | null;
154 + change_abs: number | null;
155 + provenance: Provenance | null;
156 +}
157 +
158 +export interface RegionResponse {
159 + meta: Meta;
160 + group: GroupCard;
161 + description: string | null;
162 + n_members: number;
163 + aggregates: Record<string, RegionAggregate>;
164 + headline_indicators: IndicatorCard[];
165 + members: RegionMember[];
166 + ranking: { indicator: IndicatorCard; sort: 'asc' | 'desc' | string; n: number; rows: RegionRankingRow[] };
167 +}
168 +
169 +// ---------------------------------------------------------------------------------------------- changes (global feed adds `kinds`)
170 +
171 +export interface ChangesFeedResponse {
172 + meta: Meta;
173 + n: number;
174 + kinds?: string[];
175 + items: ChangeItem[];
176 +}
177 +
178 +// ---------------------------------------------------------------------------------------------- sources
179 +
180 +export interface Source {
181 + id: string;
182 + name: string | null;
183 + organization: string | null;
184 + url: string | null;
185 + licence: string | null;
186 + attribution: string | null;
187 + api_base: string | null;
188 + last_success_at: string | null;
189 + n_indicators: number | null;
190 + n_observations: number | null;
191 + notes: string | null;
192 + /** List endpoint only. */
193 + last_retrieved_at?: string | null;
194 +}
195 +
196 +export interface SourcesResponse {
197 + meta: Meta;
198 + n: number;
199 + items: Source[];
200 +}
201 +
202 +export interface SourceIndicatorRow extends IndicatorCard {
203 + dataset: string | null;
204 + series_code: string | null;
205 + priority: number | null;
206 + transform: string | null;
207 + n_observations: number | null;
208 + n_countries: number | null;
209 + last_year: number | null;
210 + last_status: string | null;
211 + url: string | null;
212 +}
213 +
214 +export interface ImportRun {
215 + run_id: string | null;
216 + connector: string | null;
217 + dataset: string | null;
218 + started_at: string | null;
219 + finished_at: string | null;
220 + status: string | null;
221 + rows_raw: number | null;
222 + rows_norm: number | null;
223 + rows_valid: number | null;
224 + warnings: number | null;
225 + errors: number | null;
226 + message: string | null;
227 + raw_path: string | null;
228 +}
229 +
230 +export interface SourceDataset {
231 + dataset: string | null;
232 + n_observations: number | null;
233 + n_indicators: number | null;
234 + n_countries: number | null;
235 + first_year: number | null;
236 + last_year: number | null;
237 + retrieved_at: string | null;
238 +}
239 +
240 +export interface SourceResponse {
241 + meta: Meta;
242 + source: Source;
243 + indicators: SourceIndicatorRow[];
244 + datasets: SourceDataset[];
245 + import_runs: ImportRun[];
246 + freshness: Freshness;
247 +}
248 +
249 +// ---------------------------------------------------------------------------------------------- methodology
250 +
251 +export interface MethodologyResponse {
252 + meta: { built_at: string | null; run_id: string | null };
253 + principles: string[];
254 + topics: Array<{ id: string; name: string; order: number | null; blurb: string | null; n_indicators: number }>;
255 + headline_indicators: string[];
256 + indicators: {
257 + n: number;
258 + units: Array<{ unit: string; n: number }>;
259 + formats: Array<{ format: string; n: number }>;
260 + frequencies: Record<string, number>;
261 + aggregations: Record<string, number>;
262 + };
263 + sources: {
264 + connectors: string[];
265 + series_mapped_per_connector: Record<string, number>;
266 + priority_rule: string;
267 + urls: Record<string, string>;
268 + };
269 + periods: Record<string, string>;
270 + validation: {
271 + rules: Array<{ code: string; severity: string; text: string }>;
272 + stale_after_days: Record<string, number>;
273 + statuses: string[];
274 + };
275 + derived: {
276 + latest: string;
277 + rankings: string;
278 + changes: string;
279 + similarity: { modes: Record<string, string>; method: string };
280 + insights: string;
281 + country_dna: { dimensions: Array<{ id: string; label: string; indicators: string[] }>; method: string };
282 + world_aggregates: string;
283 + };
284 + formatting: Record<string, string>;
285 +}
286 +
287 +// ---------------------------------------------------------------------------------------------- admin
288 +
289 +export interface AdminConnectorRow {
290 + connector: string;
291 + n_runs: number;
292 + last_finished_at: string | null;
293 + last_status: string | null;
294 + n_ok: number;
295 + n_failed: number;
296 + rows_valid: number | null;
297 + warnings: number | null;
298 + errors: number | null;
299 +}
300 +
301 +export interface AdminSourceRow {
302 + source_id: string;
303 + n_observations: number;
304 + source_updated_at: string | null;
305 + retrieved_at: string | null;
306 + n_stale: number;
307 +}
308 +
309 +export interface AdminOverview {
310 + status: 'ok' | 'empty' | string;
311 + db: { path: string; exists: boolean; size_bytes: number | null };
312 + scheduler: { heartbeat: Record<string, unknown> | null; pid: number | null; alive: boolean | null };
313 + cache: Record<string, number>;
314 + settings: { data_dir: string; api_host: string; api_port: number; refresh_at: string };
315 + meta?: Record<string, unknown> & Meta;
316 + counts?: Record<string, number>;
317 + connectors?: AdminConnectorRow[];
318 + sources?: AdminSourceRow[];
319 + observation_statuses?: Record<string, number>;
320 +}
321 +
322 +export interface AdminRunsResponse {
323 + meta: Meta;
324 + n: number;
325 + items: ImportRun[];
326 +}
327 +
328 +export interface ValidationIssue {
329 + run_id?: string | null;
330 + connector: string | null;
331 + dataset?: string | null;
332 + indicator_id: string | null;
333 + country_id?: string | null;
334 + period?: string | null;
335 + code: string | null;
336 + severity: string | null;
337 + message?: string | null;
338 + value?: number | null;
339 + [key: string]: unknown;
340 +}
341 +
342 +export interface AdminIssuesResponse {
343 + meta: Meta;
344 + n: number;
345 + summary: Array<{ severity: string; code: string; n: number }>;
346 + items: ValidationIssue[];
347 +}
348 +
349 +export interface AdminCoverageIndicator {
350 + indicator_id: string;
351 + topic: string | null;
352 + primary_source_id: string | null;
353 + n_countries: number | null;
354 + n_observations: number | null;
355 + first_year: number | null;
356 + last_year: number | null;
357 + latest_source_updated_at: string | null;
358 + n_latest: number | null;
359 + latest_year: number | null;
360 +}
361 +
362 +export interface AdminCoverageCountry {
363 + id: string;
364 + short_name: string | null;
365 + n_indicators: number | null;
366 + n_observations: number | null;
367 + latest_year: number | null;
368 + coverage_pct: number | null;
369 +}
370 +
371 +export interface AdminCoverageResponse {
372 + meta: Meta;
373 + n_indicators: number;
374 + indicators: AdminCoverageIndicator[];
375 + countries: AdminCoverageCountry[];
376 +}
377 +
378 +export interface AdminRawFile {
379 + connector: string;
380 + dataset: string | null;
381 + path: string;
382 + size?: number;
383 + modified_at?: string;
384 + missing?: boolean;
385 +}
386 +
387 +export interface AdminRawResponse {
388 + meta: Meta;
389 + run_id: string;
390 + n_runs: number;
391 + runs: Array<{ connector: string; dataset: string | null; raw_path: string | null; status: string | null; rows_raw: number | null }>;
392 + n_files: number;
393 + files: AdminRawFile[];
394 +}
395