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;
}