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%
7.3 KB · 140 lines tsx
Raw Blame History
1'use client';2import { Check, Code2, Download, ExternalLink } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useState } from 'react';5import { t } from '@/i18n';6import { displayValue, formatDate, formatPeriod } from '@/lib/format';7import { routes } from '@/lib/site';8import type { ObservationStatus } from '@/lib/types';9import { BottomSheet } from './bottom-sheet';10import { StatusBadge } from './freshness-badge';11import { useProvenance } from './provenance-context';12import { QualityBadges, QualityStrip, deriveBadges } from './quality-badge';1314/**15 * Global provenance sheet 2.0: mounted once in the root layout; opened by <Metric>, chart source lines and any16 * component calling `useProvenance().open(payload)`. Layout: value block (indicator · country · period · value),17 * SOURCE / DATASET / SERIES / SOURCE UPDATED / RETRIEVED / LICENCE, transform + status, quality badges, actions18 * [Open original] [API] [Download series] [Indicator page].19 */20export 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]);3334  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  };4546  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 }) : []);4748  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>7677          <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>8889          {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}9697          {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}103104          <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}131132function 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}140