spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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