import { LOCALE, t } from '@/i18n'; import type { FormatSpec, IndicatorFormat } from './types'; /** * Number/date formatting. All Intl calls use the FIXED locale `en-US` (server and client alike) so * server-rendered markup never differs from the client render (hydration). Never rely on the browser default. */ const NF_CACHE = new Map(); function nf(opts: Intl.NumberFormatOptions): Intl.NumberFormat { const key = JSON.stringify(opts); let f = NF_CACHE.get(key); if (!f) { f = new Intl.NumberFormat(LOCALE, opts); NF_CACHE.set(key, f); } return f; } const NA = t('common.na'); export function isNum(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); } /** 1.2T / 45.3B / 12.4M / 53.4k — fixed suffix set (never "trillion"), 3 significant digits max. */ export function compact(v: number, maxSig = 3): string { const abs = Math.abs(v); const units: Array<[number, string]> = [ [1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'k'], ]; for (const [div, suffix] of units) { if (abs >= div) { const n = v / div; const digits = Math.abs(n) >= 100 ? 0 : Math.abs(n) >= 10 ? 1 : Math.min(2, maxSig - 1); return nf({ maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(n) + suffix; } } return nf({ maximumFractionDigits: abs >= 100 ? 0 : abs >= 10 ? 1 : 2 }).format(v); } /** Grouped integer-ish number: 1,234,567. */ export function grouped(v: number, digits = 0): string { return nf({ maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(v); } export function fixed(v: number, digits = 1): string { return nf({ maximumFractionDigits: digits, minimumFractionDigits: digits }).format(v); } /** Currency prefix from the unit string (US$, intl $, €…). Defaults to US$. */ function currencyPrefix(spec: FormatSpec): string { const u = (spec.unit_short ?? spec.unit ?? '').toLowerCase(); if (u.includes('intl') || u.includes('international') || u.includes('ppp')) return 'intl $'; if (u.includes('€') || u.includes('eur')) return '€'; if (u.includes('pps')) return 'PPS '; return 'US$'; } /** * Format a value according to the indicator's `format` (ARCHITECTURE §4). * `opts.compactBelow` (default 1e6) → numbers below it are grouped, above are compacted. */ export function formatValue( value: number | null | undefined, spec: FormatSpec, opts: { compactBelow?: number; withUnit?: boolean } = {}, ): string { if (!isNum(value)) return NA; const withUnit = opts.withUnit ?? true; const precision = spec.precision ?? 1; const format = (spec.format ?? 'number') as IndicatorFormat; const compactBelow = opts.compactBelow ?? 1e6; const abs = Math.abs(value); switch (format) { case 'currency': { // Always carries the unit prefix ("US$55.7k", "intl $66.7k"); compact from 10k, grouped below. const prefix = currencyPrefix(spec); const body = abs >= 1e4 ? compact(value) : grouped(value, abs < 10 ? 2 : abs < 1000 ? 1 : 0); const suffix = spec.unit_short && /\/h$/.test(spec.unit_short) ? '/h' : ''; return `${prefix}${body}${suffix}`; } case 'percent': return withUnit ? `${fixed(value, precision)} %` : fixed(value, precision); case 'years': return withUnit ? `${fixed(value, precision)} yrs` : fixed(value, precision); case 'per_1000': return withUnit ? `${fixed(value, precision)} ‰` : fixed(value, precision); case 'per_100k': return withUnit ? `${fixed(value, precision)} /100k` : fixed(value, precision); case 'per_million': return withUnit ? `${fixed(value, precision)} /M` : fixed(value, precision); case 'index': return fixed(value, precision); case 'ratio': return fixed(value, Math.max(precision, 2)); case 'celsius': return `${fixed(value, Math.max(precision, 2))} °C`; case 'tonnes': { const u = spec.unit_short ?? 't'; return withUnit ? `${abs >= compactBelow ? compact(value) : fixed(value, precision)} ${u}` : fixed(value, precision); } case 'kwh': { const u = spec.unit_short ?? 'kWh'; return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value, precision)} ${u}` : grouped(value, precision); } case 'km': return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value)} km` : grouped(value); case 'ha': return withUnit ? `${abs >= compactBelow ? compact(value) : grouped(value)} ha` : grouped(value); case 'number': default: { if (abs >= compactBelow) return compact(value); if (Number.isInteger(value) || abs >= 1000) return grouped(value); return fixed(value, precision); } } } /** * Display string for an API value object. Currencies ALWAYS go through `formatValue` (guaranteed unit * prefix, identical on server and client); other formats prefer the API's `formatted` string because it * carries the indicator precision that `MetricValue`/`RankingRow` do not expose. Use this instead of * `m.formatted ?? formatValue(m.value, m)`. */ export function displayValue(value: number | null | undefined, spec: FormatSpec, formatted?: string | null): string { if (!isNum(value)) return NA; // Unit-bearing formats: the client formatter guarantees the right unit token (the API has shipped "12.3k t" // for a million-tonnes indicator and bare "55.7k" for currencies). if (CLIENT_FORMATS.has(spec.format ?? 'number') || !formatted) return formatValue(value, spec); return formatted; } const CLIENT_FORMATS = new Set(['currency', 'tonnes', 'kwh']); /** Units whose change is expressed in points rather than percent. */ export function isPointsUnit(spec: FormatSpec): boolean { return spec.format === 'percent' || spec.format === 'index' || spec.format === 'ratio' || spec.format === 'years'; } /** * Signed change: "+2.3 pts" for percent-type units, "+4.1 %" for relative change, "+1.2M" absolute. * Prefer `changePct` for level indicators and `changeAbs` for point-type units. */ export function formatChange( changeAbs: number | null | undefined, changePct: number | null | undefined, spec: FormatSpec, ): { text: string; direction: 'up' | 'down' | 'flat' } | null { if (isPointsUnit(spec)) { if (!isNum(changeAbs)) return null; const d = changeAbs > 0 ? 'up' : changeAbs < 0 ? 'down' : 'flat'; const unit = spec.format === 'years' ? ' yrs' : spec.format === 'index' || spec.format === 'ratio' ? '' : ' pts'; return { text: `${sign(changeAbs)}${fixed(Math.abs(changeAbs), spec.precision ?? 1)}${unit}`, direction: d }; } if (isNum(changePct)) { const d = changePct > 0 ? 'up' : changePct < 0 ? 'down' : 'flat'; return { text: `${sign(changePct)}${fixed(Math.abs(changePct), 1)} %`, direction: d }; } if (isNum(changeAbs)) { const d = changeAbs > 0 ? 'up' : changeAbs < 0 ? 'down' : 'flat'; return { text: `${sign(changeAbs)}${formatValue(Math.abs(changeAbs), spec)}`, direction: d }; } return null; } function sign(v: number): string { return v > 0 ? '+' : v < 0 ? '−' : ''; } const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /** "2024" for annual, "Q2 2026" quarterly, "Jun 2026" monthly (period = first day, ISO date). */ export function formatPeriod(period: string | null | undefined, frequency: 'A' | 'Q' | 'M' | string | null | undefined = 'A'): string { if (!period) return NA; const m = /^(\d{4})-(\d{2})/.exec(period); if (!m) return period; const year = m[1]!; const month = Number(m[2]); if (frequency === 'Q') return `Q${Math.floor((month - 1) / 3) + 1} ${year}`; if (frequency === 'M') return `${MONTHS[month - 1] ?? ''} ${year}`; return year; } /** "12th of 190". */ export function formatRank(rank: number | null | undefined, n: number | null | undefined): string { if (!isNum(rank) || !isNum(n)) return NA; return t('metric.rankWorld', { rank: ordinal(rank), n: grouped(n) }); } export function ordinal(n: number): string { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return `${grouped(n)}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`; } /** "11 Sep 2026" — deterministic (UTC) date formatting. */ export function formatDate(iso: string | null | undefined, opts: { month?: 'short' | 'long' } = {}): string { if (!iso) return NA; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const day = d.getUTCDate(); const mon = MONTHS[d.getUTCMonth()] ?? ''; const month = opts.month === 'long' ? LONG_MONTHS[d.getUTCMonth()] : mon; return `${day} ${month} ${d.getUTCFullYear()}`; } const LONG_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; /** Human "3 d ago" style, computed against `now` (pass a fixed `now` on the server to keep SSR stable). */ export function relativeFreshness(iso: string | null | undefined, now: Date | number = Date.now()): string { if (!iso) return NA; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return NA; const nowMs = typeof now === 'number' ? now : now.getTime(); const s = Math.max(0, (nowMs - then) / 1000); if (s < 90) return t('common.ago.now'); const min = s / 60; if (min < 60) return t('common.ago.minutes', { n: Math.round(min) }); const h = min / 60; if (h < 36) return t('common.ago.hours', { n: Math.round(h) }); const d = h / 24; if (d < 45) return t('common.ago.days', { n: Math.round(d) }); const mo = d / 30.44; if (mo < 18) return t('common.ago.months', { n: Math.round(mo) }); return t('common.ago.years', { n: Math.round(d / 365.25) }); } /** Freshness class for a retrieval date: fresh < 45 d, recent < 400 d, else stale. */ export function freshnessLevel(iso: string | null | undefined, now: number = Date.now()): 'fresh' | 'recent' | 'stale' | null { if (!iso) return null; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return null; const days = (now - then) / 86_400_000; if (days < 45) return 'fresh'; if (days < 400) return 'recent'; return 'stale'; } /** Percent 0–1 or 0–100 → "83 %". */ export function formatPct(v: number | null | undefined, digits = 0): string { if (!isNum(v)) return NA; const p = v <= 1 ? v * 100 : v; return `${fixed(p, digits)} %`; } /** Axis tick label: compact for large magnitudes, otherwise ≤ 2 decimals, plus the short unit when useful. */ export function formatTick(v: number, spec: FormatSpec): string { const abs = Math.abs(v); if (spec.format === 'percent') return `${fixed(v, abs < 1 && abs > 0 ? 1 : 0)}%`; if (spec.format === 'currency') return `${currencyPrefix(spec)}${abs >= 1e3 ? compact(v, 2) : grouped(v)}`; if (abs >= 1e4) return compact(v, 2); if (Number.isInteger(v)) return grouped(v); return fixed(v, abs < 1 ? 2 : 1); } export const IndicatorFormats: readonly IndicatorFormat[] = [ 'number', 'percent', 'currency', 'index', 'years', 'per_1000', 'per_100k', 'per_million', 'ratio', 'celsius', 'tonnes', 'kwh', 'ha', 'km', ];