/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/widget/src/render.ts * Purpose: Pure, DOM-free widget helpers — config resolution, model payload parsing, display text building (unit-testable) */ import { type CounterModel, type CounterWindow, formatRate, formatValue, rateAt, windowValue, } from "@earth-now/counter"; /** Default API origin the widget talks to when data-api is not provided. */ export const DEFAULT_API = "https://www.earth-now.co"; export interface WidgetConfig { metricId: string; window: CounterWindow; lang: string; api: string; } /** * Resolve a widget config from a DOMStringMap-like object * (data-earth-now-metric / data-window / data-lang / data-api). * Returns null when no metric id is declared. */ export function resolveConfig(dataset: Partial>): WidgetConfig | null { const metricId = dataset["earthNowMetric"]?.trim(); if (!metricId) return null; const w = dataset["window"]; const window: CounterWindow = w === "today" || w === "ytd" || w === "session" || w === "total" ? w : "total"; return { metricId, window, lang: dataset["lang"] ?? "en", api: (dataset["api"] ?? DEFAULT_API).replace(/\/+$/, ""), }; } /** URL of the CounterModel endpoint for a config. */ export function modelUrl(config: WidgetConfig): string { return `${config.api}/v1/metrics/${encodeURIComponent(config.metricId)}/model`; } /** * Validate a fetched JSON payload into a CounterModel. * Accepts either the model itself or an `{ model: ... }` envelope. * Throws a descriptive error on anything malformed — the caller renders "data pending". */ export function parseModelPayload(json: unknown): CounterModel { const candidate = typeof json === "object" && json !== null && "model" in json ? (json as { model: unknown }).model : json; if (typeof candidate !== "object" || candidate === null) throw new Error("earth-now widget: model payload is not an object"); const m = candidate as Partial; if (typeof m.metricId !== "string") throw new Error("earth-now widget: model missing metricId"); if (typeof m.anchorValue !== "number" || !Number.isFinite(m.anchorValue)) throw new Error("earth-now widget: model missing finite anchorValue"); if (typeof m.anchorTime !== "string" || Number.isNaN(Date.parse(m.anchorTime))) throw new Error("earth-now widget: model missing valid anchorTime"); if (typeof m.rateFn !== "object" || m.rateFn === null || typeof m.rateFn.kind !== "string") throw new Error("earth-now widget: model missing rateFn"); if (typeof m.displayHints !== "object" || m.displayHints === null) throw new Error("earth-now widget: model missing displayHints"); return m as CounterModel; } export interface DisplayText { /** Formatted counter value (applySigFigs false — the ticker animates the trailing digits). */ value: string; /** Formatted instantaneous rate incl. cadence suffix, e.g. "4.3/s". */ rate: string; /** Display unit label from the model's display hints. */ unit: string; } /** * Compute the text a widget shows at instant tMs — pure, time is a parameter. * "session" windows use the caller-provided session start (widget load time). */ export function buildDisplayText( model: CounterModel, tMs: number, config: WidgetConfig, sessionStartMs?: number, ): DisplayText { const v = windowValue(model, tMs, config.window, sessionStartMs); const opts = { locale: config.lang, applySigFigs: false }; return { value: formatValue(v, model.displayHints, opts), rate: formatRate(rateAt(model, tMs), model.displayHints, { locale: config.lang }), unit: model.displayHints.unit, }; }