/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/lib/derived.ts * Purpose: Pure derived-metric transforms used by LiveCounter — primary window, depletion countdown, model resolution, display hints */ import { counterValue, rateAt, windowValue, type CounterModel, type CounterWindow, type DisplayHints, } from "@earth-now/counter"; import type { Locale, MetricSummary } from "./api"; /** Mean Gregorian year in seconds (365.2425 × 86 400) — matches YEAR_SECONDS in @earth-now/counter. */ export const YEAR_SECONDS = 31_556_952; /** * The window a metric's card should display by default: * derived op=window → that window; cumulative → ytd; everything else → total. */ export function resolvePrimaryWindow(metric: MetricSummary): CounterWindow { if (metric.derived?.op === "window" && metric.derived.window !== undefined) { return metric.derived.window; } // The registry declares window priority: windows[0] is the primary reading // (e.g. cigarettes/coffee/GDP lead with "today", births with "ytd"). const declared = metric.windows[0]; if (declared === "today" || declared === "ytd" || declared === "session" || declared === "total") { return declared; } if (metric.kind === "cumulative") return "ytd"; return "total"; } /** * Years until a depleting stock reaches zero at the current instantaneous rate: * value(t) / (−rate(t) × YEAR_SECONDS). NaN when the stock is not depleting * (rate ≥ 0) — the caller renders the "data pending" fallback. */ export function depletionYears(model: CounterModel, tMs: number): number { const remaining = counterValue(model, tMs); const perSecond = rateAt(model, tMs); const perYear = -perSecond * YEAR_SECONDS; if (!Number.isFinite(remaining) || !(perYear > 0)) return NaN; return remaining / perYear; } /** * Model powering a metric's card: its own CounterModel when the API materialized * one, otherwise the model of its first derived input (rate-of / depletion-countdown * / window metrics are transforms over an input model). */ export function resolveModel( metric: MetricSummary, models: Record, ): CounterModel | undefined { const own = models[metric.id]; if (own !== undefined) return own; const inputId = metric.derived?.inputs[0]?.id; return inputId !== undefined ? models[inputId] : undefined; } /** DisplayHints for the shared formatters, with the unit localized from the registry. */ export function hintsFor(metric: MetricSummary, locale: Locale): DisplayHints { return { decimals: metric.display.decimals, unit: metric.display.unit[locale], ...(metric.display.sigFigs !== undefined ? { sigFigs: metric.display.sigFigs } : {}), ...(metric.display.scale !== undefined ? { scale: metric.display.scale } : {}), }; } /** * Raw (unformatted) number a card displays at time t, applying the metric's * derived transform and window. Pure — time is a parameter. Returns NaN instead * of throwing when a session window has no session start yet. */ export function displayRawValue( metric: MetricSummary, model: CounterModel, tMs: number, window: CounterWindow, sessionStartMs?: number, ): number { const op = metric.derived?.op; if (op === "rate-of") return rateAt(model, tMs); if (op === "depletion-countdown") return depletionYears(model, tMs); if (window === "total") return counterValue(model, tMs); if (window === "session" && sessionStartMs === undefined) return NaN; return windowValue(model, tMs, window, sessionStartMs); } /** Whether a card must carry the mandatory "estimation / estimate" label. */ export function isEstimate(metric: MetricSummary): boolean { return metric.uncertaintyFraction !== undefined; }