SPB Git forge

spb/countryatlas

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

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 12 days ago (Sep 12, 2026) parent 3c74ab4

17 changed files +936 −1

modified .gitignore +1 −1
@@ -9,7 +9,7 @@ __pycache__/
9 9 *.pyc
10 10 .pytest_cache/
11 11 .ruff_cache/
12 −data/
12 +/data/
13 13 *.duckdb
14 14 *.duckdb.wal
15 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