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