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