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%
4.8 KB · 104 lines tsx
Raw Blame History
1import { AlertTriangle, CalendarClock, Clock3, History, Layers, Ruler, TrendingUp } from 'lucide-react';2import { t } from '@/i18n';3import { cn } from '@/lib/cn';4import type { QualityBadge as Badge } from '@/lib/types-analytics';56const 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};1516const 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};2526/** One data-quality badge: glyph + label always accompany the colour (never colour alone). */27export 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}3940/** Row of badges (deduplicated, stable order). Renders nothing when empty. */41export 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}5556/**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 */60export 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}8586/**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 */90export 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}104