import { formatPartialDate } from "@dci/core"; export { formatPartialDate }; const EN = "en-US"; const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); /** "—" placeholder for unknown values (never render 0 for unknown). */ export const DASH = "—"; /** Integer with thousands separators. */ export function fmtInt(n: number | null | undefined): string { if (!isNum(n)) return DASH; return new Intl.NumberFormat(EN, { maximumFractionDigits: 0 }).format(n); } /** `toFixed` with trailing zeros stripped ("15.0" → "15", "1.20" → "1.2"). */ function fixed(n: number, digits: number): string { const s = n.toFixed(digits); return s.includes(".") ? s.replace(/\.?0+$/, "") : s; } /** * Compact numbers: 1.2K, 3.4M, 15B. Hand-rolled (K/M/B/T) rather than `Intl` `notation: "compact"`: * ICU builds differ between Node and browsers on how `maximumFractionDigits` rounds compact values * ("$15.0B" vs "$15B"), which produced React hydration mismatches (#418) on the live feed. */ export function fmtCompact(n: number | null | undefined, digits = 1): string { if (!isNum(n)) return DASH; const sign = n < 0 ? "−" : ""; const abs = Math.abs(n); if (abs < 1000) return `${sign}${new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(abs)}`; const units: Array<[number, string]> = [ [1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"], ]; for (const [v, suffix] of units) { if (abs >= v) return `${sign}${fixed(abs / v, digits)}${suffix}`; } return `${sign}${fixed(abs, digits)}`; } /** Split a MW value into { value, unit } — GW when ≥ 1000 MW; 0–1 decimals. */ export function splitMw(mw: number | null | undefined): { value: string; unit: "MW" | "GW" } | null { if (!isNum(mw)) return null; if (Math.abs(mw) >= 1000) { const gw = mw / 1000; return { value: new Intl.NumberFormat(EN, { maximumFractionDigits: gw >= 100 ? 0 : gw >= 10 ? 1 : 2 }).format(gw), unit: "GW" }; } const digits = mw >= 100 ? 0 : Number.isInteger(mw) ? 0 : 1; return { value: new Intl.NumberFormat(EN, { minimumFractionDigits: 0, maximumFractionDigits: digits }).format(mw), unit: "MW" }; } /** "120 MW", "1.2 GW", "—". */ export function fmtMw(mw: number | null | undefined): string { const s = splitMw(mw); return s ? `${s.value} ${s.unit}` : DASH; } /** Compact USD: $1.2B, $450M, $15B — deterministic between server and client (see fmtCompact). */ export function fmtUsd(v: number | null | undefined): string { if (!isNum(v)) return DASH; const abs = Math.abs(v); const body = fmtCompact(abs, abs >= 1e9 ? 1 : 0); return `${v < 0 ? "−" : ""}$${body}`; } export function fmtPct(share: number | null | undefined, digits = 0): string { if (!isNum(share)) return DASH; return `${(share * 100).toFixed(digits)}%`; } export function fmtDelta(n: number | null | undefined, digits = 0): string { if (!isNum(n) || n === 0) return isNum(n) ? "0" : DASH; const sign = n > 0 ? "+" : "−"; return `${sign}${new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(Math.abs(n))}`; } /** Full date (UTC-safe) — "Sep 11, 2026". Accepts ISO or partial dates. */ export function fmtDate(iso: string | null | undefined): string { if (!iso) return DASH; if (/^\d{4}(-\d{2})?(-[QH]\d)?$/.test(iso) || /^\d{4}-[QH]\d$/.test(iso)) return formatPartialDate(iso); const d = new Date(iso); if (Number.isNaN(d.getTime())) return formatPartialDate(iso); return new Intl.DateTimeFormat(EN, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }).format(d); } /** Date + time in UTC — deterministic between server and client. */ export function fmtDateTimeUtc(iso: string | null | undefined): string { if (!iso) return DASH; const d = new Date(iso); if (Number.isNaN(d.getTime())) return DASH; return `${new Intl.DateTimeFormat(EN, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }).format(d)} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")} UTC`; } /** Day key for grouping (UTC): "2026-09-11". */ export function dayKey(iso: string): string { return iso.slice(0, 10); } /** "Today", "Yesterday", or "Sep 9, 2026" — relative to a reference (UTC). */ export function fmtDayHeading(day: string, now: Date = new Date()): string { const today = now.toISOString().slice(0, 10); const y = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); if (day === today) return "Today"; if (day === y) return "Yesterday"; return fmtDate(day); } /** Relative time: "3 min ago", "2 h ago", "5 d ago". Client-only (see ). */ export function fmtRelative(iso: string | null | undefined, now: number = Date.now()): string { if (!iso) return DASH; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return DASH; const diff = Math.max(0, now - t); const s = Math.round(diff / 1000); if (s < 45) return "just now"; const m = Math.round(s / 60); if (m < 60) return `${m} min ago`; const h = Math.round(m / 60); if (h < 36) return `${h} h ago`; const d = Math.round(h / 24); if (d < 30) return `${d} d ago`; const mo = Math.round(d / 30); if (mo < 18) return `${mo} mo ago`; return `${Math.round(d / 365)} y ago`; } /** Renders an arbitrary event value (number, string, object) for the change feed. */ export function fmtValue(v: unknown, hint?: "mw" | "date" | "usd"): string { if (v === null || v === undefined || v === "") return DASH; if (typeof v === "number") { if (hint === "mw") return fmtMw(v); if (hint === "usd") return fmtUsd(v); if (!Number.isInteger(v)) return new Intl.NumberFormat(EN, { maximumFractionDigits: 4 }).format(v); return fmtInt(v); } if (typeof v === "string") { if (hint === "date" || /^\d{4}(-\d{2}(-\d{2})?|-[QH]\d)?$/.test(v)) return fmtDate(v); return v.length > 80 ? `${v.slice(0, 77)}…` : v.replace(/_/g, " "); } if (typeof v === "boolean") return v ? "yes" : "no"; if (Array.isArray(v)) return v.map((x) => fmtValue(x)).join(", "); if (typeof v === "object") { const o = v as Record; if ("value" in o && Object.keys(o).length <= 2) return fmtValue(o.value, hint); if (typeof o.lat === "number" && typeof o.lng === "number") { const extra = [typeof o.precision === "string" ? o.precision : null, typeof o.source === "string" ? o.source : null].filter(Boolean).join(" · "); return `${o.lat.toFixed(4)}, ${o.lng.toFixed(4)}${extra ? ` (${extra})` : ""}`; } // Structured event payloads ({ code, name, status, mw, operators, countries, url… }) → compact human line const parts: string[] = []; if (typeof o.name === "string") parts.push(o.name); if (typeof o.code === "string" && o.code !== o.name) parts.push(`(${o.code})`); if (typeof o.status === "string") parts.push(o.status.replace(/_/g, " ")); if (typeof o.mw === "number") parts.push(fmtMw(o.mw)); if (typeof o.plannedMw === "number") parts.push(`≈ ${fmtMw(o.plannedMw)}`); if (typeof o.investmentUsd === "number") parts.push(fmtUsd(o.investmentUsd)); if (typeof o.date === "string") parts.push(fmtDate(o.date)); if (Array.isArray(o.operators) && o.operators.length) parts.push(o.operators.slice(0, 2).join(", ")); if (Array.isArray(o.countries) && o.countries.length) parts.push(o.countries.slice(0, 3).join(", ")); if (parts.length) return parts.join(" · "); const entries = Object.entries(o).filter(([k, val]) => k !== "url" && val !== null && val !== undefined && typeof val !== "object"); if (entries.length) return entries.slice(0, 3).map(([k, val]) => `${k.replace(/_/g, " ")}: ${fmtValue(val)}`).join(" · "); return DASH; } return String(v); } /** Country iso2 → emoji flag (regional indicators). */ export function flagEmoji(iso2: string | null | undefined): string { if (!iso2 || iso2.length !== 2) return ""; const cc = iso2.toUpperCase(); if (!/^[A-Z]{2}$/.test(cc)) return ""; return String.fromCodePoint(...[...cc].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); } /** Country name from iso2 via Intl.DisplayNames (falls back to the code). */ export function countryName(iso2: string | null | undefined): string { if (!iso2) return DASH; try { return new Intl.DisplayNames([EN], { type: "region" }).of(iso2.toUpperCase()) ?? iso2; } catch { return iso2; } } /** "Montreal, CA" style location line. */ export function placeLine(parts: { city?: string | null; regionName?: string | null; countryIso2?: string | null; countryName?: string | null }): string { const out: string[] = []; if (parts.city) out.push(parts.city); if (parts.regionName && parts.regionName !== parts.city) out.push(parts.regionName); const c = parts.countryName ?? (parts.countryIso2 ? countryName(parts.countryIso2) : null); if (c && c !== DASH) out.push(c); return out.length ? out.join(", ") : DASH; } /** Best MW figure for a facility summary (IT → total → planned), with a flag telling which one. */ export function bestMw(f: { itCapacityMw: number | null; totalPowerMw: number | null; plannedPowerMw: number | null }): { mw: number | null; kind: "it" | "total" | "planned" | null } { if (isNum(f.itCapacityMw)) return { mw: f.itCapacityMw, kind: "it" }; if (isNum(f.totalPowerMw)) return { mw: f.totalPowerMw, kind: "total" }; if (isNum(f.plannedPowerMw)) return { mw: f.plannedPowerMw, kind: "planned" }; return { mw: null, kind: null }; } export function pluralize(n: number, one: string, many = `${one}s`): string { return n === 1 ? one : many; }