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%
11.4 KB · 258 lines tsx
Raw Blame History
1'use client';2import { AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, Waves, X } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useMemo, useRef, useState } from 'react';5import { t } from '@/i18n';6import { clientExplore } from '@/lib/client-api-explore';7import { cn } from '@/lib/cn';8import { formatPeriod, grouped } from '@/lib/format';9import { WB_REGIONS } from '@/lib/regions';10import { severityLabel, severityLevel } from '@/lib/severity';11import { routes } from '@/lib/site';12import type { ChangeItem } from '@/lib/types';13import { BottomSheet } from '@/components/data/bottom-sheet';14import { kindLabel } from '@/components/data/change-list';15import { CountryChip } from '@/components/data/country-chip';16import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';1718const ICON: Record<string, typeof ArrowUpRight> = {19  yoy_jump: ArrowUpRight,20  yoy_drop: ArrowDownRight,21  record_high: Trophy,22  record_low: AlertTriangle,23  n_year_high: TrendingUp,24  n_year_low: TrendingDown,25  sign_flip: Repeat,26  accelerating: TrendingUp,27  decelerating: TrendingDown,28  structural_break: GitCommitHorizontal,29  trend_reversal: Repeat,30  volatility_spike: Waves,31};3233type Sev = 0 | 0.4 | 0.7;3435/**36 * Global change feed with filters (kind chips, indicator typeahead, region, minimum severity) in a bottom37 * sheet on phones and inline on desktop. Items are grouped by period (year). Server passes the first page;38 * changing a server-side filter (kind / indicator / severity) refetches `/api/v1/changes`; region filters39 * client-side (the API has no region parameter but every item carries the country's region).40 */41export function ChangesFeed({ initial, kinds, limit = 100, compact = false }: { initial: ChangeItem[]; kinds: string[]; limit?: number; compact?: boolean }) {42  const [kind, setKind] = useState<string | null>(null);43  const [indicator, setIndicator] = useState<PickedEntity | null>(null);44  const [region, setRegion] = useState<string | null>(null);45  const [minSev, setMinSev] = useState<Sev>(0);46  const [items, setItems] = useState<ChangeItem[]>(initial);47  const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');48  const [sheet, setSheet] = useState(false);49  const first = useRef(true);5051  useEffect(() => {52    if (first.current) {53      first.current = false;54      return;55    }56    const ctrl = new AbortController();57    setState('loading');58    clientExplore59      .changes({ limit, kind, indicator: indicator?.slug ?? null, min_severity: minSev || null }, ctrl.signal)60      .then((r) => {61        setItems(r.items);62        setState('idle');63      })64      .catch((e) => {65        if ((e as Error).name !== 'AbortError') setState('error');66      });67    return () => ctrl.abort();68  }, [kind, indicator, minSev, limit]);6970  const visible = useMemo(() => (region ? items.filter((c) => (c.country?.region ?? '').toUpperCase() === region) : items), [items, region]);71  const groups = useMemo(() => {72    const m = new Map<string, ChangeItem[]>();73    for (const c of visible) {74      const key = c.period ? formatPeriod(c.period, 'A') : t('common.na');75      if (!m.has(key)) m.set(key, []);76      m.get(key)!.push(c);77    }78    return Array.from(m.entries()).sort((a, b) => b[0].localeCompare(a[0]));79  }, [visible]);8081  const active = (kind ? 1 : 0) + (indicator ? 1 : 0) + (region ? 1 : 0) + (minSev ? 1 : 0);82  const reset = () => {83    setKind(null);84    setIndicator(null);85    setRegion(null);86    setMinSev(0);87  };8889  const filters = (90    <div className="space-y-4">91      <div>92        <div className="eyebrow mb-1.5">{t('changes.filter.kind')}</div>93        <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.kind')}>94          <li>95            <Chip active={!kind} onClick={() => setKind(null)}>96              {t('common.all')}97            </Chip>98          </li>99          {kinds.map((k) => (100            <li key={k}>101              <Chip active={kind === k} onClick={() => setKind(kind === k ? null : k)}>102                {kindLabel(k, 10)}103              </Chip>104            </li>105          ))}106        </ul>107      </div>108      <div>109        <div className="eyebrow mb-1.5">{t('changes.filter.indicator')}</div>110        {indicator ? (111          <span className="inline-flex h-11 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-sm text-accent md:h-9">112            {indicator.name}113            <button type="button" onClick={() => setIndicator(null)} className="grid h-9 w-8 place-items-center" aria-label={t('common.reset')}>114              <X size={13} aria-hidden />115            </button>116          </span>117        ) : (118          <EntityPicker type="indicator" placeholder={t('changes.filter.search')} onPick={setIndicator} className="max-w-sm" size="sm" />119        )}120      </div>121      {!compact ? (122        <div>123          <div className="eyebrow mb-1.5">{t('changes.filter.region')}</div>124          <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.region')}>125            <li>126              <Chip active={!region} onClick={() => setRegion(null)}>127                {t('common.all')}128              </Chip>129            </li>130            {WB_REGIONS.map((r) => (131              <li key={r.id}>132                <Chip active={region === r.id} onClick={() => setRegion(region === r.id ? null : r.id)}>133                  {r.short}134                </Chip>135              </li>136            ))}137          </ul>138        </div>139      ) : null}140      <div>141        <div className="eyebrow mb-1.5">{t('changes.filter.severity')}</div>142        <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.severity')}>143          {(144            [145              [0, t('changes.filter.severity.any')],146              [0.4, t('changes.filter.severity.notable')],147              [0.7, t('changes.filter.severity.major')],148            ] as Array<[Sev, string]>149          ).map(([v, label]) => (150            <li key={v}>151              <Chip active={minSev === v} onClick={() => setMinSev(v)}>152                {label}153              </Chip>154            </li>155          ))}156        </ul>157      </div>158    </div>159  );160161  return (162    <div className="min-w-0">163      <div className="flex flex-wrap items-center gap-2 pb-3">164        <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-11 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>165          <SlidersHorizontal size={15} aria-hidden />166          {t('common.filters')}167          {active ? <span className="tnum">({active})</span> : null}168        </button>169        {compact ? (170          <button type="button" onClick={() => setSheet(true)} className={cn('hidden h-9 items-center gap-1.5 rounded-sm border px-3 text-sm md:inline-flex', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>171            <SlidersHorizontal size={15} aria-hidden />172            {t('common.filters')}173            {active ? <span className="tnum">({active})</span> : null}174          </button>175        ) : null}176        {active ? (177          <button type="button" onClick={reset} className="inline-flex h-11 items-center gap-1 rounded-sm px-2 text-xs text-ink-2 hover:text-ink md:h-9">178            <X size={13} aria-hidden /> {t('common.reset')}179          </button>180        ) : null}181        <span className="tnum ml-auto text-xs text-ink-3" aria-live="polite">182          {state === 'loading' ? t('changes.loading') : t('changes.count', { n: grouped(visible.length) })}183        </span>184      </div>185      {!compact ? <div className="hidden pb-4 md:block">{filters}</div> : null}186187      {state === 'error' ? <p className="py-6 text-sm text-down">{t('changes.error')}</p> : null}188      {state !== 'error' && visible.length === 0 && state !== 'loading' ? <p className="py-8 text-center text-sm text-ink-3">{t('changes.none')}</p> : null}189190      <div className={cn('transition-opacity', state === 'loading' && 'opacity-50')} aria-busy={state === 'loading'}>191        {groups.map(([period, rows]) => (192          <section key={period} className="pb-4">193            <h3 className="tnum sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14">194              {period}195            </h3>196            <ol className="divide-y divide-rule">197              {rows.map((c, i) => (198                <ChangeRow key={c.id ?? `${period}-${i}`} c={c} />199              ))}200            </ol>201          </section>202        ))}203      </div>204205      <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('common.filters')}>206        {filters}207        <div className="mt-6 flex justify-between">208          <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={reset}>209            {t('common.reset')}210          </button>211          <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>212            {t('common.apply')}213          </button>214        </div>215      </BottomSheet>216    </div>217  );218}219220function ChangeRow({ c }: { c: ChangeItem }) {221  const Icon = ICON[c.kind ?? ''] ?? ArrowUpRight;222  const lvl = severityLevel(c.severity);223  const ind = c.indicator as { id: string; slug?: string; name?: string | null; topic?: string | null };224  const slug = ind.slug ?? ind.id;225  const seriesHref = c.country?.slug && ind.topic ? routes.countryIndicator(c.country.slug, ind.topic, slug) : routes.indicator(slug);226  return (227    <li className="flex gap-3 py-3">228      <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>229        <Icon size={15} />230      </span>231      <div className="min-w-0 flex-1">232        <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3">233          {c.country ? <CountryChip country={{ slug: c.country.slug ?? c.country.id, name: c.country.name ?? c.country.id, flag: c.country.flag }} size="sm" /> : null}234          <span className="font-medium uppercase tracking-wide">{kindLabel(c.kind, c.window_years)}</span>235          <span className={cn('rounded-xs px-1 py-px', lvl === 'high' ? 'bg-accent-soft text-accent' : lvl === 'medium' ? 'bg-surface-2 text-ink-2' : 'bg-surface-2 text-ink-3')}>{severityLabel(c.severity)}</span>236        </div>237        <p className="mt-0.5 text-sm leading-snug text-ink">{c.headline ?? `${ind.name ?? slug}: ${c.formatted ?? ''}`}</p>238        <div className="mt-1 flex flex-wrap gap-x-3 text-xs">239          <Link href={seriesHref} className="inline-flex min-h-[32px] items-center text-accent hover:underline">240            {t('changes.series')} →241          </Link>242          <Link href={routes.indicator(slug)} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent hover:underline">243            {ind.name ?? slug}244          </Link>245        </div>246      </div>247    </li>248  );249}250251function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {252  return (253    <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-11 items-center rounded-sm border px-3 text-sm md:h-9 md:px-2.5', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>254      {children}255    </button>256  );257}258