SPB Git

spb/countryatlas

Public

Fix .gitignore: anchor data/ to the repo root — apps/web/src/components/data and app/data were silently untracked

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 3c74ab4 Browse files

17 changed files +936 −1

modified .gitignore +1 −1
@@ -9,7 +9,7 @@ __pycache__/
9 *.pyc 9 *.pyc
10 .pytest_cache/ 10 .pytest_cache/
11 .ruff_cache/ 11 .ruff_cache/
12−data/ 12+/data/
13 *.duckdb 13 *.duckdb
14 *.duckdb.wal 14 *.duckdb.wal
15 logs/ 15 logs/
added apps/web/src/app/data/page.tsx +10 −0
@@ -0,0 +1,10 @@
1+import type { Metadata } from 'next';
2+import { permanentRedirect } from 'next/navigation';
3+import { routes } from '@/lib/site';
4+
5+export const metadata: Metadata = { robots: { index: false, follow: true } };
6+
7+/** /data moved to /download (dataset builder). Kept as a permanent redirect for old links. */
8+export default function DataPage() {
9+ permanentRedirect(routes.download());
10+}
added apps/web/src/components/data/bottom-sheet.tsx +116 −0
@@ -0,0 +1,116 @@
1+'use client';
2+import { X } from 'lucide-react';
3+import { useEffect, useRef, type ReactNode } from 'react';
4+import { t } from '@/i18n';
5+import { cn } from '@/lib/cn';
6+
7+/**
8+ * Sheet primitive on a native <dialog> (focus trap, Esc, inert background for free).
9+ * Mobile: bottom sheet with drag-to-close (swipe down ≥ 80 px). Desktop (md+): right-hand drawer
10+ * when `side="drawer"`, centred panel when `side="center"`.
11+ */
12+export function BottomSheet({
13+ open,
14+ onClose,
15+ title,
16+ children,
17+ side = 'drawer',
18+ labelledBy,
19+ className,
20+}: {
21+ open: boolean;
22+ onClose: () => void;
23+ title?: ReactNode;
24+ children: ReactNode;
25+ side?: 'drawer' | 'center' | 'full';
26+ labelledBy?: string;
27+ className?: string;
28+}) {
29+ const ref = useRef<HTMLDialogElement>(null);
30+ const startY = useRef<number | null>(null);
31+
32+ useEffect(() => {
33+ const d = ref.current;
34+ if (!d) return;
35+ if (open && !d.open) {
36+ d.showModal();
37+ document.documentElement.style.overflow = 'hidden';
38+ } else if (!open && d.open) {
39+ d.close();
40+ }
41+ return () => {
42+ document.documentElement.style.overflow = '';
43+ };
44+ }, [open]);
45+
46+ useEffect(() => {
47+ const d = ref.current;
48+ if (!d) return;
49+ const onCancel = (e: Event) => {
50+ e.preventDefault();
51+ onClose();
52+ };
53+ const onCloseEvt = () => {
54+ document.documentElement.style.overflow = '';
55+ if (open) onClose();
56+ };
57+ d.addEventListener('cancel', onCancel);
58+ d.addEventListener('close', onCloseEvt);
59+ return () => {
60+ d.removeEventListener('cancel', onCancel);
61+ d.removeEventListener('close', onCloseEvt);
62+ };
63+ }, [onClose, open]);
64+
65+ const onPointerDown = (e: React.PointerEvent) => {
66+ startY.current = e.clientY;
67+ };
68+ const onPointerUp = (e: React.PointerEvent) => {
69+ if (startY.current != null && e.clientY - startY.current > 80) onClose();
70+ startY.current = null;
71+ };
72+
73+ return (
74+ <dialog
75+ ref={ref}
76+ aria-labelledby={labelledBy}
77+ onClick={(e) => {
78+ if (e.target === e.currentTarget) onClose();
79+ }}
80+ className={cn(
81+ 'm-0 max-h-none max-w-none bg-transparent p-0 text-ink backdrop:bg-ink/40 backdrop:backdrop-blur-[1px]',
82+ 'fixed inset-0 h-full w-full',
83+ 'open:flex open:items-end open:justify-center',
84+ side === 'drawer' && 'md:open:items-stretch md:open:justify-end',
85+ side === 'center' && 'md:open:items-center',
86+ )}
87+ >
88+ {open ? (
89+ <div
90+ className={cn(
91+ 'animate-sheet relative flex w-full flex-col bg-surface shadow-sheet',
92+ 'max-h-[92dvh] rounded-t-md',
93+ side === 'drawer' && 'md:h-full md:max-h-none md:w-[440px] md:rounded-none md:border-l md:border-rule',
94+ side === 'center' && 'md:max-h-[85vh] md:w-[560px] md:rounded-md md:border md:border-rule',
95+ side === 'full' && 'h-[100dvh] max-h-none rounded-none md:h-auto md:max-h-[85vh] md:w-[640px] md:self-start md:mt-[8vh] md:rounded-md md:border md:border-rule',
96+ className,
97+ )}
98+ >
99+ <div
100+ className="flex items-center gap-3 border-b border-rule px-4 py-3 md:px-5"
101+ onPointerDown={onPointerDown}
102+ onPointerUp={onPointerUp}
103+ style={{ touchAction: 'none' }}
104+ >
105+ <span aria-hidden className="absolute left-1/2 top-1.5 h-1 w-10 -translate-x-1/2 rounded-full bg-rule-strong md:hidden" />
106+ <div className="min-w-0 flex-1 pt-1 text-sm font-semibold md:pt-0">{title}</div>
107+ <button type="button" onClick={onClose} className="tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2" aria-label={t('common.close')}>
108+ <X size={18} aria-hidden />
109+ </button>
110+ </div>
111+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6 pt-3 safe-bottom md:px-5">{children}</div>
112+ </div>
113+ ) : null}
114+ </dialog>
115+ );
116+}
added apps/web/src/components/data/change-chip.tsx +29 −0
@@ -0,0 +1,29 @@
1+import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
2+import { t } from '@/i18n';
3+import { cn } from '@/lib/cn';
4+import { formatChange, formatPeriod } from '@/lib/format';
5+import type { ChangeValue, FormatSpec } from '@/lib/types';
6+
7+/**
8+ * Signed change with ↑/↓ glyph and direction colour. Colour = direction × whether up is good
9+ * (`higher_is_better`): good → up colour, bad → down colour, unknown → neutral ink. Never colour alone.
10+ * Uses the API's `formatted` string when present, else lib/format.ts.
11+ */
12+export function ChangeChip({ change, spec, prevPeriod, className, showVs = true }: { change: ChangeValue | null | undefined; spec: FormatSpec; prevPeriod?: string | null; className?: string; showVs?: boolean }) {
13+ if (!change) return null;
14+ const c = formatChange(change.abs, change.pct, spec);
15+ if (!c) return null;
16+ const text = change.formatted ?? c.text;
17+ const hib = spec.higher_is_better;
18+ const tone = c.direction === 'flat' || hib == null ? 'neutral' : (c.direction === 'up') === hib ? 'good' : 'bad';
19+ const Icon = c.direction === 'up' ? ArrowUpRight : c.direction === 'down' ? ArrowDownRight : Minus;
20+ const label = c.direction === 'up' ? t('common.up') : c.direction === 'down' ? t('common.down') : t('common.flat');
21+ return (
22+ <span className={cn('tnum inline-flex items-center gap-0.5 text-xs', tone === 'good' && 'text-up', tone === 'bad' && 'text-down', tone === 'neutral' && 'text-ink-2', className)}>
23+ <Icon size={13} aria-hidden strokeWidth={2.25} />
24+ <span className="sr-only">{label} </span>
25+ <span className="font-medium">{text}</span>
26+ {showVs && prevPeriod ? <span className="ml-1 font-normal text-ink-3">{t('common.vs', { period: formatPeriod(prevPeriod, spec.frequency ?? 'A') })}</span> : null}
27+ </span>
28+ );
29+}
added apps/web/src/components/data/change-list.tsx +66 −0
@@ -0,0 +1,66 @@
1+import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, TrendingDown, TrendingUp, Trophy } from 'lucide-react';
2+import Link from 'next/link';
3+import { t, tOpt } from '@/i18n';
4+import { cn } from '@/lib/cn';
5+import { formatPeriod } from '@/lib/format';
6+import { severityLabel, severityLevel } from '@/lib/severity';
7+import { routes } from '@/lib/site';
8+import type { ChangeItem } from '@/lib/types';
9+
10+const ICON: Record<string, typeof ArrowUpRight> = {
11+ yoy_jump: ArrowUpRight,
12+ yoy_drop: ArrowDownRight,
13+ record_high: Trophy,
14+ record_low: AlertTriangle,
15+ n_year_high: TrendingUp,
16+ n_year_low: TrendingDown,
17+ sign_flip: Repeat,
18+ accelerating: TrendingUp,
19+ decelerating: TrendingDown,
20+};
21+
22+export function kindLabel(kind: string | null | undefined, windowYears?: number | null): string {
23+ if (!kind) return '';
24+ return tOpt(`change.kind.${kind}`, kind.replace(/_/g, ' '), { n: windowYears ?? 10 });
25+}
26+
27+/**
28+ * Feed of detected changes/events: kind icon + label, headline, country (optional), period, severity chip.
29+ * Server component. `showCountry` for the global feed; `compact` for the timeline.
30+ */
31+export function ChangeList({ items, showCountry = false, className, emptyText }: { items: ChangeItem[]; showCountry?: boolean; className?: string; emptyText?: string }) {
32+ if (items.length === 0) return <p className={cn('py-4 text-sm text-ink-3', className)}>{emptyText ?? t('country.changes.none')}</p>;
33+ return (
34+ <ol className={cn('divide-y divide-rule', className)}>
35+ {items.map((c, i) => {
36+ const Icon = ICON[c.kind ?? ''] ?? ArrowUpRight;
37+ const lvl = severityLevel(c.severity);
38+ const indSlug = (c.indicator as { slug?: string; id: string }).slug ?? c.indicator.id;
39+ const href = c.country?.slug ? routes.country(c.country.slug) : routes.indicator(indSlug);
40+ return (
41+ <li key={c.id ?? `${indSlug}-${i}`} className="flex gap-3 py-3">
42+ <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>
43+ <Icon size={15} />
44+ </span>
45+ <div className="min-w-0 flex-1">
46+ <div className="flex flex-wrap items-baseline gap-x-2 text-2xs text-ink-3">
47+ <span className="font-medium uppercase tracking-wide">{kindLabel(c.kind, c.window_years)}</span>
48+ {c.period ? <span className="tnum">{formatPeriod(c.period, 'A')}</span> : null}
49+ <span className={cn('rounded-xs px-1 py-px', lvl === 'high' ? 'bg-accent-soft text-accent' : 'bg-surface-2 text-ink-3')}>{severityLabel(c.severity)}</span>
50+ </div>
51+ <Link href={href} className="link-quiet mt-0.5 block text-sm leading-snug text-ink">
52+ {showCountry && c.country ? (
53+ <span className="mr-1.5 text-ink-2">
54+ <span aria-hidden>{c.country.flag} </span>
55+ {c.country.name}
56+ </span>
57+ ) : null}
58+ {c.headline ?? `${'name' in c.indicator ? c.indicator.name : indSlug}: ${c.formatted ?? ''}`}
59+ </Link>
60+ </div>
61+ </li>
62+ );
63+ })}
64+ </ol>
65+ );
66+}
added apps/web/src/components/data/collapsible-group.tsx +59 −0
@@ -0,0 +1,59 @@
1+'use client';
2+import { ChevronDown } from 'lucide-react';
3+import { useEffect, useState, type ReactNode } from 'react';
4+import { t } from '@/i18n';
5+import { cn } from '@/lib/cn';
6+
7+/**
8+ * A subtopic block on long chart pages (compare topic tabs, country topic pages). Collapsed blocks keep the
9+ * information visible through `summary` (a compact table of latest values, no charts); expanded blocks mount
10+ * `children` (the charts, which lazy-fetch on scroll). A `#id` hash in the URL (jump nav) opens the block.
11+ * Heading level 3; `id` is the anchor used by `SubtopicJumpNav`.
12+ */
13+export function CollapsibleGroup({ id, title, count, defaultOpen = false, summary, children, className }: { id: string; title: ReactNode; count: number; defaultOpen?: boolean; summary: ReactNode; children: ReactNode; className?: string }) {
14+ const [open, setOpen] = useState(defaultOpen);
15+ useEffect(() => {
16+ const check = () => {
17+ if (typeof window !== 'undefined' && window.location.hash === `#${id}`) setOpen(true);
18+ };
19+ check();
20+ window.addEventListener('hashchange', check);
21+ return () => window.removeEventListener('hashchange', check);
22+ }, [id]);
23+ return (
24+ <section id={id} className={cn('hairline min-w-0 scroll-mt-32 pt-3 pb-4 md:pt-4 md:pb-6', className)} aria-labelledby={`${id}-h`}>
25+ <h3 id={`${id}-h`} className="min-w-0">
26+ <button type="button" aria-expanded={open} aria-controls={`${id}-body`} onClick={() => setOpen((o) => !o)} className="group/g -mx-1 flex min-h-[44px] w-full items-center justify-between gap-3 rounded-sm px-1 text-left hover:bg-surface-2/60">
27+ <span className="display min-w-0 truncate text-xl text-ink md:text-2xl">{title}</span>
28+ <span className="flex shrink-0 items-center gap-2 text-xs text-ink-3">
29+ <span className="tnum">{t('group.count', { n: count })}</span>
30+ <span className="hidden text-accent sm:inline">{open ? t('group.collapse') : t('group.expand')}</span>
31+ <ChevronDown size={16} aria-hidden className={cn('text-ink-3 transition-transform group-hover/g:text-ink', open && 'rotate-180')} />
32+ </span>
33+ </button>
34+ </h3>
35+ <div id={`${id}-body`} className="mt-2 min-w-0">
36+ {open ? children : summary}
37+ </div>
38+ </section>
39+ );
40+}
41+
42+/** In-page jump nav for the subtopic blocks: horizontal chip scroller on phones, wrapped on desktop. */
43+export function SubtopicJumpNav({ items, label, className }: { items: Array<{ id: string; label: string; count: number }>; label?: string; className?: string }) {
44+ if (items.length < 2) return null;
45+ return (
46+ <nav aria-label={label ?? t('group.jump')} className={cn('-mx-4 px-4 sm:mx-0 sm:px-0', className)}>
47+ <ul className="scrollbar-none flex gap-1.5 overflow-x-auto py-1 sm:flex-wrap">
48+ {items.map((it) => (
49+ <li key={it.id} className="shrink-0">
50+ <a href={`#${it.id}`} className="inline-flex h-11 items-center gap-1.5 whitespace-nowrap 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">
51+ {it.label}
52+ <span className="tnum text-2xs text-ink-3">{it.count}</span>
53+ </a>
54+ </li>
55+ ))}
56+ </ul>
57+ </nav>
58+ );
59+}
added apps/web/src/components/data/country-chip.tsx +19 −0
@@ -0,0 +1,19 @@
1+import Link from 'next/link';
2+import { cn } from '@/lib/cn';
3+import { routes } from '@/lib/site';
4+
5+/** Flag + name link. `size="sm"` for inline mentions, default for lists. */
6+export function CountryChip({ country, size = 'md', className, muted }: { country: { slug: string; short_name?: string; name?: string; flag_emoji?: string | null; flag?: string | null }; size?: 'sm' | 'md'; className?: string; muted?: boolean }) {
7+ const name = country.short_name ?? country.name ?? country.slug;
8+ const flag = country.flag_emoji ?? country.flag ?? '';
9+ return (
10+ <Link href={routes.country(country.slug)} className={cn('link-quiet inline-flex max-w-full items-center gap-1.5 align-baseline', size === 'sm' ? 'text-sm' : 'text-base', muted && 'text-ink-2', className)}>
11+ {flag ? (
12+ <span aria-hidden className={size === 'sm' ? 'text-sm leading-none' : 'text-lg leading-none'}>
13+ {flag}
14+ </span>
15+ ) : null}
16+ <span className="truncate">{name}</span>
17+ </Link>
18+ );
19+}
added apps/web/src/components/data/data-table.tsx +63 −0
@@ -0,0 +1,63 @@
1+import type { ReactNode } from 'react';
2+import { t } from '@/i18n';
3+import { cn } from '@/lib/cn';
4+
5+export interface DataTableColumn<T> {
6+ key: string;
7+ header: ReactNode;
8+ cell: (row: T) => ReactNode;
9+ numeric?: boolean;
10+ /** Hide on the definition-list (mobile) layout. */
11+ hideOnMobile?: boolean;
12+ className?: string;
13+}
14+
15+/**
16+ * Data table that becomes a definition list on < sm (no horizontal overflow). Numeric columns are
17+ * right-aligned and tabular. Pass `rowKey` for stable keys and `caption` for AT.
18+ */
19+export function DataTable<T>({ rows, columns, rowKey, caption, className, dense }: { rows: T[]; columns: DataTableColumn<T>[]; rowKey: (row: T) => string; caption?: string; className?: string; dense?: boolean }) {
20+ return (
21+ <div className={cn('min-w-0', className)}>
22+ <table className="hidden w-full border-collapse text-sm sm:table">
23+ {caption ? <caption className="sr-only">{caption}</caption> : null}
24+ <thead>
25+ <tr className="border-b border-rule text-left text-xs text-ink-3">
26+ {columns.map((c) => (
27+ <th key={c.key} scope="col" className={cn('py-2 pr-3 font-medium', c.numeric && 'text-right', c.className)}>
28+ {c.header}
29+ </th>
30+ ))}
31+ </tr>
32+ </thead>
33+ <tbody className="divide-y divide-rule">
34+ {rows.map((r) => (
35+ <tr key={rowKey(r)} className="hover:bg-surface-2/60">
36+ {columns.map((c) => (
37+ <td key={c.key} className={cn(dense ? 'py-1.5' : 'py-2.5', 'pr-3 align-top', c.numeric && 'tnum text-right', c.className)}>
38+ {c.cell(r)}
39+ </td>
40+ ))}
41+ </tr>
42+ ))}
43+ </tbody>
44+ </table>
45+ <ul className="divide-y divide-rule sm:hidden" aria-label={caption ?? t('table.definitionList')}>
46+ {rows.map((r) => (
47+ <li key={rowKey(r)} className="py-3">
48+ <dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
49+ {columns
50+ .filter((c) => !c.hideOnMobile)
51+ .map((c, i) => (
52+ <div key={c.key} className={cn('contents', i === 0 && 'font-medium')}>
53+ <dt className={cn('text-xs text-ink-3', i === 0 && 'sr-only')}>{c.header}</dt>
54+ <dd className={cn('min-w-0 text-sm', c.numeric && 'tnum text-right', i === 0 && 'col-span-2 text-base')}>{c.cell(r)}</dd>
55+ </div>
56+ ))}
57+ </dl>
58+ </li>
59+ ))}
60+ </ul>
61+ </div>
62+ );
63+}
added apps/web/src/components/data/empty-state.tsx +30 −0
@@ -0,0 +1,30 @@
1+import { CircleSlash2 } from 'lucide-react';
2+import type { ReactNode } from 'react';
3+import { t } from '@/i18n';
4+import { cn } from '@/lib/cn';
5+
6+/** Explicit "No data" state — never a blank space. Optional list of the sources that were checked. */
7+export function EmptyState({ title, hint, sourcesChecked, action, className, compact }: { title?: ReactNode; hint?: ReactNode; sourcesChecked?: string[]; action?: ReactNode; className?: string; compact?: boolean }) {
8+ return (
9+ <div className={cn('flex items-start gap-3 border-t border-dashed border-rule-strong text-sm text-ink-2', compact ? 'py-3' : 'py-6', className)}>
10+ <CircleSlash2 size={18} aria-hidden className="mt-0.5 shrink-0 text-ink-3" />
11+ <div className="min-w-0">
12+ <p className="font-medium text-ink">{title ?? t('empty.generic')}</p>
13+ {hint ? <p className="mt-0.5 text-ink-2">{hint}</p> : null}
14+ {sourcesChecked && sourcesChecked.length ? <p className="mt-0.5 text-xs text-ink-3">{t('empty.sourcesChecked', { sources: sourcesChecked.join(', ') })}</p> : null}
15+ {action ? <div className="mt-2">{action}</div> : null}
16+ </div>
17+ </div>
18+ );
19+}
20+
21+/** Calm full-width state for a 503 (snapshot not built yet) or unreachable API. */
22+export function NotBuiltState({ className }: { className?: string }) {
23+ return (
24+ <div className={cn('mx-auto max-w-prose py-16 text-center', className)}>
25+ <div className="eyebrow">{t('site.name')}</div>
26+ <h2 className="display mt-2 text-2xl text-ink">{t('common.notBuilt')}</h2>
27+ <p className="mt-2 text-ink-2">{t('common.notBuiltHint')}</p>
28+ </div>
29+ );
30+}
added apps/web/src/components/data/freshness-badge.tsx +43 −0
@@ -0,0 +1,43 @@
1+import { Clock } from 'lucide-react';
2+import { t } from '@/i18n';
3+import { cn } from '@/lib/cn';
4+import { formatDate, freshnessLevel, relativeFreshness } from '@/lib/format';
5+import type { ObservationStatus } from '@/lib/types';
6+
7+/** Small retrieval-freshness chip: "Updated 3 d ago" with a colour + icon (never colour alone). */
8+export function FreshnessBadge({ retrievedAt, now, className }: { retrievedAt: string | null | undefined; now?: number; className?: string }) {
9+ const level = freshnessLevel(retrievedAt, now);
10+ if (!level) return null;
11+ return (
12+ <span
13+ className={cn(
14+ 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-2xs font-medium',
15+ level === 'fresh' && 'bg-accent-soft text-accent',
16+ level === 'recent' && 'bg-surface-2 text-ink-2',
17+ level === 'stale' && 'bg-surface-2 text-warn',
18+ className,
19+ )}
20+ title={formatDate(retrievedAt)}
21+ >
22+ <Clock size={11} aria-hidden />
23+ {t('common.freshness.updated', { when: relativeFreshness(retrievedAt, now) })}
24+ </span>
25+ );
26+}
27+
28+export function StatusBadge({ status }: { status: ObservationStatus }) {
29+ const label = t(`common.status.${status}` as const);
30+ return (
31+ <span
32+ className={cn(
33+ 'inline-flex items-center rounded-sm border px-1.5 py-0.5 text-2xs font-medium',
34+ status === 'verified' && 'border-rule text-ink-2',
35+ status === 'imported' && 'border-rule text-ink-3',
36+ (status === 'warning' || status === 'stale') && 'border-warn/40 text-warn',
37+ status === 'quarantined' && 'border-down/40 text-down',
38+ )}
39+ >
40+ {label}
41+ </span>
42+ );
43+}
added apps/web/src/components/data/metric.tsx +74 −0
@@ -0,0 +1,74 @@
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 { displayValue, formatPeriod } from '@/lib/format';
7+import type { MetricValue } from '@/lib/types';
8+import { pointsFromSpark } from '@/components/charts/scales';
9+import { Sparkline } from '@/components/charts/sparkline';
10+import { ChangeChip } from './change-chip';
11+import { RankBadge } from './rank-badge';
12+import { useProvenance, type ProvenancePayload } from './provenance-context';
13+
14+export interface MetricCountry {
15+ id: string;
16+ slug: string | null;
17+ name: string;
18+ flag?: string | null;
19+}
20+
21+/** Build the provenance-sheet payload for a MetricValue (also used by topic rows and chart source lines). */
22+export function payloadFor(m: MetricValue, country?: MetricCountry | null, extra?: { name?: string | null; methodology?: string | null; description?: string | null }): ProvenancePayload {
23+ return {
24+ indicator: { slug: m.indicator, name: extra?.name ?? m.indicator_name ?? m.indicator, format: m.format, unit: m.unit, unit_short: m.unit_short, frequency: m.frequency, higher_is_better: m.higher_is_better, methodology: extra?.methodology ?? null, description: extra?.description ?? null },
25+ value: m.has_data ? { value: m.value, formatted: m.formatted, period: m.period, year: m.year, unit: m.unit, is_estimate: m.is_estimate, is_forecast: m.is_forecast, status: m.status, provenance: m.provenance } : null,
26+ country: country ?? null,
27+ };
28+}
29+
30+/**
31+ * Headline metric: label, big value, period, change chip, rank line, tiny sparkline. Click on the value →
32+ * provenance sheet; `href` (optional) renders a quiet chevron link to the topic page. 1 px separators, no card.
33+ * Heights are reserved (sparkline box, rank line) so the grid does not shift while fonts/data load.
34+ */
35+export function Metric({ metric, country, regionName, href, className, size = 'md' }: { metric: MetricValue; country?: MetricCountry | null; regionName?: string | null; href?: string | null; className?: string; size?: 'sm' | 'md' | 'lg' }) {
36+ const { open } = useProvenance();
37+ const m = metric;
38+ const hasValue = m.has_data && m.value != null;
39+ const dir = m.change?.abs == null ? null : m.change.abs > 0 ? 'up' : m.change.abs < 0 ? 'down' : 'flat';
40+ const points = pointsFromSpark(m.sparkline);
41+ return (
42+ <div className={cn('flex min-w-0 flex-col gap-1 border-t border-rule py-3', className)} id={m.indicator}>
43+ <div className="flex items-start justify-between gap-2">
44+ {href ? (
45+ <Link href={href} className="group/l -my-1 flex min-h-[44px] min-w-0 items-center gap-0.5 py-1 text-xs font-medium leading-snug text-ink-2 hover:text-accent md:min-h-[32px]" aria-label={t('metric.open', { name: m.indicator_name ?? m.indicator })}>
46+ <span className="line-clamp-2">{m.indicator_name ?? m.indicator}</span>
47+ <ChevronRight size={13} aria-hidden className="shrink-0 text-ink-3 group-hover/l:text-accent" />
48+ </Link>
49+ ) : (
50+ <div className="line-clamp-2 min-w-0 py-1 text-xs font-medium leading-snug text-ink-2">{m.indicator_name ?? m.indicator}</div>
51+ )}
52+ {points.length >= 2 ? <Sparkline points={points} width={64} height={20} direction={dir} className="mt-0.5 shrink-0 opacity-90" /> : <span className="inline-block h-[20px] w-[64px] shrink-0" aria-hidden />}
53+ </div>
54+ <button type="button" onClick={() => open(payloadFor(m, country))} className="group -mx-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2 focus-visible:bg-surface-2" aria-label={t('common.openProvenance')}>
55+ <span className={cn('pnum font-semibold leading-none text-ink', size === 'lg' ? 'text-3xl md:text-4xl' : size === 'sm' ? 'text-xl' : 'text-2xl md:text-[1.75rem]')}>
56+ {hasValue ? displayValue(m.value, m, m.formatted) : <span className="text-ink-3">{t('common.noData')}</span>}
57+ </span>
58+ <span className="mt-1 flex min-h-[1.1rem] flex-wrap items-baseline gap-x-2 text-xs text-ink-3">
59+ {hasValue ? <span className="tnum">{formatPeriod(m.period, m.frequency)}</span> : null}
60+ {m.is_estimate ? <span>{t('common.estimate')}</span> : null}
61+ <ChangeChip change={m.change} spec={m} prevPeriod={m.prev?.period} />
62+ </span>
63+ </button>
64+ <div className="min-h-[1.1rem] min-w-0 truncate">
65+ <RankBadge rank={m} regionName={regionName} />
66+ </div>
67+ </div>
68+ );
69+}
70+
71+/** Responsive editorial grid for Metrics: 1 col ≤ 360 px, 2 on phones, 3–4 on desktop. */
72+export function MetricGrid({ children, className, cols = 4 }: { children: React.ReactNode; className?: string; cols?: 3 | 4 }) {
73+ return <div className={cn('grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-3', cols === 4 && 'xl:grid-cols-4', className)}>{children}</div>;
74+}
added apps/web/src/components/data/provenance-context.tsx +65 −0
@@ -0,0 +1,65 @@
1+'use client';
2+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
3+import type { FormatSpec, ObservationStatus, Provenance } from '@/lib/types';
4+
5+/** Indicator description the sheet needs (built from a MetricValue or an IndicatorCard). */
6+export interface ProvenanceIndicator extends FormatSpec {
7+ slug: string;
8+ name: string;
9+ methodology?: string | null;
10+ description?: string | null;
11+}
12+
13+/** The value being explained (a MetricValue or a SeriesValue reduced to what the sheet shows). */
14+export interface ProvenanceValue {
15+ value: number | null;
16+ formatted?: string | null;
17+ period: string | null;
18+ year?: number | null;
19+ unit?: string | null;
20+ is_estimate?: boolean;
21+ is_forecast?: boolean;
22+ status?: ObservationStatus | string | null;
23+ provenance: Provenance | null;
24+}
25+
26+export interface ProvenancePayload {
27+ indicator: ProvenanceIndicator;
28+ value: ProvenanceValue | null;
29+ country?: { id: string; slug: string | null; name: string; flag?: string | null } | null;
30+ /** Override download link; defaults to the country CSV, else the indicator CSV. */
31+ downloadHref?: string;
32+ /** Optional data-quality facts for the series (badges are derived when absent). */
33+ quality?: {
34+ badges?: Array<'fresh' | 'historical' | 'sparse' | 'limited-coverage' | 'stale' | 'flagged' | 'forecast'>;
35+ firstYear?: number | null;
36+ latestYear?: number | null;
37+ points?: number | null;
38+ missingYears?: number | null;
39+ continuityPct?: number | null;
40+ coveragePct?: number | null;
41+ referenceYear?: number | null;
42+ } | null;
43+}
44+
45+interface Ctx {
46+ payload: ProvenancePayload | null;
47+ open: (p: ProvenancePayload) => void;
48+ close: () => void;
49+}
50+
51+const ProvenanceCtx = createContext<Ctx | null>(null);
52+
53+export function ProvenanceProvider({ children }: { children: ReactNode }) {
54+ const [payload, setPayload] = useState<ProvenancePayload | null>(null);
55+ const open = useCallback((p: ProvenancePayload) => setPayload(p), []);
56+ const close = useCallback(() => setPayload(null), []);
57+ const value = useMemo(() => ({ payload, open, close }), [payload, open, close]);
58+ return <ProvenanceCtx.Provider value={value}>{children}</ProvenanceCtx.Provider>;
59+}
60+
61+export function useProvenance(): Ctx {
62+ const ctx = useContext(ProvenanceCtx);
63+ if (!ctx) return { payload: null, open: () => {}, close: () => {} };
64+ return ctx;
65+}
added apps/web/src/components/data/provenance-sheet.tsx +139 −0
@@ -0,0 +1,139 @@
1+'use client';
2+import { Check, Code2, Download, ExternalLink } from 'lucide-react';
3+import Link from 'next/link';
4+import { useEffect, useState } from 'react';
5+import { t } from '@/i18n';
6+import { displayValue, formatDate, formatPeriod } from '@/lib/format';
7+import { routes } from '@/lib/site';
8+import type { ObservationStatus } from '@/lib/types';
9+import { BottomSheet } from './bottom-sheet';
10+import { StatusBadge } from './freshness-badge';
11+import { useProvenance } from './provenance-context';
12+import { QualityBadges, QualityStrip, deriveBadges } from './quality-badge';
13+
14+/**
15+ * Global provenance sheet 2.0: mounted once in the root layout; opened by <Metric>, chart source lines and any
16+ * component calling `useProvenance().open(payload)`. Layout: value block (indicator · country · period · value),
17+ * SOURCE / DATASET / SERIES / SOURCE UPDATED / RETRIEVED / LICENCE, transform + status, quality badges, actions
18+ * [Open original] [API] [Download series] [Indicator page].
19+ */
20+export function ProvenanceSheet() {
21+ const { payload, close } = useProvenance();
22+ const open = !!payload;
23+ const ind = payload?.indicator;
24+ const v = payload?.value ?? null;
25+ const p = v?.provenance ?? null;
26+ const country = payload?.country ?? null;
27+ const download = payload?.downloadHref ?? (country ? routes.countryDownload(country.id) : ind ? routes.indicatorDownload(ind.slug) : null);
28+ const apiPath = ind ? (country ? `/api/v1/countries/${country.id}/series/${ind.slug}` : `/api/v1/indicators/${ind.slug}`) : null;
29+ const [copied, setCopied] = useState(false);
30+ useEffect(() => {
31+ if (!open) setCopied(false);
32+ }, [open]);
33+
34+ const copyApi = async () => {
35+ if (!apiPath) return;
36+ const url = `${window.location.origin}${apiPath}`;
37+ try {
38+ await navigator.clipboard.writeText(url);
39+ setCopied(true);
40+ setTimeout(() => setCopied(false), 1800);
41+ } catch {
42+ /* clipboard unavailable — the link below still opens the docs */
43+ }
44+ };
45+
46+ const badges = payload?.quality?.badges ?? (v ? deriveBadges({ latestYear: v.year ?? null, firstYear: payload?.quality?.firstYear ?? null, points: payload?.quality?.points ?? null, hasForecast: v.is_forecast, referenceYear: payload?.quality?.referenceYear ?? null }) : []);
47+
48+ return (
49+ <BottomSheet open={open} onClose={close} labelledBy="prov-title" title={<span id="prov-title">{t('prov.title')}</span>}>
50+ {payload && ind ? (
51+ <div className="space-y-5 text-sm">
52+ {/* Value block */}
53+ <div>
54+ <div className="eyebrow">{ind.name}</div>
55+ {country ? (
56+ <h3 className="display mt-1 text-xl text-ink">
57+ {country.flag ? <span aria-hidden>{country.flag} </span> : null}
58+ {country.name}
59+ </h3>
60+ ) : (
61+ <h3 className="display mt-1 text-xl text-ink">{t('common.world')}</h3>
62+ )}
63+ {v ? (
64+ <div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
65+ <span className="tnum text-3xl font-semibold text-ink">{displayValue(v.value, ind, v.formatted)}</span>
66+ <span className="tnum text-ink-2">{formatPeriod(v.period, ind.frequency)}</span>
67+ {v.status ? <StatusBadge status={v.status as ObservationStatus} /> : null}
68+ {v.is_forecast ? <span className="text-xs text-ink-3">{t('common.forecast')}</span> : null}
69+ {v.is_estimate ? <span className="text-xs text-ink-3">{t('common.estimate')}</span> : null}
70+ </div>
71+ ) : (
72+ <p className="mt-2 text-ink-3">{t('prov.noValue')}</p>
73+ )}
74+ {v?.is_forecast ? <p className="mt-1 text-xs text-ink-3">{t('prov.status.forecastNote')}</p> : null}
75+ </div>
76+
77+ <dl className="divide-y divide-rule border-y border-rule">
78+ <Row k={t('prov.source')} v={p?.source_name ?? t('common.na')} strong />
79+ <Row k={t('prov.dataset')} v={p?.dataset ?? t('common.na')} />
80+ <Row k={t('prov.series')} v={p?.series_code ?? t('common.na')} mono />
81+ <Row k={t('prov.observation')} v={formatPeriod(v?.period, ind.frequency)} />
82+ <Row k={t('prov.sourceUpdated')} v={formatDate(p?.source_updated_at)} />
83+ <Row k={t('prov.retrieved')} v={formatDate(p?.retrieved_at)} />
84+ <Row k={t('prov.unit')} v={v?.unit ?? ind.unit ?? t('common.na')} />
85+ <Row k={t('prov.transform')} v={p?.transform ?? t('prov.none')} mono={!!p?.transform} />
86+ <Row k={t('prov.licence')} v={p?.licence ?? t('common.na')} />
87+ </dl>
88+
89+ {badges.length || payload.quality ? (
90+ <div>
91+ <div className="eyebrow mb-1">{t('prov.quality')}</div>
92+ {payload.quality ? <QualityStrip latestYear={payload.quality.latestYear} firstYear={payload.quality.firstYear} points={payload.quality.points} missingYears={payload.quality.missingYears} continuityPct={payload.quality.continuityPct} coveragePct={payload.quality.coveragePct} badges={badges} /> : <QualityBadges badges={badges} />}
93+ <p className="mt-1.5 text-xs text-ink-3">{t('quality.noScore')}</p>
94+ </div>
95+ ) : null}
96+
97+ {ind.methodology || ind.description ? (
98+ <div>
99+ <div className="eyebrow">{ind.methodology ? t('prov.methodology') : t('prov.definition')}</div>
100+ <p className="mt-1 text-ink-2">{ind.methodology ?? ind.description}</p>
101+ </div>
102+ ) : null}
103+
104+ <div className="flex flex-wrap gap-2 pt-1">
105+ {p?.url ? (
106+ <a href={p.url} target="_blank" rel="noopener noreferrer" className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">
107+ <ExternalLink size={14} aria-hidden /> {t('prov.openSource')}
108+ </a>
109+ ) : null}
110+ {apiPath ? (
111+ <Link href={routes.api()} onClick={() => void copyApi()} className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2" title={t('prov.apiHint')} aria-live="polite">
112+ {copied ? <Check size={14} aria-hidden className="text-up" /> : <Code2 size={14} aria-hidden />}
113+ {copied ? t('prov.copiedApi') : t('prov.copyApi')}
114+ </Link>
115+ ) : null}
116+ {download ? (
117+ <a href={download} className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">
118+ <Download size={14} aria-hidden /> {t('prov.downloadSeries')}
119+ </a>
120+ ) : null}
121+ <Link href={routes.indicator(ind.slug)} className="tap inline-flex items-center rounded-sm px-3 text-sm text-accent hover:underline" onClick={close}>
122+ {t('prov.indicatorPage')} →
123+ </Link>
124+ </div>
125+ {apiPath ? <code className="block break-all font-mono text-2xs text-ink-3">{apiPath}</code> : null}
126+ </div>
127+ ) : null}
128+ </BottomSheet>
129+ );
130+}
131+
132+function Row({ k, v, mono, strong }: { k: string; v: string; mono?: boolean; strong?: boolean }) {
133+ return (
134+ <div className="grid grid-cols-[8.5rem_1fr] gap-3 py-2">
135+ <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3 pt-0.5">{k}</dt>
136+ <dd className={mono ? 'break-all font-mono text-[13px] text-ink' : strong ? 'font-medium text-ink' : 'text-ink'}>{v}</dd>
137+ </div>
138+ );
139+}
added apps/web/src/components/data/quality-badge.tsx +103 −0
@@ -0,0 +1,103 @@
1+import { AlertTriangle, CalendarClock, Clock3, History, Layers, Ruler, TrendingUp } from 'lucide-react';
2+import { t } from '@/i18n';
3+import { cn } from '@/lib/cn';
4+import type { QualityBadge as Badge } from '@/lib/types-analytics';
5+
6+const ICON: Record<Badge, typeof Clock3> = {
7+ fresh: Clock3,
8+ historical: History,
9+ sparse: Layers,
10+ 'limited-coverage': Ruler,
11+ stale: CalendarClock,
12+ flagged: AlertTriangle,
13+ forecast: TrendingUp,
14+};
15+
16+const TONE: Record<Badge, string> = {
17+ fresh: 'border-accent/40 bg-accent-soft text-accent',
18+ historical: 'border-rule text-ink-2',
19+ sparse: 'border-warn/40 text-warn',
20+ 'limited-coverage': 'border-warn/40 text-warn',
21+ stale: 'border-warn/40 text-warn',
22+ flagged: 'border-down/40 text-down',
23+ forecast: 'border-rule text-ink-2',
24+};
25+
26+/** One data-quality badge: glyph + label always accompany the colour (never colour alone). */
27+export function QualityBadge({ badge, className, showHint = false }: { badge: Badge; className?: string; showHint?: boolean }) {
28+ const Icon = ICON[badge] ?? Clock3;
29+ const label = t(`quality.${badge}` as 'quality.fresh');
30+ const hint = t(`quality.hint.${badge}` as 'quality.hint.fresh');
31+ return (
32+ <span className={cn('badge', TONE[badge] ?? 'border-rule text-ink-2', className)} title={showHint ? undefined : hint}>
33+ <Icon size={11} aria-hidden />
34+ {label}
35+ {showHint ? <span className="font-normal text-ink-3"> · {hint}</span> : null}
36+ </span>
37+ );
38+}
39+
40+/** Row of badges (deduplicated, stable order). Renders nothing when empty. */
41+export function QualityBadges({ badges, className, max = 5 }: { badges: Badge[] | null | undefined; className?: string; max?: number }) {
42+ if (!badges?.length) return null;
43+ const order: Badge[] = ['fresh', 'stale', 'historical', 'sparse', 'limited-coverage', 'flagged', 'forecast'];
44+ const list = order.filter((b) => badges.includes(b)).slice(0, max);
45+ return (
46+ <ul className={cn('flex flex-wrap items-center gap-1', className)} aria-label={t('quality.title')}>
47+ {list.map((b) => (
48+ <li key={b}>
49+ <QualityBadge badge={b} />
50+ </li>
51+ ))}
52+ </ul>
53+ );
54+}
55+
56+/**
57+ * Compact quality strip: latest year · first year · points · coverage, plus badges. Used by provenance panel,
58+ * indicator and country pages. All fields optional; missing ones are skipped.
59+ */
60+export function QualityStrip({ latestYear, firstYear, points, coveragePct, missingYears, continuityPct, badges, className }: { latestYear?: number | null; firstYear?: number | null; points?: number | null; coveragePct?: number | null; missingYears?: number | null; continuityPct?: number | null; badges?: Badge[] | null; className?: string }) {
61+ const cells: Array<[string, string]> = [];
62+ if (firstYear != null && latestYear != null) cells.push([t('prov.years'), `${firstYear}–${latestYear}`]);
63+ else if (latestYear != null) cells.push([t('quality.latestYear'), String(latestYear)]);
64+ if (points != null) cells.push([t('quality.points'), String(points)]);
65+ if (missingYears != null) cells.push([t('quality.missing'), String(missingYears)]);
66+ if (continuityPct != null) cells.push([t('quality.continuity'), `${Math.round(continuityPct)} %`]);
67+ if (coveragePct != null) cells.push([t('quality.coverage'), `${Math.round(coveragePct)} %`]);
68+ if (!cells.length && !badges?.length) return null;
69+ return (
70+ <div className={cn('min-w-0', className)}>
71+ {cells.length ? (
72+ <dl className="tnum flex flex-wrap gap-x-4 gap-y-1 text-xs">
73+ {cells.map(([k, v]) => (
74+ <div key={k} className="flex items-baseline gap-1">
75+ <dt className="text-ink-3">{k}</dt>
76+ <dd className="text-ink">{v}</dd>
77+ </div>
78+ ))}
79+ </dl>
80+ ) : null}
81+ <QualityBadges badges={badges} className={cells.length ? 'mt-1.5' : undefined} />
82+ </div>
83+ );
84+}
85+
86+/**
87+ * Derive badges client-side from a series when the API quality endpoints are not available:
88+ * fresh / stale from the latest year vs reference, historical from the first year, sparse from the point count.
89+ */
90+export function deriveBadges(opts: { firstYear?: number | null; latestYear?: number | null; points?: number | null; referenceYear?: number | null; hasForecast?: boolean; flaggedShare?: number | null; coveragePct?: number | null }): Badge[] {
91+ const out: Badge[] = [];
92+ const ref = opts.referenceYear ?? new Date().getUTCFullYear() - 1;
93+ if (opts.latestYear != null) {
94+ if (opts.latestYear >= ref - 1) out.push('fresh');
95+ else if (opts.latestYear <= ref - 3) out.push('stale');
96+ }
97+ if (opts.firstYear != null && opts.firstYear <= 1970) out.push('historical');
98+ if (opts.points != null && opts.points < 10) out.push('sparse');
99+ if (opts.coveragePct != null && opts.coveragePct < 50) out.push('limited-coverage');
100+ if (opts.flaggedShare != null && opts.flaggedShare > 5) out.push('flagged');
101+ if (opts.hasForecast) out.push('forecast');
102+ return out;
103+}
added apps/web/src/components/data/rank-badge.tsx +23 −0
@@ -0,0 +1,23 @@
1+import { t } from '@/i18n';
2+import { cn } from '@/lib/cn';
3+import { grouped, isNum, ordinal } from '@/lib/format';
4+import type { MetricValue } from '@/lib/types';
5+
6+type RankFields = Pick<MetricValue, 'rank_world' | 'n_world' | 'rank_region' | 'n_region' | 'rank_year' | 'rank_is_stale'>;
7+
8+/**
9+ * "12th of 190 · 3rd in North America". World rank always when known; region rank when `regionName` is given.
10+ * A `*` marks a rank computed for an older year than the indicator's latest (`rank_is_stale`).
11+ */
12+export function RankBadge({ rank, regionName, className, compact }: { rank: Partial<RankFields> | null | undefined; regionName?: string | null; className?: string; compact?: boolean }) {
13+ if (!rank || !isNum(rank.rank_world) || !isNum(rank.n_world)) return null;
14+ const world = t('metric.rankWorld', { rank: ordinal(rank.rank_world), n: grouped(rank.n_world) });
15+ const region = regionName && isNum(rank.rank_region) ? t('metric.rankRegion', { rank: ordinal(rank.rank_region), region: regionName }) : null;
16+ return (
17+ <span className={cn('tnum text-xs text-ink-2', className)} title={rank.rank_is_stale && rank.rank_year ? t('metric.rankYearNote', { year: rank.rank_year }) : undefined}>
18+ <span className="font-medium text-ink">{world}</span>
19+ {region && !compact ? <span className="text-ink-3"> · {region}</span> : null}
20+ {rank.rank_is_stale ? <sup className="text-ink-3">*</sup> : null}
21+ </span>
22+ );
23+}
added apps/web/src/components/data/section.tsx +45 −0
@@ -0,0 +1,45 @@
1+import type { ReactNode } from 'react';
2+import { cn } from '@/lib/cn';
3+
4+/**
5+ * Editorial section: eyebrow/heading + subtitle on the left, optional actions on the right, a 1 px rule above.
6+ * Use instead of cards. `id` makes it linkable from TopicNav / TOC.
7+ */
8+export function Section({
9+ id,
10+ title,
11+ subtitle,
12+ eyebrow,
13+ actions,
14+ children,
15+ className,
16+ level = 2,
17+ tight = false,
18+}: {
19+ id?: string;
20+ title: ReactNode;
21+ subtitle?: ReactNode;
22+ eyebrow?: ReactNode;
23+ actions?: ReactNode;
24+ children: ReactNode;
25+ className?: string;
26+ level?: 2 | 3;
27+ tight?: boolean;
28+}) {
29+ const H = level === 2 ? 'h2' : 'h3';
30+ return (
31+ <section id={id} className={cn('hairline min-w-0 scroll-mt-28', tight ? 'pt-4 pb-6' : 'pt-6 pb-10 md:pt-8 md:pb-14', className)} aria-labelledby={id ? `${id}-h` : undefined}>
32+ <div className="mb-4 flex flex-wrap items-end justify-between gap-x-6 gap-y-2 md:mb-6">
33+ <div className="min-w-0">
34+ {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}
35+ <H id={id ? `${id}-h` : undefined} className={cn('display text-ink', level === 2 ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl')}>
36+ {title}
37+ </H>
38+ {subtitle ? <p className="mt-1 max-w-prose text-sm text-ink-2 md:text-base">{subtitle}</p> : null}
39+ </div>
40+ {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 text-sm">{actions}</div> : null}
41+ </div>
42+ {children}
43+ </section>
44+ );
45+}
added apps/web/src/components/data/topic-nav.tsx +51 −0
@@ -0,0 +1,51 @@
1+'use client';
2+import Link from 'next/link';
3+import { usePathname } from 'next/navigation';
4+import { useEffect, useRef } from 'react';
5+import { t } from '@/i18n';
6+import { cn } from '@/lib/cn';
7+import { routes } from '@/lib/site';
8+import { TOPICS } from '@/lib/topics';
9+
10+/**
11+ * Topic navigation for a country: horizontally scrollable chips on mobile, sticky sub-nav on desktop.
12+ * The active chip scrolls into view on mount. Counts (optional) show indicators with data per topic.
13+ */
14+export function TopicNav({ slug, counts, sticky = true, className }: { slug: string; counts?: Record<string, number>; sticky?: boolean; className?: string }) {
15+ const pathname = usePathname();
16+ const ref = useRef<HTMLElement>(null);
17+ useEffect(() => {
18+ const el = ref.current?.querySelector<HTMLElement>('[aria-current="page"]');
19+ if (el) el.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior });
20+ }, [pathname]);
21+ const items = [{ id: '', short: t('country.overview'), href: routes.country(slug) }, ...TOPICS.map((tp) => ({ id: tp.id, short: tp.short, href: routes.countryTopic(slug, tp.id) }))];
22+ return (
23+ <nav
24+ ref={ref}
25+ aria-label={t('country.topics.title', { name: '' }).trim()}
26+ className={cn(sticky && 'sticky top-[52px] z-20 md:top-[56px]', '-mx-4 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6', className)}
27+ >
28+ <ul className="scrollbar-none flex snap-x gap-1 overflow-x-auto px-4 py-1 sm:px-6 md:py-1.5">
29+ {items.map((it) => {
30+ const active = pathname === it.href;
31+ const n = it.id ? counts?.[it.id] : undefined;
32+ return (
33+ <li key={it.href} className="snap-start">
34+ <Link
35+ href={it.href}
36+ aria-current={active ? 'page' : undefined}
37+ className={cn(
38+ 'inline-flex h-11 items-center gap-1.5 whitespace-nowrap rounded-sm px-3 text-sm md:h-9 md:px-2.5',
39+ active ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink',
40+ )}
41+ >
42+ {it.short}
43+ {n != null ? <span className={cn('tnum text-2xs', active ? 'text-paper/70' : 'text-ink-3')}>{n}</span> : null}
44+ </Link>
45+ </li>
46+ );
47+ })}
48+ </ul>
49+ </nav>
50+ );
51+}
52
Menu

Theme