SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
9.4 KB · 211 lines typescript
Raw Blame History
1import { formatPartialDate } from "@dci/core";23export { formatPartialDate };45const EN = "en-US";6const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);78/** "—" placeholder for unknown values (never render 0 for unknown). */9export const DASH = "—";1011/** Integer with thousands separators. */12export function fmtInt(n: number | null | undefined): string {13  if (!isNum(n)) return DASH;14  return new Intl.NumberFormat(EN, { maximumFractionDigits: 0 }).format(n);15}1617/** `toFixed` with trailing zeros stripped ("15.0" → "15", "1.20" → "1.2"). */18function fixed(n: number, digits: number): string {19  const s = n.toFixed(digits);20  return s.includes(".") ? s.replace(/\.?0+$/, "") : s;21}2223/**24 * Compact numbers: 1.2K, 3.4M, 15B. Hand-rolled (K/M/B/T) rather than `Intl` `notation: "compact"`:25 * ICU builds differ between Node and browsers on how `maximumFractionDigits` rounds compact values26 * ("$15.0B" vs "$15B"), which produced React hydration mismatches (#418) on the live feed.27 */28export function fmtCompact(n: number | null | undefined, digits = 1): string {29  if (!isNum(n)) return DASH;30  const sign = n < 0 ? "−" : "";31  const abs = Math.abs(n);32  if (abs < 1000) return `${sign}${new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(abs)}`;33  const units: Array<[number, string]> = [34    [1e12, "T"],35    [1e9, "B"],36    [1e6, "M"],37    [1e3, "K"],38  ];39  for (const [v, suffix] of units) {40    if (abs >= v) return `${sign}${fixed(abs / v, digits)}${suffix}`;41  }42  return `${sign}${fixed(abs, digits)}`;43}4445/** Split a MW value into { value, unit } — GW when ≥ 1000 MW; 0–1 decimals. */46export function splitMw(mw: number | null | undefined): { value: string; unit: "MW" | "GW" } | null {47  if (!isNum(mw)) return null;48  if (Math.abs(mw) >= 1000) {49    const gw = mw / 1000;50    return { value: new Intl.NumberFormat(EN, { maximumFractionDigits: gw >= 100 ? 0 : gw >= 10 ? 1 : 2 }).format(gw), unit: "GW" };51  }52  const digits = mw >= 100 ? 0 : Number.isInteger(mw) ? 0 : 1;53  return { value: new Intl.NumberFormat(EN, { minimumFractionDigits: 0, maximumFractionDigits: digits }).format(mw), unit: "MW" };54}5556/** "120 MW", "1.2 GW", "—". */57export function fmtMw(mw: number | null | undefined): string {58  const s = splitMw(mw);59  return s ? `${s.value} ${s.unit}` : DASH;60}6162/** Compact USD: $1.2B, $450M, $15B — deterministic between server and client (see fmtCompact). */63export function fmtUsd(v: number | null | undefined): string {64  if (!isNum(v)) return DASH;65  const abs = Math.abs(v);66  const body = fmtCompact(abs, abs >= 1e9 ? 1 : 0);67  return `${v < 0 ? "−" : ""}$${body}`;68}6970export function fmtPct(share: number | null | undefined, digits = 0): string {71  if (!isNum(share)) return DASH;72  return `${(share * 100).toFixed(digits)}%`;73}7475export function fmtDelta(n: number | null | undefined, digits = 0): string {76  if (!isNum(n) || n === 0) return isNum(n) ? "0" : DASH;77  const sign = n > 0 ? "+" : "−";78  return `${sign}${new Intl.NumberFormat(EN, { maximumFractionDigits: digits }).format(Math.abs(n))}`;79}8081/** Full date (UTC-safe) — "Sep 11, 2026". Accepts ISO or partial dates. */82export function fmtDate(iso: string | null | undefined): string {83  if (!iso) return DASH;84  if (/^\d{4}(-\d{2})?(-[QH]\d)?$/.test(iso) || /^\d{4}-[QH]\d$/.test(iso)) return formatPartialDate(iso);85  const d = new Date(iso);86  if (Number.isNaN(d.getTime())) return formatPartialDate(iso);87  return new Intl.DateTimeFormat(EN, { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }).format(d);88}8990/** Date + time in UTC — deterministic between server and client. */91export function fmtDateTimeUtc(iso: string | null | undefined): string {92  if (!iso) return DASH;93  const d = new Date(iso);94  if (Number.isNaN(d.getTime())) return DASH;95  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`;96}9798/** Day key for grouping (UTC): "2026-09-11". */99export function dayKey(iso: string): string {100  return iso.slice(0, 10);101}102103/** "Today", "Yesterday", or "Sep 9, 2026" — relative to a reference (UTC). */104export function fmtDayHeading(day: string, now: Date = new Date()): string {105  const today = now.toISOString().slice(0, 10);106  const y = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);107  if (day === today) return "Today";108  if (day === y) return "Yesterday";109  return fmtDate(day);110}111112/** Relative time: "3 min ago", "2 h ago", "5 d ago". Client-only (see <RelativeTime/>). */113export function fmtRelative(iso: string | null | undefined, now: number = Date.now()): string {114  if (!iso) return DASH;115  const t = new Date(iso).getTime();116  if (Number.isNaN(t)) return DASH;117  const diff = Math.max(0, now - t);118  const s = Math.round(diff / 1000);119  if (s < 45) return "just now";120  const m = Math.round(s / 60);121  if (m < 60) return `${m} min ago`;122  const h = Math.round(m / 60);123  if (h < 36) return `${h} h ago`;124  const d = Math.round(h / 24);125  if (d < 30) return `${d} d ago`;126  const mo = Math.round(d / 30);127  if (mo < 18) return `${mo} mo ago`;128  return `${Math.round(d / 365)} y ago`;129}130131/** Renders an arbitrary event value (number, string, object) for the change feed. */132export function fmtValue(v: unknown, hint?: "mw" | "date" | "usd"): string {133  if (v === null || v === undefined || v === "") return DASH;134  if (typeof v === "number") {135    if (hint === "mw") return fmtMw(v);136    if (hint === "usd") return fmtUsd(v);137    if (!Number.isInteger(v)) return new Intl.NumberFormat(EN, { maximumFractionDigits: 4 }).format(v);138    return fmtInt(v);139  }140  if (typeof v === "string") {141    if (hint === "date" || /^\d{4}(-\d{2}(-\d{2})?|-[QH]\d)?$/.test(v)) return fmtDate(v);142    return v.length > 80 ? `${v.slice(0, 77)}…` : v.replace(/_/g, " ");143  }144  if (typeof v === "boolean") return v ? "yes" : "no";145  if (Array.isArray(v)) return v.map((x) => fmtValue(x)).join(", ");146  if (typeof v === "object") {147    const o = v as Record<string, unknown>;148    if ("value" in o && Object.keys(o).length <= 2) return fmtValue(o.value, hint);149    if (typeof o.lat === "number" && typeof o.lng === "number") {150      const extra = [typeof o.precision === "string" ? o.precision : null, typeof o.source === "string" ? o.source : null].filter(Boolean).join(" · ");151      return `${o.lat.toFixed(4)}, ${o.lng.toFixed(4)}${extra ? ` (${extra})` : ""}`;152    }153    // Structured event payloads ({ code, name, status, mw, operators, countries, url… }) → compact human line154    const parts: string[] = [];155    if (typeof o.name === "string") parts.push(o.name);156    if (typeof o.code === "string" && o.code !== o.name) parts.push(`(${o.code})`);157    if (typeof o.status === "string") parts.push(o.status.replace(/_/g, " "));158    if (typeof o.mw === "number") parts.push(fmtMw(o.mw));159    if (typeof o.plannedMw === "number") parts.push(`≈ ${fmtMw(o.plannedMw)}`);160    if (typeof o.investmentUsd === "number") parts.push(fmtUsd(o.investmentUsd));161    if (typeof o.date === "string") parts.push(fmtDate(o.date));162    if (Array.isArray(o.operators) && o.operators.length) parts.push(o.operators.slice(0, 2).join(", "));163    if (Array.isArray(o.countries) && o.countries.length) parts.push(o.countries.slice(0, 3).join(", "));164    if (parts.length) return parts.join(" · ");165    const entries = Object.entries(o).filter(([k, val]) => k !== "url" && val !== null && val !== undefined && typeof val !== "object");166    if (entries.length) return entries.slice(0, 3).map(([k, val]) => `${k.replace(/_/g, " ")}: ${fmtValue(val)}`).join(" · ");167    return DASH;168  }169  return String(v);170}171172/** Country iso2 → emoji flag (regional indicators). */173export function flagEmoji(iso2: string | null | undefined): string {174  if (!iso2 || iso2.length !== 2) return "";175  const cc = iso2.toUpperCase();176  if (!/^[A-Z]{2}$/.test(cc)) return "";177  return String.fromCodePoint(...[...cc].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));178}179180/** Country name from iso2 via Intl.DisplayNames (falls back to the code). */181export function countryName(iso2: string | null | undefined): string {182  if (!iso2) return DASH;183  try {184    return new Intl.DisplayNames([EN], { type: "region" }).of(iso2.toUpperCase()) ?? iso2;185  } catch {186    return iso2;187  }188}189190/** "Montreal, CA" style location line. */191export function placeLine(parts: { city?: string | null; regionName?: string | null; countryIso2?: string | null; countryName?: string | null }): string {192  const out: string[] = [];193  if (parts.city) out.push(parts.city);194  if (parts.regionName && parts.regionName !== parts.city) out.push(parts.regionName);195  const c = parts.countryName ?? (parts.countryIso2 ? countryName(parts.countryIso2) : null);196  if (c && c !== DASH) out.push(c);197  return out.length ? out.join(", ") : DASH;198}199200/** Best MW figure for a facility summary (IT → total → planned), with a flag telling which one. */201export function bestMw(f: { itCapacityMw: number | null; totalPowerMw: number | null; plannedPowerMw: number | null }): { mw: number | null; kind: "it" | "total" | "planned" | null } {202  if (isNum(f.itCapacityMw)) return { mw: f.itCapacityMw, kind: "it" };203  if (isNum(f.totalPowerMw)) return { mw: f.totalPowerMw, kind: "total" };204  if (isNum(f.plannedPowerMw)) return { mw: f.plannedPowerMw, kind: "planned" };205  return { mw: null, kind: null };206}207208export function pluralize(n: number, one: string, many = `${one}s`): string {209  return n === 1 ? one : many;210}211