SPB Git

spb/earth-now Public License

earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.

TypeScript 93% Shell 2.3% SQL 1.4% JavaScript 1.3% Dockerfile 1.2% CSS 0.8%

earth-now.co v0.1 — real-time planetary dashboard

Monorepo (pnpm + Turborepo): pure CounterModel runtime (packages/counter),
statistical fitting + guardrails (packages/models: PCHIP, Fourier,
Holt-Winters, Kalman), 76-metric YAML registry with real-data fixtures,
Fastify API (REST + SSE + SVG badges + USGS/Open Notify RT pollers),
Next.js 14 live-first dashboard (light theme, tier system, per-metric
pages, country/continent rankings, session strip), 3.2 KB embeddable
widget, ingestion scaffold (USGS + NOAA CO2 parsers), infra (PM2 deploy
+ Docker Compose variant). 142 tests: golden, property-based (fast-check)
and bit-identical client/server consistency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026)

Showing 153 changed files with +15,859 and −0

added .env.example +21 −0
@@ -0,0 +1,21 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: .env.example
5 +# Purpose: Environment template — copy to .env; NEVER commit real secrets (ngrok token, DB password)
6 +
7 +# apps/api
8 +PORT=4000
9 +ENABLE_RT_POLLERS=1
10 +
11 +# apps/web
12 +NEXT_PUBLIC_API_URL=http://localhost:4000
13 +API_URL=http://localhost:4000
14 +
15 +# apps/ingest (Redis-less dev mode when unset)
16 +REDIS_URL=
17 +RAW_ARCHIVE_DIR=data/raw
18 +
19 +# infra (production, node m3u96b)
20 +POSTGRES_PASSWORD=
21 +NGROK_AUTHTOKEN=
added .gitignore +11 −0
@@ -0,0 +1,11 @@
1 +node_modules/
2 +dist/
3 +.next/
4 +.turbo/
5 +coverage/
6 +*.log
7 +.env
8 +.env.*
9 +!.env.example
10 +.DS_Store
11 +data/
added CLAUDE.md +205 −0
@@ -0,0 +1,205 @@
1 +# CLAUDE.md — earth-now.co
2 +
3 +## Project Overview
4 +
5 +**earth-now.co** is a real-time planetary dashboard — a robust successor to Worldometer. The site displays live world metrics (population, births, deaths, CO₂ emissions, temperature anomaly, forest loss, energy production, internet users, etc.) that tick continuously on screen.
6 +
7 +The fundamental difference from Worldometer: our counters are not opaque linear extrapolations. Every metric is driven by a **documented statistical model** that interpolates between the latest real observations and forecasts from authoritative sources, with seasonality and uncertainty intervals.
8 +
9 +Product pillars:
10 +1. **Live everywhere**: dashboard, shareable public pages (`/m/:token`), embeddable widgets (JS, iframe, SVG badge).
11 +2. **Statistical honesty**: every number is traceable (source, observation date, interpolation method, uncertainty).
12 +3. **Robustness**: resilient ingestion pipeline; never a frozen or absurd counter in production.
13 +
14 +---
15 +
16 +## Architecture Principle #1: A Counter Is a Model, Not a Stream
17 +
18 +**NEVER push one value per second over the network.** The server ships a **CounterModel**; the client animates locally.
19 +
20 +```typescript
21 +// The project's central contract — types/counter-model.ts
22 +interface CounterModel {
23 + metricId: string; // e.g. "co2_emissions_ytd"
24 + anchorValue: number; // value at the anchor point
25 + anchorTime: string; // ISO 8601 UTC of the anchor point
26 + rateFn: RateFunction; // how the value evolves from the anchor
27 + uncertainty?: { low: number; high: number }; // 90% CI at time T
28 + observedAt: string; // date of the last REAL observation
29 + sourceId: string; // key in the source registry
30 + modelVersion: string; // e.g. "seasonal-spline-v2"
31 + displayHints: { decimals: number; sigFigs?: number; unit: string };
32 +}
33 +
34 +type RateFunction =
35 + | { kind: "linear"; perSecond: number }
36 + | { kind: "piecewise"; segments: Array<{ from: string; perSecond: number }> }
37 + | { kind: "seasonal"; base: number; harmonics: Harmonic[] } // Fourier
38 + | { kind: "spline"; knots: Array<[time: string, value: number]> };
39 +```
40 +
41 +The client computes `value(t)` at 60 fps with `requestAnimationFrame` from the model. The server only pushes (via SSE) a **new model** when: new data is ingested, a new forecast arrives, or a correction is made. Result: zero per-tick traffic, perfect consistency across all clients (same model ⇒ same value at the same instant t), and the SVG badge uses the exact same function server-side.
42 +
43 +---
44 +
45 +## Statistical Methodology (the heart of the product)
46 +
47 +Every metric declares its model level in the registry. Escalation by level:
48 +
49 +**Level 0 — Linear**: `rate = (forecast_annual_value - last_obs) / remaining_seconds`. Acceptable only as a fallback or for genuinely quasi-linear metrics.
50 +
51 +**Level 1 — Seasonal**: trend + seasonality decomposition (Fourier harmonics or STL). Mandatory for: births/deaths (demographic seasonality), CO₂ concentration (Keeling curve: ±3 ppm annual cycle), energy consumption, fires/deforestation.
52 +
53 +**Level 2 — Observation/forecast fusion**: when a source publishes both delayed observations and projections (e.g. UN WPP, Global Carbon Budget, NOAA), we blend:
54 +- Interpolation via **monotone spline (PCHIP)** between observed points — never a natural cubic spline (possible overshoot ⇒ counters absurdly going backwards).
55 +- Observation→forecast junction via a **Kalman filter** or exponential weighting: near the last observation we track the observed data; further out we converge toward the forecast.
56 +- **Holt-Winters** internally for nowcasting frequently-published metrics (weekly/monthly) when the source provides no forecast.
57 +
58 +**Uncertainty**: propagate source CIs (e.g. UN low/median/high variants) into `uncertainty`. The UI may render a band. Absolute rule: `displayHints.sigFigs` caps significant digits — we NEVER display more precision than the model justifies (world population to the unit is a lie; we animate the unit digit but the tooltip shows the CI).
59 +
60 +**Validity constraints** (enforced by `packages/models/validate.ts`):
61 +- Cumulative counters (YTD births, YTD CO₂ emitted): `rateFn` strictly ≥ 0, reset on Jan 1 UTC via `piecewise`.
62 +- Stocks (population, CO₂ ppm): may move both ways, but |derivative| bounded by a max declared in the registry.
63 +- Every re-fitted model is diffed against the previous one: a jump > declared threshold ⇒ model deployment is blocked and an alert fires (no counter "teleportation" in prod; we catch up with a 60 s client-side smoothing transition).
64 +
65 +---
66 +
67 +## Metric & Source Registry
68 +
69 +Everything goes through `packages/registry/metrics/*.yaml`. A metric does not exist until it is declared there. Example:
70 +
71 +```yaml
72 +id: world_population
73 +name: { fr: "Population mondiale", en: "World population" }
74 +unit: people
75 +kind: stock
76 +model: seasonal-spline-v2 # level 2
77 +sources:
78 + - id: un_wpp_2024
79 + url: https://population.un.org/wpp/
80 + license: CC BY 3.0 IGO
81 + cadence: yearly
82 + variants: [low, median, high]
83 +refresh: on_source_update
84 +display: { decimals: 0, sigFigs: 7 }
85 +constraints: { maxAbsRatePerSec: 10 }
86 +```
87 +
88 +Priority sources: UN WPP (demographics), Global Carbon Project + NOAA GML (CO₂), Copernicus/ERA5 (temperature), FAO & Global Forest Watch (forests), Ember/IEA (energy), USGS (earthquakes — special case: true event-driven real time, no interpolation), OWID as a secondary aggregator. **Always check and honor each source's license; attribution is displayed in the UI and in API responses.**
89 +
90 +---
91 +
92 +## Architecture
93 +
94 +```
95 +apps/
96 + web/ # Next.js 14 (App Router) — dashboard, /m/:token pages, /embed
97 + api/ # Fastify (TS) — REST + SSE
98 + ingest/ # Ingestion workers (cron + BullMQ queue)
99 +packages/
100 + registry/ # Metric + source YAML (product source of truth)
101 + models/ # Interpolators, fitting, validation (pure, no I/O)
102 + counter/ # Shared client runtime: value(model, t) — used by web, widget AND badge
103 + widget/ # Embeddable widget.js (IIFE build < 15 KB gzip, zero dependencies)
104 +infra/ # Docker, Terraform, migrations
105 +```
106 +
107 +Data flow:
108 +1. `ingest` downloads/parses sources on their cadence (exponential retry, checksums, raw archive to S3 — we keep ALL raw data for re-fitting).
109 +2. Normalization → `observations` (append-only, PostgreSQL + TimescaleDB).
110 +3. `models` re-fits the CounterModel of each impacted metric → validation → versioned in DB + Redis cache.
111 +4. Redis pub/sub publish on `metric:{id}` → SSE servers relay the new model to connected clients.
112 +5. SVG/PNG badges: rendered server-side with the same `packages/counter`, CDN cache 60 s.
113 +
114 +Database (key tables): `observations` (append-only), `counter_models` (versioned, never UPDATEd), `metrics_registry_sync`, `share_tokens` (revocable, per-metric visibility), `ingest_runs` (full auditability of every run).
115 +
116 +---
117 +
118 +## Stack
119 +
120 +- **Strict TypeScript everywhere** (`"strict": true`, no unjustified `any`). Node 20+, pnpm workspaces, Turborepo.
121 +- Next.js 14 + Tailwind (web); Fastify (api); BullMQ + Redis (ingest); PostgreSQL 16 + TimescaleDB; Drizzle ORM + SQL migrations.
122 +- Statistical fitting: TS implementations in `packages/models` (PCHIP, least-squares Fourier fit, Holt-Winters, 1D Kalman). If a heavy fit justifies it, an isolated Python service (scipy/statsmodels) under `apps/ingest/fitters-py` called by the orchestrator — but prefer TS whenever possible to share code with the client.
123 +- Real time: **SSE** (EventSource) for model distribution; no WebSocket unless a future bidirectional need arises. Reconnection: `Last-Event-ID` + Redis Streams (15 min buffer).
124 +- Cloudflare CDN in front of `/badge/*` and `widget.js`.
125 +
126 +## Mandatory File Header
127 +
128 +**Every source code file in this repository (`.ts`, `.tsx`, `.js`, `.py`, `.sql`, `.sh`, config files where comments are allowed) MUST start with the following author header:**
129 +
130 +```typescript
131 +/**
132 + * earth-now.co
133 + * Author: Simon-Pierre Boucher
134 + * Contact: contact@spboucher.ai
135 + * File: <relative/path/to/file.ts>
136 + * Purpose: <one-line description of what this file does>
137 + */
138 +```
139 +
140 +Adapt the comment syntax to the language (`#` for Python/YAML/shell, `--` for SQL). Rules:
141 +- The `File:` line must match the file's actual path in the repo — update it if the file is moved/renamed.
142 +- `Purpose:` is one line, kept accurate when the file's role changes.
143 +- CI lint rule (`scripts/check-headers.ts`, run in `pnpm lint`) fails any file missing or with a malformed header. When creating a new file, Claude must add this header first, before any code.
144 +- Generated files (build output, lockfiles, migrations auto-generated by drizzle-kit) are exempt and listed in `scripts/check-headers.ignore`.
145 +
146 +## Deployment
147 +
148 +- Production runs on **node `m3u96b`**, exposed publicly through **ngrok** at **www.earth-now.co**.
149 +- Stack on `m3u96b`: Docker Compose (`infra/docker-compose.prod.yml`) running web, api, ingest, PostgreSQL/Timescale, and Redis; ngrok tunnel (reserved domain `www.earth-now.co`) fronting the web/api reverse proxy on port 8080.
150 +- Deploy flow:
151 + ```bash
152 + pnpm build # turbo build of all apps
153 + pnpm deploy:m3u96b # rsync + docker compose up -d on node m3u96b
154 + pnpm tunnel:status # verify the ngrok tunnel is up and serving www.earth-now.co
155 + ```
156 +- ngrok config lives in `infra/ngrok.yml` (reserved domain + edge). The ngrok authtoken is provided via environment/secret manager — **never commit it**.
157 +- Health check after every deploy: `GET https://www.earth-now.co/api/health` must return 200 and `GET /sse/health` must hold an SSE connection open ≥ 10 s.
158 +- Note: ngrok is the current exposure layer; keep the reverse-proxy config portable so we can swap to Cloudflare Tunnel or a plain LB later without app changes.
159 +
160 +## Commands
161 +
162 +```bash
163 +pnpm dev # whole monorepo (turbo)
164 +pnpm dev:web # frontend only
165 +pnpm test # vitest, all packages
166 +pnpm test:models # interpolator tests (the most critical ones)
167 +pnpm lint && pnpm typecheck # lint includes the file-header check
168 +pnpm db:migrate # drizzle-kit
169 +pnpm ingest:run <sourceId> # force an ingestion locally
170 +pnpm models:refit <metricId> # manual re-fit + diff report
171 +pnpm deploy:m3u96b # deploy to production node m3u96b (ngrok → www.earth-now.co)
172 +```
173 +
174 +## Code Conventions
175 +
176 +- Time: **UTC everywhere internally**, ISO 8601; convert to local time only at display. No hand-rolled date arithmetic — use `date-fns` or `Temporal`.
177 +- Units: SI in the data layer; display conversions (t → Gt, etc.) live in `displayHints`, never in the pipeline.
178 +- `packages/models` and `packages/counter` are **pure and deterministic**: no I/O, no implicit system clock (time is always a parameter). This is what makes them testable and shareable client/server.
179 +- Numbers: never floats for ingested cumulative values (use integers in the base unit when possible); formatting via `Intl.NumberFormat`.
180 +- Every new metric = a PR containing: registry YAML + data fixture + golden model test + source license review.
181 +- i18n: fr + en from day one (keys in the registry and `apps/web/messages`).
182 +
183 +## Testing — Non-negotiable
184 +
185 +- **Golden tests for interpolators**: for each `modelVersion`, fixtures (input data → expected values at precise instants t, defined tolerance). Any output change without a version bump = CI failure.
186 +- Property-based tests (fast-check): monotonicity of cumulatives, derivative bounds, continuity at piecewise junctions, `value(anchorTime) === anchorValue`.
187 +- Client/server consistency test: `packages/counter` run in Node and in jsdom must produce bit-identical values for the same model.
188 +- Ingestion: every source parser has fixtures of the real format (including degraded cases: truncated file, renamed columns).
189 +
190 +## Production Guardrails
191 +
192 +- A counter must **never**: go backwards if cumulative, visibly jump (> 60 s smoothing), display NaN/undefined (fallback = last frozen value + "data pending" indicator), or show precision > sigFigs.
193 +- Ingestion failing > 2× the source's cadence ⇒ "stale" badge on the metric + alert (no silent failure).
194 +- Public endpoints (`/m`, `/embed`, `/badge`, `/sse`): rate limiting per IP + token, read-only open CORS, revocable share tokens, no user data exposed.
195 +- Public /methodology page: generated from the registry — every metric exposes its source, cadence, model, and last observation there. This is a product commitment, not an option.
196 +
197 +## What Claude Must Do in This Repo
198 +
199 +1. Before adding/modifying a metric: read the registry YAML and its associated golden tests.
200 +2. Never modify `packages/models` without bumping `modelVersion` and updating the golden fixtures.
201 +3. Prefer extending an existing `RateFunction` over creating a new one; any new kind requires implementation in `packages/counter` (client + server) AND badge rendering.
202 +4. When creating any file: add the mandatory author header (Simon-Pierre Boucher / contact@spboucher.ai) before any code.
203 +5. When in doubt about source data (format, license, cadence): flag it explicitly rather than assuming.
204 +6. UI text displaying numbers goes through helpers in `packages/counter/format.ts` — never inline `toFixed`.
205 +7. After any production deploy to `m3u96b`, run the health checks against https://www.earth-now.co before declaring the deploy done.
added README.md +139 −0
@@ -0,0 +1,139 @@
1 +# 🌍 earth-now.co — La planète en direct / The planet, live
2 +
3 +<p align="center">
4 + <a href="https://www.earth-now.co"><img src="https://img.shields.io/badge/live-www.earth--now.co-2a78d6?style=flat-square" alt="Live site"></a>
5 + <img src="https://img.shields.io/badge/metrics-76-1baf7a?style=flat-square" alt="76 metrics">
6 + <img src="https://img.shields.io/badge/tests-142%20passing-0ca30c?style=flat-square" alt="142 tests">
7 + <img src="https://img.shields.io/badge/TypeScript-strict-2a78d6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript strict">
8 + <img src="https://img.shields.io/badge/widget-3.2%20KB%20gzip-eda100?style=flat-square" alt="Widget size">
9 + <img src="https://img.shields.io/badge/monorepo-pnpm%20%2B%20turborepo-4a3aa7?style=flat-square" alt="pnpm + turborepo">
10 + <img src="https://img.shields.io/badge/i18n-fr%20%C2%B7%20en-e87ba4?style=flat-square" alt="fr + en">
11 + <img src="https://img.shields.io/badge/real--time-SSE%20%2B%20USGS-e34948?style=flat-square" alt="SSE real-time">
12 +</p>
13 +
14 +**earth-now.co** is a real-time planetary dashboard — a robust successor to Worldometer. It displays live world metrics (population, births, CO₂, forest loss, energy, heartbeats, GDP…) that tick continuously on screen.
15 +
16 +The fundamental difference from every other live-counter site: **our counters are not opaque linear extrapolations**. Every metric is driven by a **documented statistical model** that interpolates between the latest real observations and forecasts from authoritative sources, with seasonality and uncertainty intervals. Every number on screen is traceable: source, license, observation date, model family, confidence interval — one click away, on every counter.
17 +
18 +---
19 +
20 +## ✨ Core principle: a counter is a model, not a stream
21 +
22 +**We never push one value per second over the network.** The server ships a `CounterModel`; every client animates the same pure function locally at 60 fps:
23 +
24 +```typescript
25 +interface CounterModel {
26 + metricId: string; // e.g. "co2_emissions_ytd"
27 + anchorValue: number; // value at the anchor point
28 + anchorTime: string; // ISO 8601 UTC
29 + rateFn: RateFunction; // linear | piecewise | seasonal (Fourier) | spline (PCHIP)
30 + uncertainty?: { low: number; high: number }; // 90 % CI
31 + observedAt: string; // date of the last REAL observation
32 + sourceId: string; // key in the source registry
33 + modelVersion: string; // e.g. "seasonal-spline-v2"
34 + displayHints: { decimals: number; sigFigs?: number; unit: string; scale?: number };
35 +}
36 +```
37 +
38 +The dashboard, the embeddable widget and the server-rendered SVG badges all evaluate the **exact same function** — bit-identical values, verified by a cross-runtime test (jsdom vs a spawned Node process). SSE only pushes *new models* (new ingestion, new forecast, correction), never per-tick values.
39 +
40 +## 📊 The 76 metrics
41 +
42 +| Domain | Count | Highlights |
43 +|---|---|---|
44 +| 👶 Population | 24 | World population (hero), births/deaths/net growth, **human heartbeats (~9.7 B/s)**, top-10 countries + 6 continents ranked live |
45 +| 💰 Economy | 9 | **World GDP (~$3.7 M/s)**, military spending + its **school-meals juxtaposition**, cars, smartphones, cement, steel, e-waste, garments |
46 +| 💧 Society | 9 | Extreme poverty, safe water, hunger, food waste, freshwater use, animals slaughtered, fish caught, **coffee cups (~26 k/s)**, food produced |
47 +| 🏥 Health | 7 | CVD/cancer/tobacco/malaria/child/road mortality (sober editorial rule), cigarettes smoked (~165 k/s) |
48 +| 📱 Tech | 6 | Emails (~4.35 M/s, day+week cycles), Google searches, data created (~6.3 k TB/s), data-center electricity |
49 +| ⚡ Energy | 5 | Electricity, renewable share, coal, oil, **solar installed while you read (~19 kW/s)** |
50 +| 🌡️ Climate | 4 | CO₂ ppm (Keeling curve fit), temperature anomaly, **1.5 °C carbon budget counting down**, years remaining |
51 +| 🏭 Emissions | 3 | CO₂ YTD / today / per second |
52 +| 🌳 Forest | 3 | Tree-cover loss YTD / today / football-pitch equivalent |
53 +| 🌊 Ocean | 2 | Sea level rise, Arctic sea ice (strong seasonal cycle) |
54 +| ⚡ Real-time | 2 | **USGS earthquakes (true event-driven, no interpolation)**, humans in space |
55 +| 🚀 Space | 2 | Earth's orbital distance (deterministic astronomy), Earth Overshoot Day countdown |
56 +
57 +**Model families in production:** `seasonal-ytd-v1` ×27 (Fourier day/week/year harmonics, Jan-1-UTC reset), `seasonal-spline-v2` ×23 (monotone PCHIP obs/forecast fusion), `derived` ×13 (exact server-side compositions), `linear-ytd-v1` ×8, `keeling-fusion-v1` ×2 (least-squares trend + annual harmonics), `static-rt-v1` ×2, `linear-stock-v1` ×1.
58 +
59 +## 🧪 Statistical honesty — the product
60 +
61 +- **Level 0** linear ⟶ **Level 1** seasonal (Fourier/STL) ⟶ **Level 2** observation/forecast fusion (monotone PCHIP splines, Kalman blending, Holt-Winters nowcasting).
62 +- **PCHIP, never natural cubic splines** — a counter can mathematically never overshoot or tick backwards (property-tested with fast-check).
63 +- `sigFigs` caps displayed precision: world population to the unit is a lie; the ticker animates but tooltips show the honest capped value + 90 % CI.
64 +- Wide-uncertainty metrics (food waste ±15 %, garments ±30 %, AI-era estimates) **always** display their interval and an "estimate" label.
65 +- Guardrails enforced at fit time: cumulative monotonicity, declared rate bounds, anchor identity, and an **anti-teleportation diff** that blocks deployment if a refit would visibly jump (clients smooth over 60 s instead).
66 +- The `/methodology` page — and a per-metric method section on every `/metric/<id>` page — is generated from the registry: source, license, cadence, model family + version, last observation, uncertainty. A product commitment, not an option.
67 +
68 +## 🗂️ Architecture
69 +
70 +```
71 +apps/
72 + web/ # Next.js 14 (App Router) — dashboard, /metric/:id, /methodology, /embed
73 + api/ # Fastify — REST + SSE + SVG badges + RT pollers (USGS, Open Notify)
74 + ingest/ # Ingestion workers (USGS, NOAA CO₂; BullMQ when Redis present)
75 +packages/
76 + registry/ # Metric & source YAML + fixtures — the product source of truth
77 + models/ # PCHIP, Fourier LSQ, Holt-Winters, Kalman, guardrail validation (pure)
78 + counter/ # Shared runtime: value(model, t), formatting, windows (pure, isomorphic)
79 + widget/ # Embeddable widget.js — IIFE, zero deps, 3.2 KB gzip (15 KB budget)
80 +infra/ # Docker Compose, nginx, ngrok, reverse proxy, migrations, deploy scripts
81 +scripts/ # check-headers.ts — mandatory author-header lint (runs in pnpm lint)
82 +```
83 +
84 +`packages/models` and `packages/counter` are **pure and deterministic**: no I/O, no implicit clock — time is always a parameter. That's what makes them testable and shareable client/server.
85 +
86 +**Data flow:** ingest ⟶ append-only observations ⟶ re-fit CounterModels ⟶ guardrail validation ⟶ SSE push ⟶ clients animate locally. Raw source payloads are archived with checksums so every model can be re-fitted from history.
87 +
88 +## 🖥️ The frontend
89 +
90 +- **Light-first token theme** (dark toggle), validated data-viz palette, overflow-proof ticking digits (`FitValue` measures and scales — a 17-digit heartbeat counter can't break the layout on any viewport).
91 +- **Live-first information architecture**: a computed tier system (`secondsPerVisibleTick ≤ 2.5 s` ⟶ live grid, sorted fastest-first) decides what leads the page — not hand-picking.
92 +- **"Since you arrived"** session strip (births, deaths, CO₂, forest, orbital km, solar installed) — the catalog's signature.
93 +- Live-ranked **top-10 countries** and **continents** with animated magnitude bars.
94 +- Year progress meter, pulsing live indicators (tier-driven, `prefers-reduced-motion` respected), fr/en from day one, thousands separators everywhere via `Intl.NumberFormat`.
95 +
96 +## 🧰 Commands
97 +
98 +```bash
99 +pnpm dev # whole monorepo (turbo)
100 +pnpm dev:web # frontend only
101 +pnpm test # all packages — 142 tests
102 +pnpm test:models # interpolators only (the critical ones)
103 +pnpm lint # includes the mandatory file-header check
104 +pnpm typecheck # strict TS across 10 packages
105 +pnpm ingest:run <sourceId> # force an ingestion locally (usgs_fdsn, noaa_gml_mlo)
106 +pnpm models:refit # re-fit + diff report with deployability verdicts
107 +pnpm deploy:m3u96b # deploy to production + mandatory health checks
108 +```
109 +
110 +## ✅ Testing — non-negotiable
111 +
112 +- **Golden/recovery tests** for every interpolator (synthetic Keeling recovered to < 0.05 ppm; output changes without a `modelVersion` bump fail CI).
113 +- **Property-based tests** (fast-check): cumulative monotonicity, PCHIP no-overshoot, piecewise continuity, `value(anchorTime) === anchorValue`.
114 +- **Client/server consistency**: `counterValue` in jsdom vs a separate Node process — `Object.is`-identical results.
115 +- **Ingestion fixtures** including degraded cases (truncated NOAA file, malformed USGS GeoJSON).
116 +- Registry integration test: every declared metric must load, fit, and pass all guardrails — 0 blocked models is enforced.
117 +
118 +## 🚀 Deployment
119 +
120 +Production is exposed at **https://www.earth-now.co** (reverse proxy → Next.js web + Fastify API). `pnpm deploy:m3u96b` performs rsync → install/build on the node → process restart → **mandatory health checks**: `GET /api/health` must return 200 and `/sse/health` must hold an SSE stream ≥ 10 s — the deploy is not "done" until both pass. A Docker Compose stack (TimescaleDB + Redis + nginx) ships in `infra/` for the DB-backed ingestion phase.
121 +
122 +## 📖 Reference documents
123 +
124 +- [`CLAUDE.md`](./CLAUDE.md) — the full engineering contract (architecture principles, guardrails, conventions).
125 +- [`earth-now-metrics-catalog.md`](./earth-now-metrics-catalog.md) — the complete metric catalog by domain with model levels, sources and priorities (🥇 MVP · 🥈 V1 · 🥉 V2+).
126 +
127 +## 👤 Author
128 +
129 +| | |
130 +|---|---|
131 +| **Author** | Simon-Pierre Boucher |
132 +| **Contact** | [contact@spboucher.ai](mailto:contact@spboucher.ai) |
133 +| **Site** | [www.earth-now.co](https://www.earth-now.co) |
134 +
135 +Every source file in this repository carries the mandatory author header (enforced by `scripts/check-headers.ts` in CI). Data sources are credited in the UI, in API responses and on the methodology page — licenses are tracked per source in the registry, with `licenseNote` flags on anything requiring legal review before commercial launch.
136 +
137 +---
138 +
139 +<p align="center"><em>Chaque compteur est un modèle statistique documenté — jamais une extrapolation opaque.</em></p>
added apps/api/package.json +32 −0
@@ -0,0 +1,32 @@
1 +{
2 + "name": "@earth-now/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Fastify REST + SSE API — serves CounterModels (never per-tick values), badges, and RT pollers",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "scripts": {
9 + "dev": "tsx watch src/server.ts",
10 + "build": "tsc -p tsconfig.json",
11 + "start": "node dist/server.js",
12 + "test": "vitest run",
13 + "typecheck": "tsc --noEmit",
14 + "lint": "echo 'lint: covered by root header check'",
15 + "db:migrate": "echo 'migrations live in infra/ — applied via docker entrypoint'",
16 + "models:refit": "tsx src/scripts/refit.ts"
17 + },
18 + "dependencies": {
19 + "@earth-now/counter": "workspace:*",
20 + "@earth-now/models": "workspace:*",
21 + "@earth-now/registry": "workspace:*",
22 + "@fastify/cors": "^9.0.1",
23 + "@fastify/rate-limit": "^9.1.0",
24 + "fastify": "^4.28.1"
25 + },
26 + "devDependencies": {
27 + "@types/node": "^20",
28 + "tsx": "^4.19.0",
29 + "typescript": "^5.5.4",
30 + "vitest": "^2.0.5"
31 + }
32 +}
added apps/api/src/badge.ts +61 −0
@@ -0,0 +1,61 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/badge.ts
6 + * Purpose: Server-side SVG badge rendering using the SAME counterValue/formatValue as every client runtime
7 + */
8 +
9 +import { type CounterModel, counterValue, formatValue } from "@earth-now/counter";
10 +import type { MetricEntry } from "@earth-now/registry";
11 +
12 +const FONT = "Verdana,Geneva,DejaVu Sans,sans-serif";
13 +const ATTRIBUTION = "earth-now.co";
14 +const HEIGHT = 28;
15 +const PAD = 12;
16 +const GAP = 12;
17 +
18 +const XML_ESCAPES: Record<string, string> = {
19 + "&": "&amp;",
20 + "<": "&lt;",
21 + ">": "&gt;",
22 + '"': "&quot;",
23 + "'": "&apos;",
24 +};
25 +
26 +function escapeXml(text: string): string {
27 + return text.replace(/[&<>"']/g, (c) => XML_ESCAPES[c] ?? c);
28 +}
29 +
30 +/**
31 + * Dark pill badge. The value is evaluated with the shared runtime at render
32 + * time; badges are static contexts so the sigFigs honesty cap applies strictly.
33 + * A metric without a deployable model renders "data pending" (never NaN).
34 + */
35 +export function renderBadgeSvg(
36 + entry: MetricEntry,
37 + model: CounterModel | undefined,
38 + nowMs: number,
39 +): string {
40 + const name = entry.name.en;
41 + const valueText = model
42 + ? `${formatValue(counterValue(model, nowMs), model.displayHints, { applySigFigs: true })} ${model.displayHints.unit}`.trim()
43 + : "data pending";
44 +
45 + // Approximate text advance widths (no font metrics server-side by design).
46 + const nameW = Math.ceil(name.length * 6.4);
47 + const valueW = Math.ceil(valueText.length * 7.4);
48 + const attrW = Math.ceil(ATTRIBUTION.length * 4.8);
49 + const width = PAD + nameW + GAP + valueW + GAP + attrW + PAD;
50 + const label = `${name}: ${valueText}`;
51 +
52 + return [
53 + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" role="img" aria-label="${escapeXml(label)}">`,
54 + `<title>${escapeXml(label)} — ${ATTRIBUTION}</title>`,
55 + `<rect width="${width}" height="${HEIGHT}" rx="${HEIGHT / 2}" fill="#0f172a"/>`,
56 + `<text x="${PAD}" y="18" font-family="${FONT}" font-size="11" fill="#94a3b8">${escapeXml(name)}</text>`,
57 + `<text x="${PAD + nameW + GAP}" y="18" font-family="${FONT}" font-size="12" font-weight="bold" fill="${model ? "#f1f5f9" : "#fbbf24"}">${escapeXml(valueText)}</text>`,
58 + `<text x="${width - PAD}" y="18" text-anchor="end" font-family="${FONT}" font-size="8" fill="#64748b">${ATTRIBUTION}</text>`,
59 + `</svg>`,
60 + ].join("");
61 +}
added apps/api/src/pollers/open-notify.ts +67 −0
@@ -0,0 +1,67 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/pollers/open-notify.ts
6 + * Purpose: Open Notify poller (1 h) for humans_in_space — extremely failure-tolerant, fixture value stays as fallback
7 + */
8 +
9 +import { buildStaticRtModel } from "@earth-now/models";
10 +import { type ModelStore, displayHintsOf } from "../store.js";
11 +import { type PollerHandle, NOOP_POLLER, fetchJson, pollersEnabled } from "./poller.js";
12 +
13 +const METRIC_ID = "humans_in_space";
14 +const SOURCE_ID = "open_notify";
15 +const ASTROS_URL = "http://api.open-notify.org/astros.json";
16 +// The API is known to be flaky: only flag the metric stale after repeated failures.
17 +const MAX_CONSECUTIVE_FAILURES = 3;
18 +
19 +export const OPEN_NOTIFY_POLL_INTERVAL_MS = 60 * 60_000;
20 +
21 +async function pollOnce(store: ModelStore): Promise<void> {
22 + const data = await fetchJson(ASTROS_URL);
23 + const count = (data as { number?: unknown }).number;
24 + if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count > 100) {
25 + throw new Error("astros.json malformed or implausible 'number' field");
26 + }
27 + const entry = store.registry.byId.get(METRIC_ID);
28 + if (!entry) throw new Error(`registry has no '${METRIC_ID}' entry`);
29 + const nowIso = new Date().toISOString();
30 + store.updateRtModel(
31 + METRIC_ID,
32 + buildStaticRtModel(
33 + {
34 + metricId: METRIC_ID,
35 + sourceId: SOURCE_ID,
36 + observedAt: nowIso,
37 + displayHints: displayHintsOf(entry),
38 + },
39 + { value: count, at: nowIso },
40 + ),
41 + );
42 +}
43 +
44 +export function startOpenNotifyPoller(
45 + store: ModelStore,
46 + intervalMs: number = OPEN_NOTIFY_POLL_INTERVAL_MS,
47 +): PollerHandle {
48 + if (!pollersEnabled()) return NOOP_POLLER;
49 + let consecutiveFailures = 0;
50 + const tick = (): void => {
51 + void pollOnce(store)
52 + .then(() => {
53 + consecutiveFailures = 0;
54 + })
55 + .catch((err: unknown) => {
56 + consecutiveFailures += 1;
57 + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) store.markStale(METRIC_ID);
58 + console.error(
59 + `[open-notify] poll failed ${consecutiveFailures}× (fixture/previous value keeps serving): ${String(err)}`,
60 + );
61 + });
62 + };
63 + tick();
64 + const timer = setInterval(tick, intervalMs);
65 + timer.unref();
66 + return { stop: () => clearInterval(timer) };
67 +}
added apps/api/src/pollers/poller.ts +31 −0
@@ -0,0 +1,31 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/pollers/poller.ts
6 + * Purpose: Shared RT-poller plumbing — env gate, timeout-bounded JSON fetch, handle type
7 + */
8 +
9 +export interface PollerHandle {
10 + stop(): void;
11 +}
12 +
13 +export const NOOP_POLLER: PollerHandle = { stop: () => undefined };
14 +
15 +/** RT pollers run unless explicitly disabled (tests, offline dev). */
16 +export function pollersEnabled(): boolean {
17 + return process.env.ENABLE_RT_POLLERS !== "0";
18 +}
19 +
20 +/** GET a JSON document with a hard timeout; throws on HTTP/network/abort errors. */
21 +export async function fetchJson(url: string, timeoutMs = 10_000): Promise<unknown> {
22 + const res = await fetch(url, {
23 + signal: AbortSignal.timeout(timeoutMs),
24 + headers: {
25 + accept: "application/json",
26 + "user-agent": "earth-now.co/0.1 (contact@spboucher.ai)",
27 + },
28 + });
29 + if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
30 + return (await res.json()) as unknown;
31 +}
added apps/api/src/pollers/usgs.ts +90 −0
@@ -0,0 +1,90 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/pollers/usgs.ts
6 + * Purpose: USGS FDSN poller (5 min) — true event-driven RT for earthquakes_24h, never interpolated, never crashes the server
7 + */
8 +
9 +import { buildStaticRtModel } from "@earth-now/models";
10 +import { type ModelStore, type QuakesSnapshot, displayHintsOf } from "../store.js";
11 +import { type PollerHandle, NOOP_POLLER, fetchJson, pollersEnabled } from "./poller.js";
12 +
13 +const METRIC_ID = "earthquakes_24h";
14 +const SOURCE_ID = "usgs_fdsn";
15 +const BASE = "https://earthquake.usgs.gov/fdsnws/event/1";
16 +const HOUR_MS = 3_600_000;
17 +
18 +export const USGS_POLL_INTERVAL_MS = 5 * 60_000;
19 +
20 +function readCount(data: unknown): number {
21 + const count = (data as { count?: unknown }).count;
22 + if (typeof count !== "number" || !Number.isFinite(count) || count < 0) {
23 + throw new Error("USGS count response malformed");
24 + }
25 + return count;
26 +}
27 +
28 +function readLastMajor(data: unknown): QuakesSnapshot["lastMajor"] {
29 + const features = (data as { features?: unknown }).features;
30 + if (!Array.isArray(features) || features.length === 0) return null;
31 + const props = (features[0] as { properties?: { mag?: unknown; place?: unknown; time?: unknown } })
32 + .properties;
33 + if (!props || typeof props.mag !== "number" || typeof props.time !== "number") return null;
34 + return {
35 + mag: props.mag,
36 + place: typeof props.place === "string" ? props.place : "unknown location",
37 + timeIso: new Date(props.time).toISOString(),
38 + };
39 +}
40 +
41 +async function pollOnce(store: ModelStore): Promise<void> {
42 + const nowMs = Date.now();
43 + const nowIso = new Date(nowMs).toISOString();
44 + const countUrl = `${BASE}/count?format=geojson&starttime=${new Date(nowMs - 24 * HOUR_MS).toISOString()}&minmagnitude=2.5`;
45 + const majorUrl = `${BASE}/query?format=geojson&starttime=${new Date(nowMs - 7 * 24 * HOUR_MS).toISOString()}&minmagnitude=5&orderby=time&limit=1`;
46 +
47 + const [countRes, majorRes] = await Promise.allSettled([fetchJson(countUrl), fetchJson(majorUrl)]);
48 + if (countRes.status === "rejected") {
49 + throw new Error(`USGS count fetch failed: ${String(countRes.reason)}`);
50 + }
51 + const count24h = readCount(countRes.value);
52 + // The "last major quake" leg is optional — keep the previous one on failure.
53 + const lastMajor =
54 + majorRes.status === "fulfilled" ? readLastMajor(majorRes.value) : store.quakes.lastMajor;
55 +
56 + const entry = store.registry.byId.get(METRIC_ID);
57 + if (!entry) throw new Error(`registry has no '${METRIC_ID}' entry`);
58 +
59 + store.updateRtModel(
60 + METRIC_ID,
61 + buildStaticRtModel(
62 + {
63 + metricId: METRIC_ID,
64 + sourceId: SOURCE_ID,
65 + observedAt: nowIso,
66 + displayHints: displayHintsOf(entry),
67 + },
68 + { value: count24h, at: nowIso },
69 + ),
70 + );
71 + store.setQuakesSnapshot({ count24h, lastMajor, fetchedAt: nowIso });
72 +}
73 +
74 +export function startUsgsPoller(
75 + store: ModelStore,
76 + intervalMs: number = USGS_POLL_INTERVAL_MS,
77 +): PollerHandle {
78 + if (!pollersEnabled()) return NOOP_POLLER;
79 + const tick = (): void => {
80 + void pollOnce(store).catch((err: unknown) => {
81 + // Keep serving the previous model — a counter must never freeze silently or show NaN.
82 + store.markStale(METRIC_ID);
83 + console.error(`[usgs] poll failed (previous model keeps serving): ${String(err)}`);
84 + });
85 + };
86 + tick();
87 + const timer = setInterval(tick, intervalMs);
88 + timer.unref();
89 + return { stop: () => clearInterval(timer) };
90 +}
added apps/api/src/routes.ts +141 −0
@@ -0,0 +1,141 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/routes.ts
6 + * Purpose: Public REST + SSE + badge routes — read-only, CORS-open, rate-limited; ships models, never per-tick values
7 + */
8 +
9 +import cors from "@fastify/cors";
10 +import rateLimit from "@fastify/rate-limit";
11 +import type { CounterModel } from "@earth-now/counter";
12 +import type { FastifyInstance } from "fastify";
13 +import { renderBadgeSvg } from "./badge.js";
14 +import type { ModelStore } from "./store.js";
15 +
16 +const SSE_HEADERS = {
17 + "content-type": "text/event-stream; charset=utf-8",
18 + "cache-control": "no-cache, no-transform",
19 + connection: "keep-alive",
20 + "x-accel-buffering": "no",
21 +} as const;
22 +
23 +function modelsSnapshot(store: ModelStore): { models: Record<string, CounterModel>; generatedAt: string } {
24 + return {
25 + models: Object.fromEntries(store.models),
26 + generatedAt: new Date().toISOString(),
27 + };
28 +}
29 +
30 +export async function registerRoutes(app: FastifyInstance, store: ModelStore): Promise<void> {
31 + // Public read-only endpoints: open CORS, GET only.
32 + await app.register(cors, { origin: "*", methods: ["GET"] });
33 + // 300 req/min per IP; SSE connections are long-lived and exempt.
34 + await app.register(rateLimit, {
35 + max: 300,
36 + timeWindow: "1 minute",
37 + allowList: (req) => req.url.startsWith("/sse/"),
38 + });
39 +
40 + app.get("/api/health", () => ({
41 + status: "ok",
42 + now: new Date().toISOString(),
43 + modelCount: store.models.size,
44 + blockedCount: store.blocked.length,
45 + uptimeSeconds: Math.round(process.uptime()),
46 + }));
47 +
48 + // Full registry (attribution included — sources+licenses are a product commitment)
49 + // plus per-metric serving status.
50 + app.get("/v1/metrics", () =>
51 + store.registry.metrics.map((entry) => {
52 + const model = store.getModel(entry.id);
53 + return {
54 + ...entry,
55 + stale: model === undefined || store.isStale(entry.id),
56 + ...(model !== undefined
57 + ? { modelVersion: model.modelVersion, observedAt: model.observedAt }
58 + : {}),
59 + };
60 + }),
61 + );
62 +
63 + app.get<{ Params: { id: string } }>("/v1/metrics/:id/model", (req, reply) => {
64 + const { id } = req.params;
65 + if (!store.registry.byId.has(id)) {
66 + return reply.code(404).send({ error: "unknown_metric", metricId: id });
67 + }
68 + const model = store.getModel(id);
69 + if (!model) return reply.code(503).send({ error: "stale", metricId: id });
70 + return model;
71 + });
72 +
73 + app.get("/v1/models", () => modelsSnapshot(store));
74 +
75 + app.get("/v1/rt/quakes", () => store.quakes);
76 +
77 + // /badge/:id.svg — same counterValue+formatValue as the widget and dashboard.
78 + app.get<{ Params: { file: string } }>("/badge/:file", (req, reply) => {
79 + const { file } = req.params;
80 + if (!file.endsWith(".svg")) return reply.code(404).send({ error: "not_found" });
81 + const id = file.slice(0, -".svg".length);
82 + const entry = store.registry.byId.get(id);
83 + if (!entry) return reply.code(404).send({ error: "unknown_metric", metricId: id });
84 + return reply
85 + .header("content-type", "image/svg+xml")
86 + .header("cache-control", "public, max-age=60")
87 + .send(renderBadgeSvg(entry, store.getModel(id), Date.now()));
88 + });
89 +
90 + // Model distribution stream: full snapshot on connect, then ONLY new models.
91 + app.get("/sse/stream", (req, reply) => {
92 + reply.hijack();
93 + const raw = reply.raw;
94 + raw.writeHead(200, SSE_HEADERS);
95 + raw.write("retry: 5000\n\n");
96 + raw.write(`event: models\ndata: ${JSON.stringify(modelsSnapshot(store))}\n\n`);
97 +
98 + const onModel = (model: CounterModel): void => {
99 + raw.write(`event: model\ndata: ${JSON.stringify(model)}\n\n`);
100 + };
101 + store.onModel(onModel);
102 +
103 + const heartbeat = setInterval(() => {
104 + raw.write(": ping\n\n");
105 + }, 15_000);
106 + heartbeat.unref();
107 +
108 + req.raw.on("close", () => {
109 + clearInterval(heartbeat);
110 + store.offModel(onModel);
111 + raw.end();
112 + });
113 + });
114 +
115 + // Deploy health check holds this open ≥ 10 s; we cap the connection at 60 s.
116 + app.get("/sse/health", (req, reply) => {
117 + reply.hijack();
118 + const raw = reply.raw;
119 + raw.writeHead(200, SSE_HEADERS);
120 + // 2 KB padding comment: tiny comment-only chunks can sit below the flush
121 + // threshold of tunnel/proxy edges (observed with ngrok) — pad past it, then
122 + // send real data events rather than bare comments.
123 + raw.write(`: ${"p".repeat(2048)}\n\n`);
124 + raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`);
125 +
126 + const heartbeat = setInterval(() => {
127 + raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`);
128 + }, 2_000);
129 + heartbeat.unref();
130 + const shutdown = setTimeout(() => {
131 + clearInterval(heartbeat);
132 + raw.end();
133 + }, 60_000);
134 + shutdown.unref();
135 +
136 + req.raw.on("close", () => {
137 + clearInterval(heartbeat);
138 + clearTimeout(shutdown);
139 + });
140 + });
141 +}
added apps/api/src/scripts/refit.ts +60 −0
@@ -0,0 +1,60 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/scripts/refit.ts
6 + * Purpose: CLI re-fit + diff report (pnpm models:refit) — old vs new value at now, jump, deployability gate per metric
7 + */
8 +
9 +import { counterValue, formatValue } from "@earth-now/counter";
10 +import { assertDeployable, diffModels } from "@earth-now/models";
11 +import { fitAllModels, loadFixtures, loadRegistry } from "@earth-now/registry";
12 +import { fitHorizon, metricConstraints } from "../store.js";
13 +
14 +const nowMs = Date.now();
15 +const registry = loadRegistry();
16 +const fixtures = loadFixtures();
17 +const { fromMs, toMs } = fitHorizon(nowMs);
18 +const fitOpts = { validateFromMs: fromMs, validateToMs: toMs };
19 +const horizon = { fromMs, toMs };
20 +
21 +// The currently deployed set. TODO: read from the counter_models table once the
22 +// DB layer lands — until then the fixture fit stands in for it, so the reported
23 +// jump is 0 by construction and the report exercises the full gate anyway.
24 +const previousModels = fitAllModels(registry.metrics, fixtures, fitOpts).models;
25 +const next = fitAllModels(registry.metrics, fixtures, fitOpts);
26 +
27 +let failures = 0;
28 +console.log(
29 + `refit report @ ${new Date(nowMs).toISOString()} — ${registry.metrics.length} metrics, horizon ${new Date(fromMs).toISOString()} → ${new Date(toMs).toISOString()}\n`,
30 +);
31 +
32 +for (const entry of registry.metrics) {
33 + const previous = previousModels.get(entry.id) ?? null;
34 + const model = next.models.get(entry.id);
35 +
36 + if (!model) {
37 + failures += 1;
38 + const issues = next.blocked.find((b) => b.metricId === entry.id)?.issues ?? [];
39 + const detail = issues.map((i) => `${i.code}: ${i.message}`).join("; ") || "no model produced";
40 + console.log(`✗ ${entry.id}: BLOCKED — ${detail}`);
41 + continue;
42 + }
43 +
44 + const hints = model.displayHints;
45 + const newValue = counterValue(model, nowMs);
46 + const oldText = previous ? formatValue(counterValue(previous, nowMs), hints) : "(new)";
47 + const jump = previous ? diffModels(previous, model, nowMs) : 0;
48 + const issues = assertDeployable(previous, model, metricConstraints(entry), nowMs, horizon);
49 + const ok = issues.length === 0;
50 + if (!ok) failures += 1;
51 +
52 + console.log(
53 + `${ok ? "✓" : "✗"} ${entry.id}: old=${oldText} new=${formatValue(newValue, hints)} ${hints.unit} jump=${jump.toPrecision(3)}${ok ? "" : ` — NOT DEPLOYABLE: ${issues.map((i) => i.code).join(", ")}`}`,
54 + );
55 +}
56 +
57 +console.log(
58 + `\n${failures === 0 ? "all models deployable" : `${failures} metric(s) NOT deployable — deployment must be blocked`}`,
59 +);
60 +if (failures > 0) process.exit(1);
added apps/api/src/server.ts +51 −0
@@ -0,0 +1,51 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/server.ts
6 + * Purpose: Fastify app factory (buildApp for tests) + production listener on PORT (default 4000)
7 + */
8 +
9 +import { pathToFileURL } from "node:url";
10 +import Fastify, { type FastifyInstance } from "fastify";
11 +import { startOpenNotifyPoller } from "./pollers/open-notify.js";
12 +import { startUsgsPoller } from "./pollers/usgs.js";
13 +import { registerRoutes } from "./routes.js";
14 +import { ModelStore } from "./store.js";
15 +
16 +export interface BuildAppOptions {
17 + logger?: boolean;
18 + /** Start the RT pollers (additionally gated by ENABLE_RT_POLLERS !== "0"). */
19 + pollers?: boolean;
20 +}
21 +
22 +export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
23 + const app = Fastify({ logger: options.logger ?? true });
24 +
25 + const store = new ModelStore();
26 + store.boot();
27 + await registerRoutes(app, store);
28 +
29 + if (options.pollers ?? true) {
30 + const handles = [startUsgsPoller(store), startOpenNotifyPoller(store)];
31 + app.addHook("onClose", async () => {
32 + for (const handle of handles) handle.stop();
33 + });
34 + }
35 +
36 + return app;
37 +}
38 +
39 +const isMain =
40 + process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
41 +
42 +if (isMain) {
43 + const app = await buildApp();
44 + const port = Number(process.env.PORT ?? 4000);
45 + try {
46 + await app.listen({ port, host: "0.0.0.0" });
47 + } catch (err) {
48 + app.log.error(err);
49 + process.exit(1);
50 + }
51 +}
added apps/api/src/store.ts +171 −0
@@ -0,0 +1,171 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/src/store.ts
6 + * Purpose: In-memory model store — boots from registry+fixtures, tracks blocked/stale metrics, emits "model" events for SSE
7 + */
8 +
9 +import { EventEmitter } from "node:events";
10 +import type { CounterModel, DisplayHints } from "@earth-now/counter";
11 +import type { MetricConstraints, ValidationIssue } from "@earth-now/models";
12 +import {
13 + type MetricEntry,
14 + type Registry,
15 + fitAllModels,
16 + loadFixtures,
17 + loadRegistry,
18 +} from "@earth-now/registry";
19 +
20 +export interface BlockedMetric {
21 + metricId: string;
22 + issues: ValidationIssue[];
23 +}
24 +
25 +export interface QuakesSnapshot {
26 + count24h: number;
27 + lastMajor: { mag: number; place: string; timeIso: string } | null;
28 + fetchedAt: string;
29 +}
30 +
31 +export type ModelListener = (model: CounterModel) => void;
32 +
33 +/** Registry display block → runtime DisplayHints (same mapping the fitter uses). */
34 +export function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints {
35 + const hints: DisplayHints = {
36 + decimals: entry.display.decimals,
37 + unit: entry.display.unit[locale],
38 + };
39 + if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs;
40 + if (entry.display.scale !== undefined) hints.scale = entry.display.scale;
41 + return hints;
42 +}
43 +
44 +/** Registry constraints block → validation MetricConstraints. */
45 +export function metricConstraints(entry: MetricEntry): MetricConstraints {
46 + const c: MetricConstraints = {
47 + kind: entry.kind === "cumulative" ? "cumulative" : "stock",
48 + };
49 + if (entry.constraints.maxAbsRatePerSec !== undefined)
50 + c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec;
51 + if (entry.constraints.maxJumpOnRefit !== undefined)
52 + c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit;
53 + return c;
54 +}
55 +
56 +/** Fit/validation horizon: Jan 1 of the current UTC year → Dec 31 of the next year. */
57 +export function fitHorizon(nowMs: number): { fromMs: number; toMs: number } {
58 + const year = new Date(nowMs).getUTCFullYear();
59 + return { fromMs: Date.UTC(year, 0, 1), toMs: Date.UTC(year + 2, 0, 1) };
60 +}
61 +
62 +/**
63 + * Holds the currently deployable CounterModel of every metric. The API serves
64 + * models, never per-tick values; SSE subscribers listen to the "model" event
65 + * and only receive a payload when a NEW model is deployed.
66 + */
67 +export class ModelStore extends EventEmitter {
68 + private readonly modelMap = new Map<string, CounterModel>();
69 + private blockedList: BlockedMetric[] = [];
70 + private readonly staleSinceMap = new Map<string, string>();
71 + private loadedRegistry: Registry | null = null;
72 + private quakesSnapshot: QuakesSnapshot = {
73 + count24h: 0,
74 + lastMajor: null,
75 + fetchedAt: new Date(0).toISOString(),
76 + };
77 +
78 + /** Load registry + fixtures and fit every model. Blocked metrics are served as stale. */
79 + boot(nowMs: number = Date.now()): void {
80 + const registry = loadRegistry();
81 + const fixtures = loadFixtures();
82 + const { fromMs, toMs } = fitHorizon(nowMs);
83 + const { models, blocked } = fitAllModels(registry.metrics, fixtures, {
84 + validateFromMs: fromMs,
85 + validateToMs: toMs,
86 + });
87 +
88 + this.loadedRegistry = registry;
89 + this.modelMap.clear();
90 + for (const [id, model] of models) this.modelMap.set(id, model);
91 + this.blockedList = blocked;
92 +
93 + // Loud by design: a blocked metric must never fail silently (stale badge instead).
94 + for (const b of blocked) {
95 + console.error(`[store] BLOCKED metric '${b.metricId}' — no model served (stale):`);
96 + for (const issue of b.issues) console.error(`[store] ${issue.code}: ${issue.message}`);
97 + }
98 +
99 + // Fixture-backed defaults for /v1/rt/quakes until the USGS poller first succeeds.
100 + const quakesModel = models.get("earthquakes_24h");
101 + if (quakesModel) {
102 + this.quakesSnapshot = {
103 + count24h: quakesModel.anchorValue,
104 + lastMajor: null,
105 + fetchedAt: quakesModel.observedAt,
106 + };
107 + }
108 + }
109 +
110 + get registry(): Registry {
111 + if (!this.loadedRegistry) throw new Error("ModelStore used before boot()");
112 + return this.loadedRegistry;
113 + }
114 +
115 + get models(): ReadonlyMap<string, CounterModel> {
116 + return this.modelMap;
117 + }
118 +
119 + get blocked(): readonly BlockedMetric[] {
120 + return this.blockedList;
121 + }
122 +
123 + get quakes(): QuakesSnapshot {
124 + return this.quakesSnapshot;
125 + }
126 +
127 + getModel(metricId: string): CounterModel | undefined {
128 + return this.modelMap.get(metricId);
129 + }
130 +
131 + isStale(metricId: string): boolean {
132 + return !this.modelMap.has(metricId) || this.staleSinceMap.has(metricId);
133 + }
134 +
135 + staleSince(metricId: string): string | undefined {
136 + return this.staleSinceMap.get(metricId);
137 + }
138 +
139 + /**
140 + * Swap the model of a true-RT (event) metric and notify SSE subscribers.
141 + * Event-driven jumps are legitimate (no interpolation, no anti-teleportation
142 + * diff); every other model family MUST go through fitAllModels + validation.
143 + */
144 + updateRtModel(metricId: string, model: CounterModel): void {
145 + const entry = this.registry.byId.get(metricId);
146 + if (!entry) throw new Error(`updateRtModel: unknown metric '${metricId}'`);
147 + if (entry.level !== "rt") {
148 + throw new Error(`updateRtModel: metric '${metricId}' is not event-driven (level ${entry.level})`);
149 + }
150 + this.modelMap.set(metricId, model);
151 + this.staleSinceMap.delete(metricId);
152 + this.emit("model", model);
153 + }
154 +
155 + /** Record when a metric's ingestion started failing (previous model keeps serving). */
156 + markStale(metricId: string, atIso: string = new Date().toISOString()): void {
157 + if (!this.staleSinceMap.has(metricId)) this.staleSinceMap.set(metricId, atIso);
158 + }
159 +
160 + setQuakesSnapshot(snapshot: QuakesSnapshot): void {
161 + this.quakesSnapshot = snapshot;
162 + }
163 +
164 + onModel(listener: ModelListener): void {
165 + this.on("model", listener);
166 + }
167 +
168 + offModel(listener: ModelListener): void {
169 + this.off("model", listener);
170 + }
171 +}
added apps/api/test/api.test.ts +123 −0
@@ -0,0 +1,123 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/api/test/api.test.ts
6 + * Purpose: API integration tests via app.inject() — health, registry, models, 404s, badge SVG (pollers disabled)
7 + */
8 +
9 +// Pollers read the env at buildApp() time, so setting it here (after hoisted
10 +// imports evaluate) is safe; buildApp({ pollers: false }) is the second guard.
11 +process.env.ENABLE_RT_POLLERS = "0";
12 +
13 +import { afterAll, beforeAll, describe, expect, it } from "vitest";
14 +import { type CounterModel, counterValue } from "@earth-now/counter";
15 +import { buildApp } from "../src/server";
16 +
17 +type App = Awaited<ReturnType<typeof buildApp>>;
18 +
19 +interface HealthBody {
20 + status: string;
21 + now: string;
22 + modelCount: number;
23 + blockedCount: number;
24 + uptimeSeconds: number;
25 +}
26 +
27 +interface MetricSummary {
28 + id: string;
29 + stale: boolean;
30 + modelVersion?: string;
31 + observedAt?: string;
32 + sources: Array<{ id: string; license: string }>;
33 +}
34 +
35 +let app: App;
36 +
37 +beforeAll(async () => {
38 + app = await buildApp({ logger: false, pollers: false });
39 +});
40 +
41 +afterAll(async () => {
42 + await app.close();
43 +});
44 +
45 +describe("GET /api/health", () => {
46 + it("returns ok with model/blocked counts and uptime", async () => {
47 + const res = await app.inject({ method: "GET", url: "/api/health" });
48 + expect(res.statusCode).toBe(200);
49 + const body = JSON.parse(res.body) as HealthBody;
50 + expect(body.status).toBe("ok");
51 + expect(Number.isNaN(Date.parse(body.now))).toBe(false);
52 + expect(body.modelCount).toBeGreaterThan(0);
53 + expect(body.blockedCount).toBeGreaterThanOrEqual(0);
54 + expect(body.uptimeSeconds).toBeGreaterThanOrEqual(0);
55 + });
56 +});
57 +
58 +describe("GET /v1/metrics", () => {
59 + it("returns the full registry with source attribution and stale flags", async () => {
60 + const res = await app.inject({ method: "GET", url: "/v1/metrics" });
61 + expect(res.statusCode).toBe(200);
62 + const metrics = JSON.parse(res.body) as MetricSummary[];
63 + expect(metrics.length).toBeGreaterThanOrEqual(30);
64 + expect(metrics.map((m) => m.id)).toContain("world_population");
65 + for (const metric of metrics) {
66 + expect(typeof metric.stale).toBe("boolean");
67 + expect(metric.sources.length).toBeGreaterThan(0);
68 + for (const source of metric.sources) expect(source.license.length).toBeGreaterThan(0);
69 + }
70 + });
71 +});
72 +
73 +describe("GET /v1/metrics/:id/model", () => {
74 + it("serves a world_population model evaluating to a sane live value", async () => {
75 + const res = await app.inject({ method: "GET", url: "/v1/metrics/world_population/model" });
76 + expect(res.statusCode).toBe(200);
77 + const model = JSON.parse(res.body) as CounterModel;
78 + expect(model.metricId).toBe("world_population");
79 + const value = counterValue(model, Date.now());
80 + expect(value).toBeGreaterThan(8.2e9);
81 + expect(value).toBeLessThan(8.4e9);
82 + });
83 +
84 + it("returns 404 for an unknown metric id", async () => {
85 + const res = await app.inject({ method: "GET", url: "/v1/metrics/nope_not_a_metric/model" });
86 + expect(res.statusCode).toBe(404);
87 + });
88 +});
89 +
90 +describe("GET /badge/:id.svg", () => {
91 + it("renders an SVG badge for co2_ppm with CDN caching headers", async () => {
92 + const res = await app.inject({ method: "GET", url: "/badge/co2_ppm.svg" });
93 + expect(res.statusCode).toBe(200);
94 + expect(res.headers["content-type"]).toContain("image/svg+xml");
95 + expect(res.headers["cache-control"]).toBe("public, max-age=60");
96 + expect(res.body).toContain("<svg");
97 + expect(res.body).toContain("earth-now.co");
98 + });
99 +
100 + it("returns 404 for an unknown metric badge", async () => {
101 + const res = await app.inject({ method: "GET", url: "/badge/nope_not_a_metric.svg" });
102 + expect(res.statusCode).toBe(404);
103 + });
104 +});
105 +
106 +describe("GET /v1/models", () => {
107 + it("serves every fitted model (registry count minus blocked)", async () => {
108 + const [modelsRes, metricsRes, healthRes] = await Promise.all([
109 + app.inject({ method: "GET", url: "/v1/models" }),
110 + app.inject({ method: "GET", url: "/v1/metrics" }),
111 + app.inject({ method: "GET", url: "/api/health" }),
112 + ]);
113 + expect(modelsRes.statusCode).toBe(200);
114 + const { models, generatedAt } = JSON.parse(modelsRes.body) as {
115 + models: Record<string, CounterModel>;
116 + generatedAt: string;
117 + };
118 + const metrics = JSON.parse(metricsRes.body) as MetricSummary[];
119 + const health = JSON.parse(healthRes.body) as HealthBody;
120 + expect(Number.isNaN(Date.parse(generatedAt))).toBe(false);
121 + expect(Object.keys(models).length).toBe(metrics.length - health.blockedCount);
122 + });
123 +});
added apps/api/tsconfig.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "outDir": "dist",
5 + "rootDir": "src",
6 + "module": "NodeNext",
7 + "moduleResolution": "NodeNext"
8 + },
9 + "include": ["src"]
10 +}
added apps/ingest/package.json +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "name": "@earth-now/ingest",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Ingestion workers — source fetchers/parsers, raw archiving, cron + BullMQ scheduling",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "scripts": {
9 + "dev": "tsx watch src/index.ts",
10 + "build": "tsc -p tsconfig.json",
11 + "test": "vitest run",
12 + "typecheck": "tsc -p tsconfig.json --noEmit",
13 + "lint": "echo 'lint: covered by root header check'",
14 + "run:source": "tsx src/run-source.ts"
15 + },
16 + "dependencies": {
17 + "@earth-now/counter": "workspace:*",
18 + "@earth-now/models": "workspace:*",
19 + "@earth-now/registry": "workspace:*",
20 + "bullmq": "^5.12.0",
21 + "ioredis": "^5.4.1"
22 + },
23 + "devDependencies": {
24 + "@types/node": "^20",
25 + "tsx": "^4.19.0",
26 + "typescript": "^5.5.4",
27 + "vitest": "^2.0.5"
28 + }
29 +}
added apps/ingest/src/archive.ts +53 −0
@@ -0,0 +1,53 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/archive.ts
6 + * Purpose: Raw payload archive — every ingested byte is kept (with sha256) for future re-fitting; S3-shaped interface, local FS for now
7 + */
8 +
9 +import { createHash } from "node:crypto";
10 +import { mkdir, writeFile } from "node:fs/promises";
11 +import { join } from "node:path";
12 +
13 +export interface ArchiveOptions {
14 + /** Archive root. Defaults to $RAW_ARCHIVE_DIR, then "data/raw". */
15 + dir?: string;
16 + /** Timestamp override for deterministic tests. */
17 + now?: Date;
18 +}
19 +
20 +export interface ArchiveResult {
21 + rawPath: string;
22 + checksumPath: string;
23 + sha256: string;
24 +}
25 +
26 +/**
27 + * Write `${dir}/${sourceId}/${timestamp}.raw` plus a sibling `.sha256` checksum file.
28 + * We keep ALL raw data for re-fitting; when this moves to S3 only this module changes.
29 + */
30 +export async function archiveRaw(
31 + sourceId: string,
32 + payload: string | Uint8Array,
33 + options: ArchiveOptions = {},
34 +): Promise<ArchiveResult> {
35 + if (!/^[a-z0-9_-]+$/i.test(sourceId))
36 + throw new Error(`archiveRaw: invalid sourceId '${sourceId}' (path-safe [a-z0-9_-] only)`);
37 + const dir = options.dir ?? process.env.RAW_ARCHIVE_DIR ?? "data/raw";
38 + const now = options.now ?? new Date();
39 + // ISO timestamp made filename-safe: 2026-08-09T12:00:00.000Z → 2026-08-09T12-00-00-000Z
40 + const stamp = now.toISOString().replace(/[:.]/g, "-");
41 + const targetDir = join(dir, sourceId);
42 + await mkdir(targetDir, { recursive: true });
43 +
44 + const data = typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload);
45 + const sha256 = createHash("sha256").update(data).digest("hex");
46 + const fileName = `${stamp}.raw`;
47 + const rawPath = join(targetDir, fileName);
48 + const checksumPath = `${rawPath}.sha256`;
49 +
50 + await writeFile(rawPath, data);
51 + await writeFile(checksumPath, `${sha256} ${fileName}\n`, "utf8");
52 + return { rawPath, checksumPath, sha256 };
53 +}
added apps/ingest/src/fetch-like.ts +18 −0
@@ -0,0 +1,18 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/fetch-like.ts
6 + * Purpose: Minimal structural fetch interface so tests inject fixture-backed fakes and never hit the network
7 + */
8 +
9 +export interface FetchResponseLike {
10 + ok: boolean;
11 + status: number;
12 + text(): Promise<string>;
13 +}
14 +
15 +export type FetchLike = (url: string) => Promise<FetchResponseLike>;
16 +
17 +/** Default implementation: the global fetch (Node 20+). Response satisfies FetchResponseLike structurally. */
18 +export const defaultFetch: FetchLike = (url) => fetch(url);
added apps/ingest/src/index.ts +114 −0
@@ -0,0 +1,114 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/index.ts
6 + * Purpose: Ingestion orchestrator — BullMQ repeatable jobs when REDIS_URL is set, plain setInterval scheduler otherwise; never crashes on a failing source
7 + */
8 +
9 +import { SOURCES, runSource } from "./runners.js";
10 +
11 +const log = {
12 + info: (msg: string): void => console.log(`${new Date().toISOString()} [ingest] ${msg}`),
13 + warn: (msg: string): void => console.warn(`${new Date().toISOString()} [ingest] WARN ${msg}`),
14 + error: (msg: string): void => console.error(`${new Date().toISOString()} [ingest] ERROR ${msg}`),
15 +};
16 +
17 +interface SourceState {
18 + lastSuccessMs: number | null;
19 + failureCount: number;
20 + startedMs: number;
21 +}
22 +
23 +const states = new Map<string, SourceState>(
24 + SOURCES.map((s) => [s.id, { lastSuccessMs: null, failureCount: 0, startedMs: Date.now() }]),
25 +);
26 +
27 +/**
28 + * Production guardrail: a source failing for longer than 2× its cadence must raise the
29 + * "stale" badge + alert. Stub for now — wired to the API alerting later.
30 + */
31 +function checkStale(sourceId: string, cadenceMs: number): void {
32 + const state = states.get(sourceId);
33 + if (!state) return;
34 + const reference = state.lastSuccessMs ?? state.startedMs;
35 + if (Date.now() - reference > 2 * cadenceMs) {
36 + log.warn(
37 + `[stale] ${sourceId} has had no successful ingestion for > 2× its cadence ` +
38 + `(${state.failureCount} consecutive failure(s)) — would trigger the stale badge + alert (stub)`,
39 + );
40 + }
41 +}
42 +
43 +/** Run one source, absorbing every failure: log + count, never crash the scheduler. */
44 +async function tick(sourceId: string, cadenceMs: number): Promise<void> {
45 + const state = states.get(sourceId);
46 + try {
47 + const summary = await runSource(sourceId);
48 + if (state) {
49 + state.lastSuccessMs = Date.now();
50 + state.failureCount = 0;
51 + }
52 + log.info(
53 + `${sourceId}: ok — ${summary.observationCount} observation(s), ` +
54 + `last=${summary.last ? `${summary.last.time} → ${summary.last.value}` : "n/a"}, raw=${summary.rawPath}`,
55 + );
56 + } catch (err) {
57 + if (state) state.failureCount += 1;
58 + log.error(`${sourceId}: run failed — ${err instanceof Error ? err.message : String(err)}`);
59 + checkStale(sourceId, cadenceMs);
60 + }
61 +}
62 +
63 +async function startWithRedis(redisUrl: string): Promise<void> {
64 + // bullmq/ioredis are imported ONLY when REDIS_URL is set — dev stays dependency-light.
65 + const { Queue, Worker } = await import("bullmq");
66 + const { Redis } = await import("ioredis");
67 + const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
68 +
69 + const queue = new Queue("ingest", { connection });
70 + for (const source of SOURCES) {
71 + await queue.add(
72 + source.id,
73 + {},
74 + {
75 + repeat: { every: source.cadenceMs },
76 + jobId: `repeat:${source.id}`,
77 + removeOnComplete: 100,
78 + removeOnFail: 500,
79 + },
80 + );
81 + log.info(`scheduled '${source.id}' every ${source.cadenceMs / 1000}s (BullMQ repeatable)`);
82 + }
83 +
84 + const worker = new Worker(
85 + "ingest",
86 + async (job) => {
87 + const source = SOURCES.find((s) => s.id === job.name);
88 + await tick(job.name, source?.cadenceMs ?? 0);
89 + },
90 + { connection },
91 + );
92 + worker.on("error", (err) => log.error(`worker error: ${err.message}`));
93 + log.info(`BullMQ mode: queue "ingest" on ${redisUrl}`);
94 +}
95 +
96 +function startRedisLess(): void {
97 + log.info("REDIS_URL not set — Redis-less dev mode: plain setInterval scheduler");
98 + for (const source of SOURCES) {
99 + void tick(source.id, source.cadenceMs);
100 + setInterval(() => void tick(source.id, source.cadenceMs), source.cadenceMs);
101 + log.info(`scheduled '${source.id}' every ${source.cadenceMs / 1000}s (setInterval)`);
102 + }
103 +}
104 +
105 +async function main(): Promise<void> {
106 + const redisUrl = process.env.REDIS_URL;
107 + if (redisUrl) await startWithRedis(redisUrl);
108 + else startRedisLess();
109 +}
110 +
111 +main().catch((err: unknown) => {
112 + log.error(`fatal startup error: ${err instanceof Error ? err.message : String(err)}`);
113 + process.exit(1);
114 +});
added apps/ingest/src/run-source.ts +32 −0
@@ -0,0 +1,32 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/run-source.ts
6 + * Purpose: CLI for `pnpm ingest:run <sourceId>` — force one ingestion locally and print a summary
7 + */
8 +
9 +import { KNOWN_SOURCE_IDS, runSource } from "./runners.js";
10 +
11 +const sourceId = process.argv[2];
12 +
13 +if (!sourceId || !KNOWN_SOURCE_IDS.includes(sourceId)) {
14 + console.error(
15 + sourceId ? `Unknown source '${sourceId}'.` : "Usage: pnpm ingest:run <sourceId>",
16 + );
17 + console.error("Known sources:");
18 + for (const id of KNOWN_SOURCE_IDS) console.error(` - ${id}`);
19 + process.exit(1);
20 +}
21 +
22 +runSource(sourceId)
23 + .then((summary) => {
24 + console.log(`✓ ${summary.sourceId}: ${summary.observationCount} observation(s)`);
25 + if (summary.first) console.log(` first: ${summary.first.time} → ${summary.first.value}`);
26 + if (summary.last) console.log(` last: ${summary.last.time} → ${summary.last.value}`);
27 + console.log(` raw: ${summary.rawPath} (sha256 ${summary.sha256.slice(0, 12)}…)`);
28 + })
29 + .catch((err: unknown) => {
30 + console.error(`✗ ${sourceId} ingestion failed:`, err instanceof Error ? err.message : err);
31 + process.exit(1);
32 + });
added apps/ingest/src/runners.ts +94 −0
@@ -0,0 +1,94 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/runners.ts
6 + * Purpose: Per-source run functions (fetch → archive raw → parse → summary) shared by the CLI and the queue worker
7 + */
8 +
9 +import { archiveRaw } from "./archive.js";
10 +import { type FetchLike, defaultFetch } from "./fetch-like.js";
11 +import { NOAA_CO2_URL, parseNoaaMonthlyCo2 } from "./sources/noaa-co2.js";
12 +import { parseUsgsCount, usgsCountUrl } from "./sources/usgs.js";
13 +
14 +/** USGS live counter parameters: quakes M ≥ 4.5 over the trailing 24 h. */
15 +export const USGS_WINDOW_HOURS = 24;
16 +// Must match the registry definition of earthquakes_24h (catalog: M ≥ 2.5).
17 +export const USGS_MIN_MAGNITUDE = 2.5;
18 +
19 +export interface ObservationPoint {
20 + time: string;
21 + value: number;
22 +}
23 +
24 +export interface RunSummary {
25 + sourceId: string;
26 + observationCount: number;
27 + first: ObservationPoint | null;
28 + last: ObservationPoint | null;
29 + rawPath: string;
30 + sha256: string;
31 +}
32 +
33 +async function fetchText(url: string, fetchImpl: FetchLike): Promise<string> {
34 + const res = await fetchImpl(url);
35 + if (!res.ok) throw new Error(`fetch failed: HTTP ${res.status} for ${url}`);
36 + return res.text();
37 +}
38 +
39 +/** USGS FDSN: one observation — the M≥4.5 quake count over the trailing 24 h window. */
40 +export async function runUsgsFdsn(fetchImpl: FetchLike = defaultFetch): Promise<RunSummary> {
41 + const url = usgsCountUrl(USGS_WINDOW_HOURS, USGS_MIN_MAGNITUDE);
42 + const text = await fetchText(url, fetchImpl);
43 + const archived = await archiveRaw("usgs_fdsn", text);
44 + const count = parseUsgsCount(JSON.parse(text));
45 + const point: ObservationPoint = { time: new Date().toISOString(), value: count };
46 + return {
47 + sourceId: "usgs_fdsn",
48 + observationCount: 1,
49 + first: point,
50 + last: point,
51 + rawPath: archived.rawPath,
52 + sha256: archived.sha256,
53 + };
54 +}
55 +
56 +/** NOAA GML Mauna Loa: full monthly CO₂ series (Keeling curve). */
57 +export async function runNoaaGmlMlo(fetchImpl: FetchLike = defaultFetch): Promise<RunSummary> {
58 + const text = await fetchText(NOAA_CO2_URL, fetchImpl);
59 + const archived = await archiveRaw("noaa_gml_mlo", text);
60 + const observations = parseNoaaMonthlyCo2(text);
61 + return {
62 + sourceId: "noaa_gml_mlo",
63 + observationCount: observations.length,
64 + first: observations[0] ?? null,
65 + last: observations[observations.length - 1] ?? null,
66 + rawPath: archived.rawPath,
67 + sha256: archived.sha256,
68 + };
69 +}
70 +
71 +export interface SourceSpec {
72 + id: string;
73 + /** Scheduling cadence in ms (usgs 5 min, noaa daily). */
74 + cadenceMs: number;
75 + run: (fetchImpl?: FetchLike) => Promise<RunSummary>;
76 +}
77 +
78 +/** Every source the ingest service knows how to run, with its scheduling cadence. */
79 +export const SOURCES: readonly SourceSpec[] = [
80 + { id: "usgs_fdsn", cadenceMs: 5 * 60_000, run: runUsgsFdsn },
81 + { id: "noaa_gml_mlo", cadenceMs: 24 * 3_600_000, run: runNoaaGmlMlo },
82 +];
83 +
84 +export const KNOWN_SOURCE_IDS: readonly string[] = SOURCES.map((s) => s.id);
85 +
86 +/** Run one source by id. Throws on unknown id or on any fetch/parse failure. */
87 +export async function runSource(
88 + sourceId: string,
89 + fetchImpl: FetchLike = defaultFetch,
90 +): Promise<RunSummary> {
91 + const spec = SOURCES.find((s) => s.id === sourceId);
92 + if (!spec) throw new Error(`Unknown source '${sourceId}'. Known: ${KNOWN_SOURCE_IDS.join(", ")}`);
93 + return spec.run(fetchImpl);
94 +}
added apps/ingest/src/sources/noaa-co2.ts +71 −0
@@ -0,0 +1,71 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/sources/noaa-co2.ts
6 + * Purpose: NOAA GML Mauna Loa monthly CO₂ (co2_mm_mlo.txt) — pure text parser and fetcher (Keeling curve)
7 + */
8 +
9 +import { type FetchLike, defaultFetch } from "../fetch-like.js";
10 +
11 +export const NOAA_CO2_URL = "https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_mm_mlo.txt";
12 +
13 +/** NOAA marks missing monthly means with -9.99 (and -1 / -9.99 in the auxiliary columns). */
14 +export const NOAA_MISSING_SENTINEL = -9.99;
15 +
16 +export interface MonthlyObservation {
17 + /** ISO 8601 UTC, pinned to the 15th of the month (monthly means have no finer resolution). */
18 + time: string;
19 + /** CO₂ mole fraction in dry air, ppm. */
20 + value: number;
21 +}
22 +
23 +/**
24 + * Pure parser for the NOAA GML co2_mm_mlo.txt format:
25 + * - comment lines start with '#'
26 + * - data lines: year month decimal-date average deseasonalized ndays stdev unc (whitespace-separated)
27 + * - average == -9.99 → missing month, skipped.
28 + * Chosen degraded-case behavior: a truncated/malformed data line THROWS a descriptive error
29 + * (a partial file must never silently feed the fitting pipeline).
30 + */
31 +export function parseNoaaMonthlyCo2(text: string): MonthlyObservation[] {
32 + const out: MonthlyObservation[] = [];
33 + const lines = text.split(/\r?\n/);
34 + for (let i = 0; i < lines.length; i++) {
35 + const line = (lines[i] ?? "").trim();
36 + if (line === "" || line.startsWith("#")) continue;
37 + const fields = line.split(/\s+/);
38 + if (fields.length < 8)
39 + throw new Error(
40 + `NOAA co2_mm_mlo parse error at line ${i + 1}: expected 8 columns ` +
41 + `(year month decimal-date average deseasonalized ndays stdev unc), got ${fields.length} ` +
42 + `— file truncated or format changed`,
43 + );
44 + const year = Number(fields[0]);
45 + const month = Number(fields[1]);
46 + const average = Number(fields[3]);
47 + if (!Number.isInteger(year) || year < 1950 || year > 2200)
48 + throw new Error(`NOAA co2_mm_mlo parse error at line ${i + 1}: invalid year '${fields[0]}'`);
49 + if (!Number.isInteger(month) || month < 1 || month > 12)
50 + throw new Error(`NOAA co2_mm_mlo parse error at line ${i + 1}: invalid month '${fields[1]}'`);
51 + if (!Number.isFinite(average))
52 + throw new Error(
53 + `NOAA co2_mm_mlo parse error at line ${i + 1}: non-numeric average '${fields[3]}'`,
54 + );
55 + // Missing month: -9.99 sentinel (any negative average is physically impossible for CO₂ ppm).
56 + if (average < 0) continue;
57 + out.push({ time: new Date(Date.UTC(year, month - 1, 15)).toISOString(), value: average });
58 + }
59 + if (out.length === 0)
60 + throw new Error("NOAA co2_mm_mlo parse error: no valid data rows found in payload");
61 + return out;
62 +}
63 +
64 +/** Fetch and parse the current Mauna Loa monthly series. Inject fetchImpl in tests. */
65 +export async function fetchNoaaCo2(
66 + fetchImpl: FetchLike = defaultFetch,
67 +): Promise<MonthlyObservation[]> {
68 + const res = await fetchImpl(NOAA_CO2_URL);
69 + if (!res.ok) throw new Error(`NOAA co2_mm_mlo fetch failed: HTTP ${res.status}`);
70 + return parseNoaaMonthlyCo2(await res.text());
71 +}
added apps/ingest/src/sources/usgs.ts +89 −0
@@ -0,0 +1,89 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/src/sources/usgs.ts
6 + * Purpose: USGS FDSN event service — count endpoint fetcher and pure GeoJSON parsers (event-driven, no interpolation)
7 + */
8 +
9 +import { type FetchLike, defaultFetch } from "../fetch-like.js";
10 +
11 +export const USGS_FDSN_BASE = "https://earthquake.usgs.gov/fdsnws/event/1";
12 +
13 +/** Build the count-endpoint URL for the trailing window. nowMs is a parameter so tests stay deterministic. */
14 +export function usgsCountUrl(
15 + windowHours: number,
16 + minMagnitude: number,
17 + nowMs: number = Date.now(),
18 +): string {
19 + const start = new Date(nowMs - windowHours * 3_600_000).toISOString();
20 + return (
21 + `${USGS_FDSN_BASE}/count?format=geojson` +
22 + `&starttime=${encodeURIComponent(start)}&minmagnitude=${minMagnitude}`
23 + );
24 +}
25 +
26 +/**
27 + * Pure parser for the count endpoint payload: { count: n, maxAllowed: m }.
28 + * Throws a descriptive error on anything malformed.
29 + */
30 +export function parseUsgsCount(json: unknown): number {
31 + if (typeof json !== "object" || json === null)
32 + throw new Error("USGS count parse error: payload is not an object");
33 + const count = (json as { count?: unknown }).count;
34 + if (typeof count !== "number" || !Number.isInteger(count) || count < 0)
35 + throw new Error(
36 + `USGS count parse error: 'count' must be a non-negative integer, got ${JSON.stringify(count)}`,
37 + );
38 + return count;
39 +}
40 +
41 +export interface UsgsLastMajor {
42 + mag: number;
43 + place: string;
44 + timeIso: string;
45 +}
46 +
47 +/**
48 + * Pure parser for the query endpoint (orderby=time&limit=1): features[0] → last major quake.
49 + * Throws a descriptive error on malformed GeoJSON.
50 + */
51 +export function parseUsgsLastMajor(geojson: unknown): UsgsLastMajor {
52 + if (typeof geojson !== "object" || geojson === null)
53 + throw new Error("USGS query parse error: payload is not an object");
54 + const features = (geojson as { features?: unknown }).features;
55 + if (!Array.isArray(features) || features.length === 0)
56 + throw new Error("USGS query parse error: 'features' is missing or empty");
57 + const first = features[0] as { properties?: unknown };
58 + if (typeof first !== "object" || first === null || typeof first.properties !== "object" || first.properties === null)
59 + throw new Error("USGS query parse error: features[0].properties is missing");
60 + const props = first.properties as { mag?: unknown; place?: unknown; time?: unknown };
61 + if (typeof props.mag !== "number" || !Number.isFinite(props.mag))
62 + throw new Error("USGS query parse error: features[0].properties.mag is not a finite number");
63 + if (typeof props.place !== "string" || props.place.length === 0)
64 + throw new Error("USGS query parse error: features[0].properties.place is not a string");
65 + if (typeof props.time !== "number" || !Number.isFinite(props.time))
66 + throw new Error(
67 + "USGS query parse error: features[0].properties.time is not a millisecond timestamp",
68 + );
69 + return { mag: props.mag, place: props.place, timeIso: new Date(props.time).toISOString() };
70 +}
71 +
72 +/** Fetch the earthquake count for the trailing window (M >= minMagnitude). Inject fetchImpl in tests. */
73 +export async function fetchUsgsQuakeCount(
74 + windowHours: number,
75 + minMagnitude: number,
76 + fetchImpl: FetchLike = defaultFetch,
77 +): Promise<number> {
78 + const url = usgsCountUrl(windowHours, minMagnitude);
79 + const res = await fetchImpl(url);
80 + if (!res.ok) throw new Error(`USGS count fetch failed: HTTP ${res.status} for ${url}`);
81 + const text = await res.text();
82 + let json: unknown;
83 + try {
84 + json = JSON.parse(text);
85 + } catch {
86 + throw new Error("USGS count fetch failed: response is not valid JSON");
87 + }
88 + return parseUsgsCount(json);
89 +}
added apps/ingest/test/archive.test.ts +46 −0
@@ -0,0 +1,46 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/test/archive.test.ts
6 + * Purpose: Raw archive tests — payload + sha256 checksum written under ${dir}/${sourceId}/, deterministic timestamps
7 + */
8 +
9 +import { createHash } from "node:crypto";
10 +import { mkdtempSync, readFileSync } from "node:fs";
11 +import { tmpdir } from "node:os";
12 +import { join, sep } from "node:path";
13 +import { describe, expect, it } from "vitest";
14 +import { archiveRaw } from "../src/archive";
15 +
16 +describe("archiveRaw", () => {
17 + it("writes the raw payload and a sha256 sidecar under dir/sourceId/", async () => {
18 + const dir = mkdtempSync(join(tmpdir(), "earth-now-archive-"));
19 + const payload = "year month value\n2024 1 422.80\n";
20 + const now = new Date("2026-08-09T12:00:00.000Z");
21 +
22 + const result = await archiveRaw("noaa_gml_mlo", payload, { dir, now });
23 +
24 + expect(result.rawPath).toBe(join(dir, "noaa_gml_mlo", "2026-08-09T12-00-00-000Z.raw"));
25 + expect(result.checksumPath).toBe(`${result.rawPath}.sha256`);
26 + expect(readFileSync(result.rawPath, "utf8")).toBe(payload);
27 +
28 + const expectedSha = createHash("sha256").update(payload, "utf8").digest("hex");
29 + expect(result.sha256).toBe(expectedSha);
30 + expect(readFileSync(result.checksumPath, "utf8")).toBe(
31 + `${expectedSha} 2026-08-09T12-00-00-000Z.raw\n`,
32 + );
33 + });
34 +
35 + it("accepts binary payloads (Uint8Array)", async () => {
36 + const dir = mkdtempSync(join(tmpdir(), "earth-now-archive-"));
37 + const payload = new Uint8Array([0, 1, 2, 254, 255]);
38 + const result = await archiveRaw("usgs_fdsn", payload, { dir });
39 + expect(readFileSync(result.rawPath)).toEqual(Buffer.from(payload));
40 + expect(result.rawPath.split(sep)).toContain("usgs_fdsn");
41 + });
42 +
43 + it("rejects path-unsafe source ids", async () => {
44 + await expect(archiveRaw("../evil", "x", { dir: tmpdir() })).rejects.toThrow(/invalid sourceId/);
45 + });
46 +});
added apps/ingest/test/fixtures/co2_mm_mlo_sample.txt +24 −0
@@ -0,0 +1,24 @@
1 +# --------------------------------------------------------------------
2 +# USE OF NOAA GML DATA: These data are made freely available to the
3 +# public and the scientific community (see https://gml.noaa.gov).
4 +# CO2 expressed as a mole fraction in dry air, micromol/mol, abbreviated as ppm
5 +#
6 +# Missing months are denoted by -9.99; ndays = -1 means no daily means in month.
7 +#
8 +# year month decimal-date average deseasonalized ndays sdev unc
9 +2024 1 2024.0417 422.80 421.99 31 0.42 0.15
10 +2024 2 2024.1250 424.55 423.29 28 0.71 0.26
11 +2024 3 2024.2083 425.38 423.51 31 0.55 0.19
12 +2024 4 2024.2917 426.57 423.68 30 0.73 0.26
13 +2024 5 2024.3750 426.90 423.60 31 0.53 0.19
14 +2024 6 2024.4583 -9.99 -9.99 -1 -9.99 -9.99
15 +2024 7 2024.5417 425.55 424.31 31 0.44 0.15
16 +2024 8 2024.6250 422.99 424.79 31 0.42 0.15
17 +2024 9 2024.7083 422.03 425.55 30 0.36 0.13
18 +2024 10 2024.7917 422.38 425.72 31 0.29 0.10
19 +2024 11 2024.8750 423.94 425.95 30 0.49 0.17
20 +2024 12 2024.9583 425.40 426.23 31 0.51 0.18
21 +2025 1 2025.0417 426.65 425.82 31 0.53 0.19
22 +2025 2 2025.1250 427.09 425.79 28 0.98 0.36
23 +2025 3 2025.2083 428.15 426.26 31 0.53 0.19
24 +2025 4 2025.2917 429.64 426.74 30 0.62 0.22
added apps/ingest/test/fixtures/co2_mm_mlo_truncated.txt +8 −0
@@ -0,0 +1,8 @@
1 +# --------------------------------------------------------------------
2 +# USE OF NOAA GML DATA: These data are made freely available to the
3 +# public and the scientific community (see https://gml.noaa.gov).
4 +#
5 +# year month decimal-date average deseasonalized ndays sdev unc
6 +2024 1 2024.0417 422.80 421.99 31 0.42 0.15
7 +2024 2 2024.1250 424.55 423.29 28 0.71 0.26
8 +2024 3 2024.2
added apps/ingest/test/fixtures/usgs_count_malformed.json +4 −0
@@ -0,0 +1,4 @@
1 +{
2 + "error": "Bad Request",
3 + "message": "count field omitted by upstream"
4 +}
added apps/ingest/test/fixtures/usgs_count_sample.json +4 −0
@@ -0,0 +1,4 @@
1 +{
2 + "count": 132,
3 + "maxAllowed": 20000
4 +}
added apps/ingest/test/fixtures/usgs_query_malformed.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "type": "FeatureCollection",
3 + "metadata": {
4 + "generated": 1754733600000,
5 + "status": 200,
6 + "count": 1
7 + },
8 + "features": [
9 + {
10 + "type": "Feature",
11 + "properties": {
12 + "place": "somewhere in the Pacific",
13 + "time": "not-a-number"
14 + },
15 + "geometry": null,
16 + "id": "usbroken01"
17 + }
18 + ]
19 +}
added apps/ingest/test/fixtures/usgs_query_sample.json +39 −0
@@ -0,0 +1,39 @@
1 +{
2 + "type": "FeatureCollection",
3 + "metadata": {
4 + "generated": 1754733600000,
5 + "url": "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&minmagnitude=6&orderby=time&limit=1",
6 + "title": "USGS Earthquakes",
7 + "status": 200,
8 + "api": "1.14.1",
9 + "count": 1
10 + },
11 + "features": [
12 + {
13 + "type": "Feature",
14 + "properties": {
15 + "mag": 6.3,
16 + "place": "142 km E of Petropavlovsk-Kamchatsky, Russia",
17 + "time": 1754650000000,
18 + "updated": 1754651000000,
19 + "tz": null,
20 + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000qabc",
21 + "felt": 214,
22 + "tsunami": 0,
23 + "sig": 612,
24 + "net": "us",
25 + "code": "7000qabc",
26 + "status": "reviewed",
27 + "magType": "mww",
28 + "type": "earthquake",
29 + "title": "M 6.3 - 142 km E of Petropavlovsk-Kamchatsky, Russia"
30 + },
31 + "geometry": {
32 + "type": "Point",
33 + "coordinates": [160.512, 52.923, 35.0]
34 + },
35 + "id": "us7000qabc"
36 + }
37 + ],
38 + "bbox": [160.512, 52.923, 35.0, 160.512, 52.923, 35.0]
39 +}
added apps/ingest/test/noaa-co2.test.ts +75 −0
@@ -0,0 +1,75 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/test/noaa-co2.test.ts
6 + * Purpose: NOAA GML co2_mm_mlo parser/fetcher tests — fixture-backed incl. missing month + truncated file, no network
7 + */
8 +
9 +import { readFileSync } from "node:fs";
10 +import { describe, expect, it } from "vitest";
11 +import type { FetchLike } from "../src/fetch-like";
12 +import { NOAA_CO2_URL, fetchNoaaCo2, parseNoaaMonthlyCo2 } from "../src/sources/noaa-co2";
13 +
14 +const fixture = (name: string): string =>
15 + readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
16 +
17 +describe("parseNoaaMonthlyCo2", () => {
18 + const sample = fixture("co2_mm_mlo_sample.txt");
19 +
20 + it("parses the sample: 16 data rows, one -9.99 missing month skipped → 15 observations", () => {
21 + const obs = parseNoaaMonthlyCo2(sample);
22 + expect(obs).toHaveLength(15);
23 + });
24 +
25 + it("pins each observation to the 15th of the month UTC with the right value", () => {
26 + const obs = parseNoaaMonthlyCo2(sample);
27 + expect(obs[0]).toEqual({ time: "2024-01-15T00:00:00.000Z", value: 422.8 });
28 + expect(obs[obs.length - 1]).toEqual({ time: "2025-04-15T00:00:00.000Z", value: 429.64 });
29 + });
30 +
31 + it("skips the missing month (2024-06, average -9.99) without inventing a value", () => {
32 + const obs = parseNoaaMonthlyCo2(sample);
33 + expect(obs.some((o) => o.time.startsWith("2024-06"))).toBe(false);
34 + // May and July around the gap are both present.
35 + expect(obs.some((o) => o.time.startsWith("2024-05"))).toBe(true);
36 + expect(obs.some((o) => o.time.startsWith("2024-07"))).toBe(true);
37 + });
38 +
39 + it("THROWS on the truncated fixture (chosen degraded behavior — never feed partial files downstream)", () => {
40 + expect(() => parseNoaaMonthlyCo2(fixture("co2_mm_mlo_truncated.txt"))).toThrow(
41 + /expected 8 columns.*truncated/,
42 + );
43 + });
44 +
45 + it("throws when there are no data rows at all", () => {
46 + expect(() => parseNoaaMonthlyCo2("# only comments\n# nothing else\n")).toThrow(
47 + /no valid data rows/,
48 + );
49 + });
50 +
51 + it("throws on out-of-range year/month", () => {
52 + const bad = "1800 1 1800.04 350.00 350.00 31 0.4 0.1";
53 + expect(() => parseNoaaMonthlyCo2(bad)).toThrow(/invalid year/);
54 + const badMonth = "2024 13 2024.99 420.00 420.00 31 0.4 0.1";
55 + expect(() => parseNoaaMonthlyCo2(badMonth)).toThrow(/invalid month/);
56 + });
57 +});
58 +
59 +describe("fetchNoaaCo2", () => {
60 + it("fetches the NOAA URL and parses via an injected fetchImpl (no network)", async () => {
61 + const seen: string[] = [];
62 + const fetchImpl: FetchLike = async (url) => {
63 + seen.push(url);
64 + return { ok: true, status: 200, text: async () => fixture("co2_mm_mlo_sample.txt") };
65 + };
66 + const obs = await fetchNoaaCo2(fetchImpl);
67 + expect(obs).toHaveLength(15);
68 + expect(seen).toEqual([NOAA_CO2_URL]);
69 + });
70 +
71 + it("throws on HTTP errors", async () => {
72 + const fetchImpl: FetchLike = async () => ({ ok: false, status: 500, text: async () => "" });
73 + await expect(fetchNoaaCo2(fetchImpl)).rejects.toThrow(/HTTP 500/);
74 + });
75 +});
added apps/ingest/test/runners.test.ts +74 −0
@@ -0,0 +1,74 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/test/runners.test.ts
6 + * Purpose: End-to-end run functions (fetch → archive → parse → summary) with fixture-backed fakes, no network
7 + */
8 +
9 +import { existsSync, mkdtempSync, readdirSync, readFileSync } from "node:fs";
10 +import { tmpdir } from "node:os";
11 +import { join } from "node:path";
12 +import { afterEach, beforeEach, describe, expect, it } from "vitest";
13 +import type { FetchLike } from "../src/fetch-like";
14 +import { KNOWN_SOURCE_IDS, runSource } from "../src/runners";
15 +
16 +const fixture = (name: string): string =>
17 + readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
18 +
19 +let previousRawDir: string | undefined;
20 +
21 +beforeEach(() => {
22 + previousRawDir = process.env.RAW_ARCHIVE_DIR;
23 + process.env.RAW_ARCHIVE_DIR = mkdtempSync(join(tmpdir(), "earth-now-runners-"));
24 +});
25 +
26 +afterEach(() => {
27 + if (previousRawDir === undefined) delete process.env.RAW_ARCHIVE_DIR;
28 + else process.env.RAW_ARCHIVE_DIR = previousRawDir;
29 +});
30 +
31 +describe("runSource", () => {
32 + it("knows exactly the declared sources", () => {
33 + expect([...KNOWN_SOURCE_IDS]).toEqual(["usgs_fdsn", "noaa_gml_mlo"]);
34 + });
35 +
36 + it("rejects unknown source ids listing the known ones", async () => {
37 + await expect(runSource("nope")).rejects.toThrow(/Unknown source 'nope'.*usgs_fdsn/);
38 + });
39 +
40 + it("usgs_fdsn: fetches, archives the raw payload and summarizes one observation", async () => {
41 + const body = fixture("usgs_count_sample.json");
42 + const fetchImpl: FetchLike = async () => ({ ok: true, status: 200, text: async () => body });
43 +
44 + const summary = await runSource("usgs_fdsn", fetchImpl);
45 + expect(summary.sourceId).toBe("usgs_fdsn");
46 + expect(summary.observationCount).toBe(1);
47 + expect(summary.first?.value).toBe(132);
48 + expect(existsSync(summary.rawPath)).toBe(true);
49 + expect(readFileSync(summary.rawPath, "utf8")).toBe(body);
50 + expect(existsSync(`${summary.rawPath}.sha256`)).toBe(true);
51 + });
52 +
53 + it("noaa_gml_mlo: fetches, archives and summarizes the monthly series", async () => {
54 + const body = fixture("co2_mm_mlo_sample.txt");
55 + const fetchImpl: FetchLike = async () => ({ ok: true, status: 200, text: async () => body });
56 +
57 + const summary = await runSource("noaa_gml_mlo", fetchImpl);
58 + expect(summary.observationCount).toBe(15);
59 + expect(summary.first?.time).toBe("2024-01-15T00:00:00.000Z");
60 + expect(summary.last?.value).toBe(429.64);
61 + expect(readFileSync(summary.rawPath, "utf8")).toBe(body);
62 + });
63 +
64 + it("noaa_gml_mlo: still archives the raw payload even when parsing fails (truncated file)", async () => {
65 + const body = fixture("co2_mm_mlo_truncated.txt");
66 + const fetchImpl: FetchLike = async () => ({ ok: true, status: 200, text: async () => body });
67 +
68 + await expect(runSource("noaa_gml_mlo", fetchImpl)).rejects.toThrow(/truncated/);
69 + // The raw bytes were archived before parsing — nothing ingested is ever lost.
70 + const archivedDir = join(process.env.RAW_ARCHIVE_DIR ?? "", "noaa_gml_mlo");
71 + expect(existsSync(archivedDir)).toBe(true);
72 + expect(readdirSync(archivedDir).some((f) => f.endsWith(".raw"))).toBe(true);
73 + });
74 +});
added apps/ingest/test/usgs.test.ts +99 −0
@@ -0,0 +1,99 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/ingest/test/usgs.test.ts
6 + * Purpose: USGS FDSN parsers/fetcher tests — fixture-backed, no network
7 + */
8 +
9 +import { readFileSync } from "node:fs";
10 +import { describe, expect, it } from "vitest";
11 +import type { FetchLike } from "../src/fetch-like";
12 +import {
13 + fetchUsgsQuakeCount,
14 + parseUsgsCount,
15 + parseUsgsLastMajor,
16 + usgsCountUrl,
17 +} from "../src/sources/usgs";
18 +
19 +const fixture = (name: string): string =>
20 + readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
21 +
22 +const fakeFetch =
23 + (body: string, ok = true, status = 200): FetchLike =>
24 + async () => ({ ok, status, text: async () => body });
25 +
26 +describe("usgsCountUrl", () => {
27 + it("builds a deterministic count URL from a parameterized now", () => {
28 + const nowMs = Date.parse("2026-08-09T12:00:00Z");
29 + expect(usgsCountUrl(24, 4.5, nowMs)).toBe(
30 + "https://earthquake.usgs.gov/fdsnws/event/1/count?format=geojson" +
31 + "&starttime=2026-08-08T12%3A00%3A00.000Z&minmagnitude=4.5",
32 + );
33 + });
34 +});
35 +
36 +describe("parseUsgsCount", () => {
37 + it("returns the count from a realistic payload", () => {
38 + expect(parseUsgsCount(JSON.parse(fixture("usgs_count_sample.json")))).toBe(132);
39 + });
40 +
41 + it("throws descriptive errors on malformed payloads", () => {
42 + expect(() => parseUsgsCount(JSON.parse(fixture("usgs_count_malformed.json")))).toThrow(
43 + /'count' must be a non-negative integer/,
44 + );
45 + expect(() => parseUsgsCount(null)).toThrow(/not an object/);
46 + expect(() => parseUsgsCount({ count: -3 })).toThrow(/non-negative/);
47 + expect(() => parseUsgsCount({ count: "132" })).toThrow(/non-negative integer/);
48 + });
49 +});
50 +
51 +describe("parseUsgsLastMajor", () => {
52 + it("extracts mag/place/timeIso from features[0]", () => {
53 + const result = parseUsgsLastMajor(JSON.parse(fixture("usgs_query_sample.json")));
54 + expect(result).toEqual({
55 + mag: 6.3,
56 + place: "142 km E of Petropavlovsk-Kamchatsky, Russia",
57 + timeIso: new Date(1754650000000).toISOString(),
58 + });
59 + });
60 +
61 + it("throws on the degraded fixture (missing mag, non-numeric time)", () => {
62 + expect(() => parseUsgsLastMajor(JSON.parse(fixture("usgs_query_malformed.json")))).toThrow(
63 + /mag is not a finite number/,
64 + );
65 + });
66 +
67 + it("throws on empty or missing features", () => {
68 + expect(() => parseUsgsLastMajor({ type: "FeatureCollection", features: [] })).toThrow(
69 + /'features' is missing or empty/,
70 + );
71 + expect(() => parseUsgsLastMajor({})).toThrow(/'features' is missing or empty/);
72 + });
73 +});
74 +
75 +describe("fetchUsgsQuakeCount", () => {
76 + it("fetches and parses via an injected fetchImpl (no network)", async () => {
77 + const seen: string[] = [];
78 + const fetchImpl: FetchLike = async (url) => {
79 + seen.push(url);
80 + return { ok: true, status: 200, text: async () => fixture("usgs_count_sample.json") };
81 + };
82 + await expect(fetchUsgsQuakeCount(24, 4.5, fetchImpl)).resolves.toBe(132);
83 + expect(seen).toHaveLength(1);
84 + expect(seen[0]).toContain("/fdsnws/event/1/count?format=geojson");
85 + expect(seen[0]).toContain("minmagnitude=4.5");
86 + });
87 +
88 + it("throws on HTTP errors", async () => {
89 + await expect(fetchUsgsQuakeCount(24, 4.5, fakeFetch("", false, 503))).rejects.toThrow(
90 + /HTTP 503/,
91 + );
92 + });
93 +
94 + it("throws on non-JSON bodies", async () => {
95 + await expect(fetchUsgsQuakeCount(24, 4.5, fakeFetch("<html>oops</html>"))).rejects.toThrow(
96 + /not valid JSON/,
97 + );
98 + });
99 +});
added apps/ingest/tsconfig.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "outDir": "dist",
5 + "rootDir": "src",
6 + "module": "NodeNext",
7 + "moduleResolution": "NodeNext"
8 + },
9 + "include": ["src"]
10 +}
added apps/web/app/embed/[id]/page.tsx +58 −0
@@ -0,0 +1,58 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/app/embed/[id]/page.tsx
6 + * Purpose: Minimal standalone embed page — one fullscreen LiveCounter for iframes (?window=&lang=), no chrome, earth-now.co attribution
7 + */
8 +
9 +import EmbedCounter from "@/components/EmbedCounter";
10 +import { fetchMetrics, fetchModels } from "@/lib/api";
11 +import { resolveModel, resolvePrimaryWindow } from "@/lib/derived";
12 +import { DEFAULT_LOCALE, isLocale } from "@/lib/messages";
13 +import type { CounterWindow } from "@earth-now/counter";
14 +
15 +export const dynamic = "force-dynamic";
16 +
17 +function isWindow(value: unknown): value is CounterWindow {
18 + return value === "today" || value === "ytd" || value === "session" || value === "total";
19 +}
20 +
21 +export default async function EmbedPage({
22 + params,
23 + searchParams,
24 +}: {
25 + params: { id: string };
26 + searchParams?: { window?: string; lang?: string };
27 +}) {
28 + const langParam = searchParams?.lang;
29 + const locale = isLocale(langParam) ? langParam : DEFAULT_LOCALE;
30 +
31 + try {
32 + const [metrics, modelsResponse] = await Promise.all([fetchMetrics(), fetchModels()]);
33 + const metric = metrics.find((m) => m.id === params.id);
34 + if (metric === undefined) {
35 + return (
36 + <div className="fixed inset-0 z-50 flex items-center justify-center bg-page text-sm text-ink2">
37 + {locale === "fr" ? "Métrique introuvable" : "Metric not found"} — earth-now.co
38 + </div>
39 + );
40 + }
41 + const windowParam = searchParams?.window;
42 + const counterWindow = isWindow(windowParam) ? windowParam : resolvePrimaryWindow(metric);
43 + return (
44 + <EmbedCounter
45 + metric={metric}
46 + model={resolveModel(metric, modelsResponse.models)}
47 + window={counterWindow}
48 + locale={locale}
49 + />
50 + );
51 + } catch {
52 + return (
53 + <div className="fixed inset-0 z-50 flex items-center justify-center bg-page text-sm text-ink2">
54 + API offline — earth-now.co
55 + </div>
56 + );
57 + }
58 +}
added apps/web/app/globals.css +92 −0
@@ -0,0 +1,92 @@
1 +/*
2 + * earth-now.co — apps/web/app/globals.css
3 + * Tailwind layers + theme tokens. LIGHT theme is the default; dark is an explicit
4 + * toggle ([data-theme="dark"]). Values from the validated reference palette.
5 + */
6 +
7 +@tailwind base;
8 +@tailwind components;
9 +@tailwind utilities;
10 +
11 +:root {
12 + color-scheme: light;
13 + --page: #f9f9f7;
14 + --surface: #fcfcfb;
15 + --ink: #0b0b0b;
16 + --ink-2: #52514e;
17 + --muted: #898781;
18 + --line: #e1e0d9;
19 + --border: rgba(11, 11, 11, 0.1);
20 + --accent: #2a78d6;
21 + --accent-wash: rgba(42, 120, 214, 0.07);
22 + --good: #006300;
23 + --warn: #fab219;
24 + --critical: #d03b3b;
25 +}
26 +
27 +:root[data-theme="dark"] {
28 + color-scheme: dark;
29 + --page: #0d0d0d;
30 + --surface: #1a1a19;
31 + --ink: #ffffff;
32 + --ink-2: #c3c2b7;
33 + --muted: #898781;
34 + --line: #2c2c2a;
35 + --border: rgba(255, 255, 255, 0.1);
36 + --accent: #3987e5;
37 + --accent-wash: rgba(57, 135, 229, 0.12);
38 + --good: #0ca30c;
39 + --warn: #fab219;
40 + --critical: #d03b3b;
41 +}
42 +
43 +body {
44 + background-color: var(--page);
45 + color: var(--ink);
46 + -webkit-font-smoothing: antialiased;
47 + /* Faint planetary wash at the top of the page — light and dark variants via tokens. */
48 + background-image: radial-gradient(90rem 28rem at 50% -6rem, var(--accent-wash), transparent 70%);
49 + background-repeat: no-repeat;
50 +}
51 +
52 +/* Pulsing "live" indicator — honesty rule: only rendered on counters the tier
53 + system classified as visibly ticking. Static under prefers-reduced-motion. */
54 +.live-dot {
55 + width: 0.5rem;
56 + height: 0.5rem;
57 + border-radius: 9999px;
58 + background-color: var(--accent);
59 + animation: live-pulse 2s ease-in-out infinite;
60 +}
61 +
62 +@keyframes live-pulse {
63 + 0%,
64 + 100% {
65 + box-shadow: 0 0 0 0 var(--accent-wash);
66 + opacity: 1;
67 + }
68 + 50% {
69 + box-shadow: 0 0 0 6px var(--accent-wash);
70 + opacity: 0.75;
71 + }
72 +}
73 +
74 +@media (prefers-reduced-motion: reduce) {
75 + .live-dot {
76 + animation: none;
77 + }
78 + * {
79 + transition-duration: 0.01ms !important;
80 + }
81 +}
82 +
83 +/* Ticking digits never shift horizontally (functional, not decorative:
84 + a proportional-figure ticker would jitter as digits change). */
85 +.tabular-nums {
86 + font-variant-numeric: tabular-nums;
87 + font-feature-settings: "tnum" 1;
88 +}
89 +
90 +::selection {
91 + background-color: var(--accent-wash);
92 +}
added apps/web/app/layout.tsx +53 −0
@@ -0,0 +1,53 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/app/layout.tsx
6 + * Purpose: Root layout — light-first shell (dark via data-theme toggle, read pre-paint), metadata, footer attribution + methodology link
7 + */
8 +
9 +import type { Metadata } from "next";
10 +import Link from "next/link";
11 +import type { ReactNode } from "react";
12 +import "./globals.css";
13 +
14 +export const metadata: Metadata = {
15 + title: "earth-now.co — La planète en direct",
16 + description:
17 + "La planète en direct / The planet, live — compteurs mondiaux pilotés par des modèles statistiques documentés (sources, incertitude, méthode).",
18 +};
19 +
20 +// Applied before paint so a saved dark preference never flashes light.
21 +// LIGHT is the default — dark only when explicitly chosen.
22 +const THEME_BOOT = `try{if(localStorage.getItem("earth-now.theme")==="dark")document.documentElement.dataset.theme="dark"}catch(e){}`;
23 +
24 +export default function RootLayout({ children }: { children: ReactNode }) {
25 + // Default lang is "fr"; the client dashboard syncs document.documentElement.lang
26 + // with the user's locale toggle (persisted in localStorage).
27 + return (
28 + <html lang="fr">
29 + <body className="min-h-screen font-sans">
30 + <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} />
31 + <div className="flex min-h-screen flex-col">
32 + <main className="flex-1">{children}</main>
33 + <footer className="border-t border-line px-4 py-6">
34 + <div className="mx-auto flex max-w-6xl flex-col gap-2 text-xs text-muted sm:flex-row sm:items-center sm:justify-between">
35 + <p className="max-w-3xl">
36 + Données : ONU (WPP), NOAA GML, Global Carbon Project, Copernicus/ERA5, FAO,
37 + Global Forest Watch, Ember/IEA, USGS — chaque compteur est un modèle statistique
38 + documenté. / Data: every counter is a documented statistical model over real
39 + observations.
40 + </p>
41 + <Link
42 + href="/methodology"
43 + className="shrink-0 text-accent underline decoration-accent/40 hover:opacity-80"
44 + >
45 + Méthodologie / Methodology
46 + </Link>
47 + </div>
48 + </footer>
49 + </div>
50 + </body>
51 + </html>
52 + );
53 +}
added apps/web/app/methodology/page.tsx +136 −0
@@ -0,0 +1,136 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/app/methodology/page.tsx
6 + * Purpose: Public methodology page (product commitment) — per-metric source, license, cadence, model family/version, last observation, uncertainty
7 + */
8 +
9 +import Link from "next/link";
10 +import { formatValue } from "@earth-now/counter";
11 +import { fetchMetrics, type MetricSummary } from "@/lib/api";
12 +import {
13 + DEFAULT_LOCALE,
14 + formatDateUtc,
15 + isLocale,
16 + translate,
17 + type MessageKey,
18 +} from "@/lib/messages";
19 +
20 +export const dynamic = "force-dynamic";
21 +
22 +const PERCENT_HINTS = { decimals: 0, unit: "%" } as const;
23 +
24 +function levelLabel(metric: MetricSummary): string {
25 + const level = typeof metric.level === "number" ? `L${metric.level}` : metric.level.toUpperCase();
26 + const version = metric.modelVersion !== undefined ? ` · ${metric.modelVersion}` : "";
27 + return `${level} · ${metric.model}${version}`;
28 +}
29 +
30 +export default async function MethodologyPage({
31 + searchParams,
32 +}: {
33 + searchParams?: { lang?: string };
34 +}) {
35 + const langParam = searchParams?.lang;
36 + const locale = isLocale(langParam) ? langParam : DEFAULT_LOCALE;
37 + const t = (key: MessageKey) => translate(locale, key);
38 +
39 + let metrics: MetricSummary[] = [];
40 + let offline = false;
41 + try {
42 + metrics = await fetchMetrics();
43 + } catch {
44 + offline = true;
45 + }
46 +
47 + return (
48 + <div className="mx-auto max-w-6xl px-4 py-10">
49 + <Link href="/" className="text-xs text-accent hover:opacity-80">
50 + {t("methodology.back")}
51 + </Link>
52 + <h1 className="mt-3 text-2xl font-semibold tracking-tight text-ink">
53 + {t("methodology.title")}
54 + </h1>
55 + <p className="mt-3 max-w-3xl text-sm leading-relaxed text-ink2">
56 + {t("methodology.intro")}
57 + </p>
58 +
59 + {offline ? (
60 + <p className="mt-8 text-sm text-ink2">{t("offline.body")}</p>
61 + ) : (
62 + <div className="mt-8 overflow-x-auto">
63 + <table className="w-full min-w-[900px] border-collapse text-left text-xs">
64 + <thead>
65 + <tr className="border-b border-line text-ink2">
66 + <th className="py-2 pr-3 font-medium">{t("methodology.metric")}</th>
67 + <th className="py-2 pr-3 font-medium">{t("methodology.domain")}</th>
68 + <th className="py-2 pr-3 font-medium">{t("methodology.kind")}</th>
69 + <th className="py-2 pr-3 font-medium">{t("methodology.model")}</th>
70 + <th className="py-2 pr-3 font-medium">{t("methodology.source")}</th>
71 + <th className="py-2 pr-3 font-medium">{t("methodology.license")}</th>
72 + <th className="py-2 pr-3 font-medium">{t("methodology.cadence")}</th>
73 + <th className="py-2 pr-3 font-medium">{t("methodology.observed")}</th>
74 + <th className="py-2 pr-3 font-medium">{t("methodology.uncertainty")}</th>
75 + <th className="py-2 font-medium">{t("methodology.note")}</th>
76 + </tr>
77 + </thead>
78 + <tbody>
79 + {metrics.map((metric) => {
80 + const source = metric.sources[0];
81 + return (
82 + <tr key={metric.id} className="border-b border-line align-top text-ink2">
83 + <td className="py-2 pr-3 font-medium text-ink">
84 + {metric.name[locale]}
85 + {metric.stale && (
86 + <span className="ml-2 rounded border border-warn/50 px-1.5 py-0.5 text-[10px] uppercase text-ink2">
87 + {t("chip.stale")}
88 + </span>
89 + )}
90 + </td>
91 + <td className="py-2 pr-3">
92 + {t(`domain.${metric.domain}` as MessageKey)}
93 + </td>
94 + <td className="py-2 pr-3">{t(`kind.${metric.kind}` as MessageKey)}</td>
95 + <td className="py-2 pr-3 tabular-nums">{levelLabel(metric)}</td>
96 + <td className="py-2 pr-3">
97 + {source !== undefined ? (
98 + <a
99 + href={source.url}
100 + target="_blank"
101 + rel="noreferrer"
102 + className="text-accent underline decoration-accent/40 hover:opacity-80"
103 + >
104 + {source.name}
105 + </a>
106 + ) : (
107 + "—"
108 + )}
109 + </td>
110 + <td className="py-2 pr-3">{source?.license ?? "—"}</td>
111 + <td className="py-2 pr-3">{source?.cadence ?? "—"}</td>
112 + <td className="py-2 pr-3">
113 + {metric.observedAt !== undefined
114 + ? formatDateUtc(metric.observedAt, locale)
115 + : "—"}
116 + </td>
117 + <td className="py-2 pr-3 tabular-nums">
118 + {metric.uncertaintyFraction !== undefined
119 + ? `± ${formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, {
120 + locale,
121 + })} % (${t("chip.estimate")})`
122 + : "—"}
123 + </td>
124 + <td className="py-2 text-ink2">
125 + {metric.editorialNote !== undefined ? metric.editorialNote[locale] : ""}
126 + </td>
127 + </tr>
128 + );
129 + })}
130 + </tbody>
131 + </table>
132 + </div>
133 + )}
134 + </div>
135 + );
136 +}
added apps/web/app/metric/[id]/page.tsx +47 −0
@@ -0,0 +1,47 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/app/metric/[id]/page.tsx
6 + * Purpose: Public per-metric page — live counter, real-time model chart and the metric's own methodology
7 + */
8 +
9 +import type { Metadata } from "next";
10 +import { notFound } from "next/navigation";
11 +import MetricDetail from "@/components/MetricDetail";
12 +import { fetchMetrics, fetchModels } from "@/lib/api";
13 +
14 +export const dynamic = "force-dynamic";
15 +
16 +interface Params {
17 + params: { id: string };
18 +}
19 +
20 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
21 + try {
22 + const metrics = await fetchMetrics();
23 + const metric = metrics.find((m) => m.id === params.id);
24 + if (metric === undefined) return { title: "earth-now.co" };
25 + return {
26 + title: `${metric.name.fr} — earth-now.co`,
27 + description: `${metric.name.fr} / ${metric.name.en} — compteur live piloté par un modèle statistique documenté (${metric.model}).`,
28 + };
29 + } catch {
30 + return { title: "earth-now.co" };
31 + }
32 +}
33 +
34 +export default async function MetricPage({ params }: Params) {
35 + let metric;
36 + let model = null;
37 + try {
38 + const [metrics, models] = await Promise.all([fetchMetrics(), fetchModels()]);
39 + metric = metrics.find((m) => m.id === params.id);
40 + model = models.models[params.id] ?? null;
41 + } catch {
42 + // API down: 404 rather than a broken page (the dashboard has the offline state).
43 + notFound();
44 + }
45 + if (metric === undefined) notFound();
46 + return <MetricDetail metric={metric} model={model} />;
47 +}
added apps/web/app/page.tsx +39 −0
@@ -0,0 +1,39 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/app/page.tsx
6 + * Purpose: Home page (server component) — fetches registry metrics + counter models and hands off to the client dashboard; friendly API-offline state
7 + */
8 +
9 +import Dashboard from "@/components/Dashboard";
10 +import { fetchMetrics, fetchModels } from "@/lib/api";
11 +
12 +export const dynamic = "force-dynamic";
13 +
14 +export default async function HomePage() {
15 + try {
16 + const [metrics, modelsResponse] = await Promise.all([fetchMetrics(), fetchModels()]);
17 + return <Dashboard metrics={metrics} initialModels={modelsResponse.models} />;
18 + } catch {
19 + // Never crash the page when apps/api is down — render a calm offline state.
20 + return (
21 + <div className="mx-auto flex max-w-2xl flex-col items-center gap-3 px-4 py-24 text-center">
22 + <p className="text-4xl" aria-hidden>
23 + 🛰️
24 + </p>
25 + <h1 className="text-xl font-semibold text-ink">
26 + API hors ligne / API offline
27 + </h1>
28 + <p className="text-sm text-ink2">
29 + Les compteurs sont momentanément indisponibles — le serveur de modèles ne répond pas.
30 + Réessayez dans un instant.
31 + </p>
32 + <p className="text-sm text-muted">
33 + Counters are momentarily unavailable — the model server is not responding. Please try
34 + again shortly.
35 + </p>
36 + </div>
37 + );
38 + }
39 +}
added apps/web/components/CountryRanking.tsx +112 −0
@@ -0,0 +1,112 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/CountryRanking.tsx
6 + * Purpose: Top-10 countries by population — live re-ranked list, ticking values, single-hue magnitude bars (length encodes, not color)
7 + */
8 +
9 +"use client";
10 +
11 +import { useEffect, useState } from "react";
12 +import { counterValue, formatRate, formatValue, rateAt, type CounterModel } from "@earth-now/counter";
13 +import type { MetricSummary } from "@/lib/api";
14 +import { useI18n } from "@/lib/i18n";
15 +
16 +const FLAGS: Record<string, string> = {
17 + country_population_india: "🇮🇳",
18 + country_population_china: "🇨🇳",
19 + country_population_usa: "🇺🇸",
20 + country_population_indonesia: "🇮🇩",
21 + country_population_pakistan: "🇵🇰",
22 + country_population_nigeria: "🇳🇬",
23 + country_population_brazil: "🇧🇷",
24 + country_population_bangladesh: "🇧🇩",
25 + country_population_russia: "🇷🇺",
26 + country_population_mexico: "🇲🇽",
27 + continent_population_asia: "🌏",
28 + continent_population_africa: "🌍",
29 + continent_population_europe: "🌍",
30 + continent_population_latam: "🌎",
31 + continent_population_north_america: "🌎",
32 + continent_population_oceania: "🌏",
33 +};
34 +
35 +interface Row {
36 + metric: MetricSummary;
37 + value: number;
38 + perSecond: number;
39 +}
40 +
41 +export interface CountryRankingProps {
42 + metrics: MetricSummary[];
43 + models: Record<string, CounterModel>;
44 +}
45 +
46 +export default function CountryRanking({ metrics, models }: CountryRankingProps) {
47 + const { locale } = useI18n();
48 + const [rows, setRows] = useState<Row[]>([]);
49 +
50 + useEffect(() => {
51 + const compute = () => {
52 + const now = Date.now();
53 + const next: Row[] = [];
54 + for (const metric of metrics) {
55 + const model = models[metric.id];
56 + if (model === undefined) continue;
57 + const value = counterValue(model, now);
58 + if (!Number.isFinite(value)) continue;
59 + next.push({ metric, value, perSecond: rateAt(model, now) });
60 + }
61 + next.sort((a, b) => b.value - a.value);
62 + setRows(next);
63 + };
64 + compute();
65 + const interval = window.setInterval(compute, 1_000);
66 + return () => window.clearInterval(interval);
67 + }, [metrics, models]);
68 +
69 + if (rows.length === 0) return null;
70 + const max = rows[0]!.value;
71 +
72 + return (
73 + <ol className="divide-y divide-line rounded-2xl border bg-surface">
74 + {rows.map((row, index) => {
75 + const hints = {
76 + decimals: row.metric.display.decimals,
77 + unit: row.metric.display.unit[locale],
78 + ...(row.metric.display.scale !== undefined ? { scale: row.metric.display.scale } : {}),
79 + };
80 + return (
81 + <li key={row.metric.id} className="flex min-w-0 items-center gap-3 px-4 py-2.5 transition-colors hover:bg-page/60">
82 + <span className="w-5 shrink-0 text-right text-xs text-muted tabular-nums">
83 + {index + 1}
84 + </span>
85 + <span aria-hidden className="shrink-0 text-base leading-none">
86 + {FLAGS[row.metric.id] ?? "🌍"}
87 + </span>
88 + <span className="w-28 shrink-0 truncate text-sm text-ink sm:w-36">
89 + {row.metric.name[locale]}
90 + </span>
91 + <span className="hidden flex-1 sm:block" aria-hidden>
92 + {/* Magnitude bar: single hue, length encodes the value (4px rounded ends). */}
93 + <span className="block h-1.5 w-full rounded-full bg-line">
94 + <span
95 + className="block h-1.5 rounded-full bg-accent transition-[width] duration-1000 ease-linear"
96 + style={{ width: `${Math.max(2, (row.value / max) * 100)}%` }}
97 + />
98 + </span>
99 + </span>
100 + <span className="ml-auto min-w-0 shrink text-right text-sm font-medium text-ink tabular-nums">
101 + {formatValue(row.value, hints, { locale, applySigFigs: false })}
102 + </span>
103 + <span className="hidden w-20 shrink-0 text-right text-xs text-ink2 tabular-nums sm:block">
104 + {row.perSecond >= 0 ? "+" : ""}
105 + {formatRate(row.perSecond, hints, { locale })}
106 + </span>
107 + </li>
108 + );
109 + })}
110 + </ol>
111 + );
112 +}
added apps/web/components/Dashboard.tsx +418 −0
@@ -0,0 +1,418 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/Dashboard.tsx
6 + * Purpose: Client dashboard orchestrator — live-first layout (hero, live grid by tick speed, session strip, country ranking, realtime, reference strip)
7 + */
8 +
9 +"use client";
10 +
11 +import { useEffect, useMemo, useState } from "react";
12 +import { formatValue, type CounterModel } from "@earth-now/counter";
13 +import {
14 + fetchQuakes,
15 + sseStreamUrl,
16 + type Locale,
17 + type MetricSummary,
18 + type QuakesResponse,
19 +} from "@/lib/api";
20 +import { resolveModel, resolvePrimaryWindow } from "@/lib/derived";
21 +import { I18nProvider, useI18n } from "@/lib/i18n";
22 +import { formatDateUtc, isLocale } from "@/lib/messages";
23 +import { CONTINENT_PREFIX, HERO_METRIC_ID, liveSortKey, tierFor, type DisplayTier } from "@/lib/tiers";
24 +import CountryRanking from "./CountryRanking";
25 +import YearProgress from "./YearProgress";
26 +import LiveCounter, { type PreviousModel } from "./LiveCounter";
27 +
28 +const LOCALE_STORAGE_KEY = "earth-now.locale";
29 +const THEME_STORAGE_KEY = "earth-now.theme";
30 +
31 +type Theme = "light" | "dark";
32 +
33 +/** Catalog §14 vedette absolue — the "since you arrived" metrics, session window. */
34 +const SESSION_METRIC_IDS = [
35 + "births_ytd",
36 + "deaths_ytd",
37 + "co2_emissions_ytd",
38 + "forest_loss_ytd",
39 + "earth_orbit_ytd",
40 + "solar_installed_ytd",
41 +] as const;
42 +
43 +interface ModelSlot {
44 + model: CounterModel;
45 + prev?: PreviousModel;
46 +}
47 +
48 +export interface DashboardProps {
49 + metrics: MetricSummary[];
50 + initialModels: Record<string, CounterModel>;
51 +}
52 +
53 +function toSlots(models: Record<string, CounterModel>): Record<string, ModelSlot> {
54 + const slots: Record<string, ModelSlot> = {};
55 + for (const [id, model] of Object.entries(models)) slots[id] = { model };
56 + return slots;
57 +}
58 +
59 +function sameModel(a: CounterModel, b: CounterModel): boolean {
60 + return (
61 + a.modelVersion === b.modelVersion &&
62 + a.anchorTime === b.anchorTime &&
63 + a.anchorValue === b.anchorValue &&
64 + a.observedAt === b.observedAt
65 + );
66 +}
67 +
68 +export default function Dashboard({ metrics, initialModels }: DashboardProps) {
69 + const [locale, setLocaleState] = useState<Locale>("fr");
70 + const [theme, setThemeState] = useState<Theme>("light");
71 + const [slots, setSlots] = useState<Record<string, ModelSlot>>(() => toSlots(initialModels));
72 + // User arrival instant — anchors every "since you arrived" counter.
73 + const sessionStart = useMemo(() => Date.now(), []);
74 +
75 + useEffect(() => {
76 + const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
77 + if (isLocale(savedLocale)) setLocaleState(savedLocale);
78 + if (window.localStorage.getItem(THEME_STORAGE_KEY) === "dark") setThemeState("dark");
79 + }, []);
80 +
81 + useEffect(() => {
82 + document.documentElement.lang = locale;
83 + }, [locale]);
84 +
85 + useEffect(() => {
86 + if (theme === "dark") document.documentElement.dataset.theme = "dark";
87 + else delete document.documentElement.dataset.theme;
88 + }, [theme]);
89 +
90 + const setLocale = (next: Locale) => {
91 + setLocaleState(next);
92 + window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
93 + };
94 + const toggleTheme = () => {
95 + setThemeState((current) => {
96 + const next: Theme = current === "light" ? "dark" : "light";
97 + window.localStorage.setItem(THEME_STORAGE_KEY, next);
98 + return next;
99 + });
100 + };
101 +
102 + // SSE: the server pushes MODELS, never per-tick values. Replacements are
103 + // cross-faded over 60 s by LiveCounter (prev + swapStartMs) — no visible jump.
104 + useEffect(() => {
105 + const applyModel = (incoming: CounterModel, receivedAt: number) => {
106 + setSlots((current) => {
107 + const existing = current[incoming.metricId];
108 + if (existing !== undefined && sameModel(existing.model, incoming)) return current;
109 + const slot: ModelSlot =
110 + existing !== undefined
111 + ? { model: incoming, prev: { model: existing.model, swapStartMs: receivedAt } }
112 + : { model: incoming };
113 + return { ...current, [incoming.metricId]: slot };
114 + });
115 + };
116 +
117 + const source = new EventSource(sseStreamUrl());
118 + source.addEventListener("models", (event) => {
119 + try {
120 + const payload = JSON.parse((event as MessageEvent<string>).data) as
121 + | Record<string, CounterModel>
122 + | { models: Record<string, CounterModel> };
123 + const models =
124 + "models" in payload && typeof payload.models === "object"
125 + ? (payload as { models: Record<string, CounterModel> }).models
126 + : (payload as Record<string, CounterModel>);
127 + const now = Date.now();
128 + for (const model of Object.values(models)) applyModel(model, now);
129 + } catch {
130 + // Malformed frame — keep the current models (never break a running counter).
131 + }
132 + });
133 + source.addEventListener("model", (event) => {
134 + try {
135 + const model = JSON.parse((event as MessageEvent<string>).data) as CounterModel;
136 + applyModel(model, Date.now());
137 + } catch {
138 + // Malformed frame — ignored.
139 + }
140 + });
141 + return () => source.close();
142 + }, []);
143 +
144 + return (
145 + <I18nProvider locale={locale} setLocale={setLocale}>
146 + <DashboardBody
147 + metrics={metrics}
148 + slots={slots}
149 + sessionStart={sessionStart}
150 + theme={theme}
151 + onToggleTheme={toggleTheme}
152 + />
153 + </I18nProvider>
154 + );
155 +}
156 +
157 +function DashboardBody({
158 + metrics,
159 + slots,
160 + sessionStart,
161 + theme,
162 + onToggleTheme,
163 +}: {
164 + metrics: MetricSummary[];
165 + slots: Record<string, ModelSlot>;
166 + sessionStart: number;
167 + theme: Theme;
168 + onToggleTheme: () => void;
169 +}) {
170 + const { locale, setLocale, t } = useI18n();
171 + const models = useMemo(() => {
172 + const map: Record<string, CounterModel> = {};
173 + for (const [id, slot] of Object.entries(slots)) map[id] = slot.model;
174 + return map;
175 + }, [slots]);
176 +
177 + // Live-first information architecture: tiers computed from the models
178 + // themselves (tick speed of the last visible digit) — not hand-picked.
179 + const tiers = useMemo(() => {
180 + const now = Date.now();
181 + const byTier: Record<DisplayTier, MetricSummary[]> = {
182 + hero: [],
183 + live: [],
184 + country: [],
185 + realtime: [],
186 + reference: [],
187 + };
188 + for (const metric of metrics) {
189 + byTier[tierFor(metric, resolveModel(metric, models), now)].push(metric);
190 + }
191 + byTier.live.sort(
192 + (a, b) =>
193 + liveSortKey(a, resolveModel(a, models), now) -
194 + liveSortKey(b, resolveModel(b, models), now),
195 + );
196 + return byTier;
197 + }, [metrics, models]);
198 +
199 + const hero = metrics.find((m) => m.id === HERO_METRIC_ID);
200 + const sessionMetrics = SESSION_METRIC_IDS.map((id) => metrics.find((m) => m.id === id)).filter(
201 + (m): m is MetricSummary => m !== undefined,
202 + );
203 +
204 + const slotFor = (metric: MetricSummary): ModelSlot | undefined => {
205 + const own = slots[metric.id];
206 + if (own !== undefined) return own;
207 + const inputId = metric.derived?.inputs[0]?.id;
208 + return inputId !== undefined ? slots[inputId] : undefined;
209 + };
210 +
211 + const counterProps = (metric: MetricSummary) => ({
212 + metric,
213 + model: resolveModel(metric, models),
214 + prev: slotFor(metric)?.prev,
215 + sessionStart,
216 + });
217 +
218 + return (
219 + <div className="mx-auto max-w-6xl px-4 pb-16">
220 + <header className="sticky top-0 z-30 -mx-4 mb-4 border-b bg-page/85 px-4 py-3 backdrop-blur-md">
221 + <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
222 + <div className="flex items-center gap-3">
223 + <h1 className="text-xl font-semibold tracking-tight text-ink">{t("site.title")}</h1>
224 + <span className="flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide text-accent">
225 + <span aria-hidden className="live-dot" />
226 + {t("live.title")}
227 + </span>
228 + </div>
229 + <div className="flex items-center gap-2">
230 + <button
231 + type="button"
232 + onClick={onToggleTheme}
233 + className="rounded-full border px-3 py-1 text-xs text-ink2 transition-colors hover:border-ink/30"
234 + >
235 + {theme === "light" ? t("theme.toDark") : t("theme.toLight")}
236 + </button>
237 + <button
238 + type="button"
239 + onClick={() => setLocale(locale === "fr" ? "en" : "fr")}
240 + className="rounded-full border px-3 py-1 text-xs text-ink2 transition-colors hover:border-ink/30"
241 + >
242 + {t("locale.toggle")}
243 + </button>
244 + </div>
245 + </div>
246 + </header>
247 + <p className="mb-4 max-w-2xl text-sm text-ink2">{t("site.tagline")}</p>
248 + <nav aria-label="sections" className="mb-5 flex flex-wrap gap-2 text-xs">
249 + {[
250 + ["#section-live", t("live.title")],
251 + ["#since-arrival", t("since.title")],
252 + ["#section-countries", t("countries.title")],
253 + ["#section-realtime", t("realtime.title")],
254 + ["#section-reference", t("reference.title")],
255 + ].map(([href, label]) => (
256 + <a
257 + key={href}
258 + href={href}
259 + className="rounded-full border px-3 py-1 text-ink2 transition-colors hover:border-ink/30 hover:text-ink"
260 + >
261 + {label}
262 + </a>
263 + ))}
264 + </nav>
265 + <YearProgress />
266 +
267 + {hero !== undefined && (
268 + <section aria-labelledby="hero-figure" className="mb-10">
269 + <LiveCounter {...counterProps(hero)} window="total" size="display" live />
270 + </section>
271 + )}
272 +
273 + {tiers.live.length > 0 && (
274 + <Section id="live" title={t("live.title")} subtitle={t("live.subtitle")}>
275 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
276 + {tiers.live.map((metric) => (
277 + <LiveCounter
278 + key={metric.id}
279 + {...counterProps(metric)}
280 + window={resolvePrimaryWindow(metric)}
281 + live
282 + />
283 + ))}
284 + </div>
285 + </Section>
286 + )}
287 +
288 + {sessionMetrics.length > 0 && (
289 + <section
290 + aria-labelledby="since-arrival"
291 + className="mb-12 rounded-3xl border bg-gradient-to-br from-wash via-surface to-surface p-5 sm:p-6"
292 + >
293 + <h2 id="since-arrival" className="text-lg font-semibold text-ink">
294 + {t("since.title")}
295 + </h2>
296 + <p className="mb-4 mt-1 text-sm text-ink2">{t("since.subtitle")}</p>
297 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
298 + {sessionMetrics.map((metric) => (
299 + <LiveCounter
300 + key={`session-${metric.id}`}
301 + {...counterProps(metric)}
302 + window="session"
303 + live
304 + />
305 + ))}
306 + </div>
307 + </section>
308 + )}
309 +
310 + {tiers.country.filter((m) => !m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (
311 + <Section
312 + id="countries"
313 + title={t("countries.title")}
314 + subtitle={t("countries.subtitle")}
315 + >
316 + <CountryRanking
317 + metrics={tiers.country.filter((m) => !m.id.startsWith(CONTINENT_PREFIX))}
318 + models={models}
319 + />
320 + </Section>
321 + )}
322 +
323 + {tiers.country.filter((m) => m.id.startsWith(CONTINENT_PREFIX)).length > 0 && (
324 + <Section
325 + id="continents"
326 + title={t("continents.title")}
327 + subtitle={t("continents.subtitle")}
328 + >
329 + <CountryRanking
330 + metrics={tiers.country.filter((m) => m.id.startsWith(CONTINENT_PREFIX))}
331 + models={models}
332 + />
333 + </Section>
334 + )}
335 +
336 + {tiers.realtime.length > 0 && (
337 + <Section id="realtime" title={t("realtime.title")} subtitle={t("realtime.subtitle")}>
338 + <QuakesLine />
339 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
340 + {tiers.realtime.map((metric) => (
341 + <LiveCounter key={metric.id} {...counterProps(metric)} window="total" />
342 + ))}
343 + </div>
344 + </Section>
345 + )}
346 +
347 + {tiers.reference.length > 0 && (
348 + <Section
349 + id="reference"
350 + title={t("reference.title")}
351 + subtitle={t("reference.subtitle")}
352 + >
353 + <div className="divide-y divide-line rounded-2xl border bg-surface">
354 + {tiers.reference.map((metric) => (
355 + <LiveCounter
356 + key={metric.id}
357 + {...counterProps(metric)}
358 + window={resolvePrimaryWindow(metric)}
359 + size="row"
360 + />
361 + ))}
362 + </div>
363 + </Section>
364 + )}
365 + </div>
366 + );
367 +}
368 +
369 +function Section({
370 + id,
371 + title,
372 + subtitle,
373 + children,
374 +}: {
375 + id: string;
376 + title: string;
377 + subtitle: string;
378 + children: React.ReactNode;
379 +}) {
380 + return (
381 + <section aria-labelledby={`section-${id}`} className="mb-12">
382 + <h2 id={`section-${id}`} className="text-lg font-semibold text-ink">
383 + {title}
384 + </h2>
385 + <p className="mb-4 mt-1 text-sm text-ink2">{subtitle}</p>
386 + {children}
387 + </section>
388 + );
389 +}
390 +
391 +/** True event-driven realtime (USGS): one fetch on mount, no interpolation. */
392 +function QuakesLine() {
393 + const { locale, t } = useI18n();
394 + const [quakes, setQuakes] = useState<QuakesResponse | null>(null);
395 +
396 + useEffect(() => {
397 + let cancelled = false;
398 + fetchQuakes()
399 + .then((q) => {
400 + if (!cancelled) setQuakes(q);
401 + })
402 + .catch(() => {
403 + // Realtime extras are best-effort — the counter cards remain authoritative.
404 + });
405 + return () => {
406 + cancelled = true;
407 + };
408 + }, []);
409 +
410 + if (quakes === null || quakes.lastMajor === null) return null;
411 + const magnitude = formatValue(quakes.lastMajor.mag, { decimals: 1, unit: "" }, { locale });
412 + return (
413 + <p className="mb-3 text-xs text-ink2">
414 + {t("quakes.lastMajor")} : M {magnitude} — {quakes.lastMajor.place} (
415 + {formatDateUtc(quakes.lastMajor.timeIso, locale)})
416 + </p>
417 + );
418 +}
added apps/web/components/EmbedCounter.tsx +53 −0
@@ -0,0 +1,53 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/EmbedCounter.tsx
6 + * Purpose: Client wrapper for /embed/[id] — one fullscreen LiveCounter with a fixed locale and a tiny earth-now.co attribution link
7 + */
8 +
9 +"use client";
10 +
11 +import { useMemo, useState } from "react";
12 +import type { CounterModel, CounterWindow } from "@earth-now/counter";
13 +import type { Locale, MetricSummary } from "@/lib/api";
14 +import { I18nProvider } from "@/lib/i18n";
15 +import LiveCounter from "./LiveCounter";
16 +
17 +export interface EmbedCounterProps {
18 + metric: MetricSummary;
19 + model?: CounterModel | undefined;
20 + window: CounterWindow;
21 + locale: Locale;
22 +}
23 +
24 +export default function EmbedCounter({ metric, model, window: win, locale }: EmbedCounterProps) {
25 + // Locale is fixed by the ?lang= query — the toggle just satisfies the provider contract.
26 + const [embedLocale, setEmbedLocale] = useState<Locale>(locale);
27 + // Session window in an embed = since the iframe loaded.
28 + const sessionStart = useMemo(() => Date.now(), []);
29 +
30 + return (
31 + <I18nProvider locale={embedLocale} setLocale={setEmbedLocale}>
32 + <div className="fixed inset-0 z-50 flex items-center justify-center bg-page p-4">
33 + <div className="w-full max-w-xl">
34 + <LiveCounter
35 + metric={metric}
36 + model={model}
37 + window={win}
38 + sessionStart={sessionStart}
39 + size="display"
40 + />
41 + </div>
42 + <a
43 + href="https://earth-now.co"
44 + target="_blank"
45 + rel="noreferrer"
46 + className="absolute bottom-2 right-3 text-[10px] text-muted hover:text-ink2"
47 + >
48 + earth-now.co
49 + </a>
50 + </div>
51 + </I18nProvider>
52 + );
53 +}
added apps/web/components/FitValue.tsx +69 −0
@@ -0,0 +1,69 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/FitValue.tsx
6 + * Purpose: Overflow-proof ticking value — measures and scales the digit line to always fit its container (mobile and desktop)
7 + */
8 +
9 +"use client";
10 +
11 +import { useEffect, useRef } from "react";
12 +
13 +export interface FitValueProps {
14 + text: string;
15 + className?: string;
16 +}
17 +
18 +/**
19 + * Renders a single-line value that NEVER overflows: the inner span is scaled
20 + * down (transform, left-anchored) whenever it is wider than the container.
21 + * With tabular-nums the width only depends on the character count, so we
22 + * re-measure on length changes and container resizes — not on every rAF tick.
23 + */
24 +export default function FitValue({ text, className = "" }: FitValueProps) {
25 + const containerRef = useRef<HTMLDivElement>(null);
26 + const spanRef = useRef<HTMLSpanElement>(null);
27 + const lastLength = useRef(-1);
28 +
29 + useEffect(() => {
30 + const container = containerRef.current;
31 + const span = spanRef.current;
32 + if (container === null || span === null) return;
33 +
34 + const fit = () => {
35 + // scale = min(1, available / natural); measured unscaled via scrollWidth.
36 + const natural = span.scrollWidth;
37 + const available = container.clientWidth;
38 + const scale = natural > 0 ? Math.min(1, available / natural) : 1;
39 + span.style.transform = scale < 1 ? `scale(${scale})` : "";
40 + };
41 +
42 + fit();
43 + const observer = new ResizeObserver(fit);
44 + observer.observe(container);
45 + // Re-fit when the digit count changes (value crossed a power of ten).
46 + const id = window.setInterval(() => {
47 + const len = span.textContent?.length ?? 0;
48 + if (len !== lastLength.current) {
49 + lastLength.current = len;
50 + fit();
51 + }
52 + }, 500);
53 + return () => {
54 + observer.disconnect();
55 + window.clearInterval(id);
56 + };
57 + }, []);
58 +
59 + return (
60 + <div ref={containerRef} className="w-full overflow-hidden">
61 + <span
62 + ref={spanRef}
63 + className={`inline-block origin-bottom-left whitespace-nowrap ${className}`}
64 + >
65 + {text}
66 + </span>
67 + </div>
68 + );
69 +}
added apps/web/components/LiveCounter.tsx +327 −0
@@ -0,0 +1,327 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/LiveCounter.tsx
6 + * Purpose: The live counter — rAF animation of a CounterModel with windows, derived transforms, smoothing, honesty guardrails and info popover (display/card/row sizes)
7 + */
8 +
9 +"use client";
10 +
11 +import Link from "next/link";
12 +import { useEffect, useRef, useState } from "react";
13 +import {
14 + formatRate,
15 + formatUncertainty,
16 + formatValue,
17 + rateAt,
18 + type CounterModel,
19 + type CounterWindow,
20 +} from "@earth-now/counter";
21 +import type { MetricSummary } from "@/lib/api";
22 +import { displayRawValue, hintsFor, isEstimate } from "@/lib/derived";
23 +import { blendValues, SMOOTHING_DURATION_MS } from "@/lib/smoothing";
24 +import { useI18n } from "@/lib/i18n";
25 +import { formatDateUtc, type MessageKey } from "@/lib/messages";
26 +import FitValue from "./FitValue";
27 +
28 +export interface PreviousModel {
29 + model: CounterModel;
30 + swapStartMs: number;
31 +}
32 +
33 +export interface LiveCounterProps {
34 + metric: MetricSummary;
35 + model?: CounterModel | undefined;
36 + /** Previous model + swap instant — enables the 60 s no-jump smoothing blend. */
37 + prev?: PreviousModel | undefined;
38 + window: CounterWindow;
39 + sessionStart?: number | undefined;
40 + /** display = the page's hero figure; card = KPI tile; row = compact reference row. */
41 + size?: "display" | "card" | "row";
42 + /** Shows the pulsing live indicator (set by the tier system, not hand-picked). */
43 + live?: boolean;
44 + /** Disables the link to the metric's own page (used ON that page). */
45 + noLink?: boolean;
46 +}
47 +
48 +interface DisplayState {
49 + main: string;
50 + rate: string | null;
51 + capped: string;
52 + pending: boolean;
53 +}
54 +
55 +const INITIAL_DISPLAY: DisplayState = { main: "—", rate: null, capped: "—", pending: false };
56 +
57 +const PERCENT_HINTS = { decimals: 0, unit: "%" } as const;
58 +
59 +/** Muted, sober treatment for mortality metrics — editorial rule, no celebratory styling. */
60 +function isSomber(metric: MetricSummary): boolean {
61 + return metric.id.includes("death");
62 +}
63 +
64 +export default function LiveCounter({
65 + metric,
66 + model,
67 + prev,
68 + window: win,
69 + sessionStart,
70 + size = "card",
71 + live = false,
72 + noLink = false,
73 +}: LiveCounterProps) {
74 + const { locale, t } = useI18n();
75 + const [display, setDisplay] = useState<DisplayState>(INITIAL_DISPLAY);
76 + const [infoOpen, setInfoOpen] = useState(false);
77 + const lastFinite = useRef<number | null>(null);
78 +
79 + const hints = hintsFor(metric, locale);
80 + const isRateOf = metric.derived?.op === "rate-of";
81 + const showRateLine =
82 + !isRateOf &&
83 + (metric.kind === "cumulative" || metric.derived?.op === "window" || size === "display");
84 +
85 + useEffect(() => {
86 + if (model === undefined) return;
87 + let raf = 0;
88 + const localHints = hintsFor(metric, locale);
89 + const tick = () => {
90 + const now = Date.now();
91 + let raw = displayRawValue(metric, model, now, win, sessionStart);
92 + // 60 s smoothing between model versions — no visible jump (production guardrail).
93 + if (prev !== undefined && now < prev.swapStartMs + SMOOTHING_DURATION_MS) {
94 + const oldRaw = displayRawValue(metric, prev.model, now, win, sessionStart);
95 + if (Number.isFinite(oldRaw) && Number.isFinite(raw)) {
96 + raw = blendValues(oldRaw, raw, now, prev.swapStartMs, SMOOTHING_DURATION_MS);
97 + }
98 + }
99 + // Never NaN/undefined on screen: freeze the last finite value + "data pending" chip.
100 + let pending = false;
101 + if (Number.isFinite(raw)) {
102 + lastFinite.current = raw;
103 + } else {
104 + pending = true;
105 + raw = lastFinite.current ?? NaN;
106 + }
107 + const main = isRateOf
108 + ? formatRate(raw, localHints, { locale })
109 + : formatValue(raw, localHints, { locale, applySigFigs: false });
110 + const capped = isRateOf ? main : formatValue(raw, localHints, { locale });
111 + const rate = showRateLine
112 + ? formatRate(rateAt(model, now), localHints, { locale })
113 + : null;
114 + setDisplay((d) =>
115 + d.main === main && d.rate === rate && d.capped === capped && d.pending === pending
116 + ? d
117 + : { main, rate, capped, pending },
118 + );
119 + raf = requestAnimationFrame(tick);
120 + };
121 + raf = requestAnimationFrame(tick);
122 + return () => cancelAnimationFrame(raf);
123 + }, [metric, model, prev, win, sessionStart, locale, isRateOf, showRateLine]);
124 +
125 + const somber = isSomber(metric);
126 + const source = metric.sources[0];
127 + const windowKey = `window.${win}` as MessageKey;
128 + const observedAt = model?.observedAt ?? metric.observedAt;
129 + const modelVersion = model?.modelVersion ?? metric.modelVersion;
130 + const unitLabel = metric.display.unit[locale];
131 +
132 + const chips = (
133 + <div className="flex flex-wrap items-center gap-1.5 text-[10px] uppercase tracking-wide">
134 + <span className="rounded bg-page px-1.5 py-0.5 text-muted">{t(windowKey)}</span>
135 + {metric.stale && (
136 + <span className="rounded border border-warn/50 px-1.5 py-0.5 text-ink2">
137 + ⚠ {t("chip.stale")}
138 + </span>
139 + )}
140 + {display.pending && (
141 + <span className="rounded border border-warn/50 px-1.5 py-0.5 text-ink2">
142 + ⚠ {t("chip.pending")}
143 + </span>
144 + )}
145 + {isEstimate(metric) && metric.uncertaintyFraction !== undefined && (
146 + <span className="rounded bg-wash px-1.5 py-0.5 normal-case text-ink2">
147 + ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % ·{" "}
148 + {t("chip.estimate")}
149 + </span>
150 + )}
151 + </div>
152 + );
153 +
154 + const infoButton = (
155 + <button
156 + type="button"
157 + aria-label={t("info.open")}
158 + onClick={() => setInfoOpen((o) => !o)}
159 + className="shrink-0 rounded-full px-1.5 text-xs text-muted transition-colors hover:text-ink"
160 + >
161 +
162 + </button>
163 + );
164 +
165 + const infoPanel = infoOpen && (
166 + <div className="absolute left-2 right-2 top-full z-20 mt-1 rounded-lg border bg-surface p-3 text-xs leading-relaxed text-ink2 shadow-lg">
167 + <dl className="space-y-1">
168 + <div>
169 + <dt className="inline font-medium text-ink">{t("info.cappedValue")} : </dt>
170 + <dd className="inline tabular-nums">
171 + {display.capped} {isRateOf ? "" : unitLabel}
172 + </dd>
173 + </div>
174 + {model?.uncertainty !== undefined && (
175 + <div>
176 + <dt className="inline font-medium text-ink">{t("info.uncertainty")} : </dt>
177 + <dd className="inline tabular-nums">
178 + {formatUncertainty(model.uncertainty.low, model.uncertainty.high, hints, {
179 + locale,
180 + })}{" "}
181 + {unitLabel}
182 + </dd>
183 + </div>
184 + )}
185 + {metric.uncertaintyFraction !== undefined && (
186 + <div>
187 + <dt className="inline font-medium text-ink">{t("info.uncertainty")} : </dt>
188 + <dd className="inline tabular-nums">
189 + ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % (
190 + {t("info.estimateNote")})
191 + </dd>
192 + </div>
193 + )}
194 + {source !== undefined && (
195 + <div>
196 + <dt className="inline font-medium text-ink">{t("info.source")} : </dt>
197 + <dd className="inline">
198 + <a
199 + href={source.url}
200 + target="_blank"
201 + rel="noreferrer"
202 + className="text-accent underline decoration-accent/40 hover:opacity-80"
203 + >
204 + {source.name}
205 + </a>{" "}
206 + · {t("info.license")} : {source.license}
207 + </dd>
208 + </div>
209 + )}
210 + {observedAt !== undefined && (
211 + <div>
212 + <dt className="inline font-medium text-ink">{t("info.observed")} : </dt>
213 + <dd className="inline">{formatDateUtc(observedAt, locale)}</dd>
214 + </div>
215 + )}
216 + <div>
217 + <dt className="inline font-medium text-ink">{t("info.model")} : </dt>
218 + <dd className="inline">
219 + {metric.model}
220 + {modelVersion !== undefined && modelVersion !== metric.model
221 + ? ` (${modelVersion})`
222 + : ""}
223 + </dd>
224 + </div>
225 + {metric.editorialNote !== undefined && (
226 + <div className="pt-1 text-muted">{metric.editorialNote[locale]}</div>
227 + )}
228 + </dl>
229 + <button
230 + type="button"
231 + onClick={() => setInfoOpen(false)}
232 + className="mt-2 text-[10px] uppercase tracking-wide text-muted hover:text-ink"
233 + >
234 + {t("info.close")}
235 + </button>
236 + </div>
237 + );
238 +
239 + if (size === "row") {
240 + // Compact reference row: slow-moving values, no big animation, dense layout.
241 + return (
242 + <div className="relative flex min-w-0 items-center gap-3 px-4 py-2.5 transition-colors hover:bg-page/60">
243 + {noLink ? (
244 + <span className={`min-w-0 flex-1 truncate text-sm ${somber ? "text-muted" : "text-ink2"}`}>
245 + {metric.name[locale]}
246 + </span>
247 + ) : (
248 + <Link
249 + href={`/metric/${metric.id}`}
250 + className={`min-w-0 flex-1 truncate text-sm underline-offset-2 transition-colors hover:text-accent hover:underline ${somber ? "text-muted" : "text-ink2"}`}
251 + >
252 + {metric.name[locale]}
253 + </Link>
254 + )}
255 + <span className="shrink-0 text-sm font-medium text-ink tabular-nums">
256 + {display.main}
257 + <span className="ml-1 font-normal text-muted">{isRateOf ? "" : unitLabel}</span>
258 + </span>
259 + {observedAt !== undefined && (
260 + <span className="hidden shrink-0 text-xs text-muted lg:block">
261 + {formatDateUtc(observedAt, locale)}
262 + </span>
263 + )}
264 + {(metric.stale || display.pending) && (
265 + <span className="shrink-0 text-[10px] uppercase tracking-wide text-ink2">
266 + ⚠ {t(metric.stale ? "chip.stale" : "chip.pending")}
267 + </span>
268 + )}
269 + {infoButton}
270 + {infoPanel}
271 + </div>
272 + );
273 + }
274 +
275 + const isDisplay = size === "display";
276 +
277 + return (
278 + <div
279 + className={`group relative flex min-w-0 flex-col gap-1 rounded-2xl border bg-surface transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg ${
280 + isDisplay ? "p-6 sm:p-8" : "p-4"
281 + }`}
282 + >
283 + <div className="flex items-start justify-between gap-2">
284 + <h3
285 + className={`flex min-w-0 items-center gap-2 font-medium leading-snug ${
286 + somber ? "text-muted" : "text-ink2"
287 + } ${isDisplay ? "text-base" : "text-sm"}`}
288 + >
289 + {live && !somber && <span aria-hidden className="live-dot shrink-0" />}
290 + {noLink ? (
291 + <span className="min-w-0">{metric.name[locale]}</span>
292 + ) : (
293 + <Link
294 + href={`/metric/${metric.id}`}
295 + className="min-w-0 underline-offset-2 transition-colors hover:text-accent hover:underline"
296 + >
297 + {metric.name[locale]}
298 + </Link>
299 + )}
300 + </h3>
301 + {infoButton}
302 + </div>
303 +
304 + {/* Value line carries DIGITS ONLY (unit moves below) and is scale-fitted:
305 + it can never overflow the tile, on any viewport. */}
306 + <FitValue
307 + text={display.main}
308 + className={`tabular-nums font-semibold tracking-tight text-ink ${
309 + isDisplay ? "text-5xl leading-tight sm:text-7xl" : "text-2xl leading-snug sm:text-3xl"
310 + }`}
311 + />
312 +
313 + <p className={`text-muted ${isDisplay ? "text-base" : "text-xs"}`}>
314 + {isRateOf ? "" : unitLabel}
315 + {display.rate !== null && (
316 + <span className="tabular-nums">
317 + {isRateOf ? "" : " · "}
318 + {t("rate.current")} : {display.rate}
319 + </span>
320 + )}
321 + </p>
322 +
323 + <div className="mt-1">{chips}</div>
324 + {infoPanel}
325 + </div>
326 + );
327 +}
added apps/web/components/MetricDetail.tsx +175 −0
@@ -0,0 +1,175 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/MetricDetail.tsx
6 + * Purpose: Per-metric page body — big live counter and the metric's own methodology section (model family, sources, uncertainty)
7 + */
8 +
9 +"use client";
10 +
11 +import Link from "next/link";
12 +import { useEffect, useMemo, useState } from "react";
13 +import { formatUncertainty, formatValue, type CounterModel } from "@earth-now/counter";
14 +import type { Locale, MetricSummary } from "@/lib/api";
15 +import { hintsFor, resolvePrimaryWindow } from "@/lib/derived";
16 +import { I18nProvider, useI18n } from "@/lib/i18n";
17 +import { formatDateUtc, isLocale, type MessageKey } from "@/lib/messages";
18 +import { tierFor } from "@/lib/tiers";
19 +import LiveCounter from "./LiveCounter";
20 +
21 +const LOCALE_STORAGE_KEY = "earth-now.locale";
22 +const PERCENT_HINTS = { decimals: 0, unit: "%" } as const;
23 +
24 +export interface MetricDetailProps {
25 + metric: MetricSummary;
26 + model: CounterModel | null;
27 +}
28 +
29 +export default function MetricDetail({ metric, model }: MetricDetailProps) {
30 + const [locale, setLocaleState] = useState<Locale>("fr");
31 + useEffect(() => {
32 + const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
33 + if (isLocale(saved)) setLocaleState(saved);
34 + }, []);
35 + useEffect(() => {
36 + document.documentElement.lang = locale;
37 + }, [locale]);
38 + const setLocale = (next: Locale) => {
39 + setLocaleState(next);
40 + window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
41 + };
42 + return (
43 + <I18nProvider locale={locale} setLocale={setLocale}>
44 + <MetricDetailBody metric={metric} model={model} />
45 + </I18nProvider>
46 + );
47 +}
48 +
49 +function MetricDetailBody({ metric, model }: MetricDetailProps) {
50 + const { locale, t } = useI18n();
51 + const sessionStart = useMemo(() => Date.now(), []);
52 + const live = model !== null && tierFor(metric, model, sessionStart) === "live";
53 + const hints = hintsFor(metric, locale);
54 +
55 + return (
56 + <div className="mx-auto max-w-4xl px-4 pb-16">
57 + <nav className="py-6 text-sm">
58 + <Link href="/" className="text-accent hover:opacity-80">
59 + {t("metric.back")}
60 + </Link>
61 + </nav>
62 +
63 + <LiveCounter
64 + metric={metric}
65 + model={model ?? undefined}
66 + window={resolvePrimaryWindow(metric)}
67 + sessionStart={sessionStart}
68 + size="display"
69 + live={live}
70 + noLink
71 + />
72 +
73 + <section aria-labelledby="metric-method" className="mt-10">
74 + <h2 id="metric-method" className="text-lg font-semibold text-ink">
75 + {t("metric.methodTitle")}
76 + </h2>
77 + <p className="mt-2 max-w-3xl text-sm leading-relaxed text-ink2">
78 + {familyExplanation(metric.model, t)}
79 + </p>
80 +
81 + <dl className="mt-5 grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
82 + <MethodRow label={t("methodology.kind")}>
83 + {t(`kind.${metric.kind}` as MessageKey)} · {t(`domain.${metric.domain}` as MessageKey)}
84 + </MethodRow>
85 + <MethodRow label={t("methodology.model")}>
86 + {metric.model}
87 + {model !== null && model.modelVersion !== metric.model
88 + ? ` (${model.modelVersion})`
89 + : ""}{" "}
90 + — {t("metric.level")} {String(metric.level)}
91 + </MethodRow>
92 + {model !== null && (
93 + <MethodRow label={t("methodology.observed")}>
94 + {formatDateUtc(model.observedAt, locale)}
95 + </MethodRow>
96 + )}
97 + {model?.uncertainty !== undefined && (
98 + <MethodRow label={t("methodology.uncertainty")}>
99 + {formatUncertainty(model.uncertainty.low, model.uncertainty.high, hints, {
100 + locale,
101 + })}{" "}
102 + {metric.display.unit[locale]}
103 + </MethodRow>
104 + )}
105 + {metric.uncertaintyFraction !== undefined && (
106 + <MethodRow label={t("methodology.uncertainty")}>
107 + ± {formatValue(metric.uncertaintyFraction * 100, PERCENT_HINTS, { locale })} % (
108 + {t("info.estimateNote")})
109 + </MethodRow>
110 + )}
111 + <MethodRow label={t("metric.windows")}>
112 + {metric.windows.map((w) => t(`window.${w}` as MessageKey)).join(" · ")}
113 + </MethodRow>
114 + </dl>
115 +
116 + <h3 className="mt-6 text-sm font-semibold uppercase tracking-wide text-ink2">
117 + {t("methodology.source")}
118 + </h3>
119 + <ul className="mt-2 space-y-2 text-sm">
120 + {metric.sources.map((source) => (
121 + <li key={source.id} className="rounded-xl border bg-surface px-4 py-3">
122 + <a
123 + href={source.url}
124 + target="_blank"
125 + rel="noreferrer"
126 + className="font-medium text-accent underline decoration-accent/40 hover:opacity-80"
127 + >
128 + {source.name}
129 + </a>
130 + <div className="mt-1 text-xs text-ink2">
131 + {t("methodology.license")} : {source.license} · {t("methodology.cadence")} :{" "}
132 + {source.cadence}
133 + </div>
134 + </li>
135 + ))}
136 + </ul>
137 +
138 + {metric.editorialNote !== undefined && (
139 + <p className="mt-4 max-w-3xl rounded-xl bg-wash px-4 py-3 text-sm text-ink2">
140 + {metric.editorialNote[locale]}
141 + </p>
142 + )}
143 +
144 + <p className="mt-6 text-sm">
145 + <Link href="/methodology" className="text-accent underline decoration-accent/40 hover:opacity-80">
146 + {t("metric.fullMethodology")}
147 + </Link>
148 + </p>
149 + </section>
150 + </div>
151 + );
152 +}
153 +
154 +function MethodRow({ label, children }: { label: string; children: React.ReactNode }) {
155 + return (
156 + <div>
157 + <dt className="text-xs uppercase tracking-wide text-muted">{label}</dt>
158 + <dd className="mt-0.5 text-ink">{children}</dd>
159 + </div>
160 + );
161 +}
162 +
163 +/** One-sentence how-it-works per model family (the per-metric methodology promise). */
164 +function familyExplanation(family: string, t: (key: MessageKey) => string): string {
165 + const known = new Set([
166 + "seasonal-spline-v2",
167 + "keeling-fusion-v1",
168 + "seasonal-ytd-v1",
169 + "linear-ytd-v1",
170 + "linear-stock-v1",
171 + "static-rt-v1",
172 + "derived",
173 + ]);
174 + return known.has(family) ? t(`explain.${family}` as MessageKey) : t("explain.default");
175 +}
added apps/web/components/YearProgress.tsx +50 −0
@@ -0,0 +1,50 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/components/YearProgress.tsx
6 + * Purpose: Live progress of the current UTC year — deterministic astronomy-style ticker with a thin accent meter
7 + */
8 +
9 +"use client";
10 +
11 +import { useEffect, useState } from "react";
12 +import { formatValue, startOfUtcYear } from "@earth-now/counter";
13 +import { useI18n } from "@/lib/i18n";
14 +
15 +const PERCENT_HINTS = { decimals: 6, unit: "%" } as const;
16 +
17 +export default function YearProgress() {
18 + const { locale, t } = useI18n();
19 + const [now, setNow] = useState<number | null>(null);
20 +
21 + useEffect(() => {
22 + let raf = 0;
23 + const tick = () => {
24 + setNow(Date.now());
25 + raf = requestAnimationFrame(tick);
26 + };
27 + raf = requestAnimationFrame(tick);
28 + return () => cancelAnimationFrame(raf);
29 + }, []);
30 +
31 + if (now === null) return null;
32 + const start = startOfUtcYear(now);
33 + const end = Date.UTC(new Date(now).getUTCFullYear() + 1, 0, 1);
34 + const fraction = (now - start) / (end - start);
35 + const year = new Date(now).getUTCFullYear();
36 +
37 + return (
38 + <div className="mb-8 flex items-center gap-3 text-xs text-ink2">
39 + <span className="shrink-0">
40 + {year} : {formatValue(fraction * 100, PERCENT_HINTS, { locale })} % {t("year.elapsed")}
41 + </span>
42 + <span className="h-1 min-w-0 flex-1 rounded-full bg-line" aria-hidden>
43 + <span
44 + className="block h-1 rounded-full bg-accent"
45 + style={{ width: `${fraction * 100}%` }}
46 + />
47 + </span>
48 + </div>
49 + );
50 +}
added apps/web/lib/api.ts +129 −0
@@ -0,0 +1,129 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/api.ts
6 + * Purpose: Typed fetchers + MetricSummary type mirroring the apps/api REST contract (/v1/metrics, /v1/models, /v1/rt/quakes)
7 + */
8 +
9 +import type { CounterModel } from "@earth-now/counter";
10 +
11 +export type Locale = "fr" | "en";
12 +
13 +export interface LocalizedText {
14 + fr: string;
15 + en: string;
16 +}
17 +
18 +export type MetricDomain =
19 + | "population"
20 + | "climate"
21 + | "emissions"
22 + | "forest"
23 + | "ocean"
24 + | "energy"
25 + | "society"
26 + | "health"
27 + | "economy"
28 + | "tech"
29 + | "realtime"
30 + | "space";
31 +
32 +export type MetricKind = "stock" | "cumulative" | "event" | "derived";
33 +
34 +export type ModelLevel = 0 | 1 | 2 | "rt" | "derived";
35 +
36 +export type DerivedOp =
37 + | "linear-combination"
38 + | "window"
39 + | "rate-of"
40 + | "depletion-countdown";
41 +
42 +export interface MetricSource {
43 + id: string;
44 + name: string;
45 + url: string;
46 + license: string;
47 + cadence: string;
48 +}
49 +
50 +export interface MetricDisplay {
51 + decimals: number;
52 + sigFigs?: number;
53 + scale?: number;
54 + unit: LocalizedText;
55 +}
56 +
57 +export interface DerivedSpec {
58 + op: DerivedOp;
59 + inputs: Array<{ id: string; weight: number }>;
60 + constant?: number;
61 + window?: "today" | "ytd" | "session";
62 +}
63 +
64 +export interface MetricSummary {
65 + id: string;
66 + name: LocalizedText;
67 + domain: MetricDomain;
68 + priority: string;
69 + kind: MetricKind;
70 + level: ModelLevel;
71 + model: string;
72 + unit: string;
73 + sources: MetricSource[];
74 + display: MetricDisplay;
75 + windows: string[];
76 + derived?: DerivedSpec;
77 + uncertaintyFraction?: number;
78 + editorialNote?: LocalizedText;
79 + stale: boolean;
80 + modelVersion?: string;
81 + observedAt?: string;
82 +}
83 +
84 +export interface ModelsResponse {
85 + models: Record<string, CounterModel>;
86 + generatedAt: string;
87 +}
88 +
89 +export interface QuakesResponse {
90 + count24h: number;
91 + lastMajor: { mag: number; place: string; timeIso: string } | null;
92 + fetchedAt: string;
93 +}
94 +
95 +/**
96 + * API base URL. Client-side: NEXT_PUBLIC_API_URL (inlined at build time).
97 + * Server-side: API_URL takes precedence, then NEXT_PUBLIC_API_URL.
98 + */
99 +export function apiBase(): string {
100 + if (typeof window === "undefined") {
101 + return (
102 + process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"
103 + );
104 + }
105 + return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
106 +}
107 +
108 +async function getJson<T>(path: string): Promise<T> {
109 + const res = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
110 + if (!res.ok) throw new Error(`API ${path} responded ${res.status}`);
111 + return (await res.json()) as T;
112 +}
113 +
114 +export async function fetchMetrics(): Promise<MetricSummary[]> {
115 + return getJson<MetricSummary[]>("/v1/metrics");
116 +}
117 +
118 +export async function fetchModels(): Promise<ModelsResponse> {
119 + return getJson<ModelsResponse>("/v1/models");
120 +}
121 +
122 +export async function fetchQuakes(): Promise<QuakesResponse> {
123 + return getJson<QuakesResponse>("/v1/rt/quakes");
124 +}
125 +
126 +/** SSE stream URL — subscribed with EventSource in the client dashboard. */
127 +export function sseStreamUrl(): string {
128 + return `${apiBase()}/sse/stream`;
129 +}
added apps/web/lib/derived.ts +101 −0
@@ -0,0 +1,101 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/derived.ts
6 + * Purpose: Pure derived-metric transforms used by LiveCounter — primary window, depletion countdown, model resolution, display hints
7 + */
8 +
9 +import {
10 + counterValue,
11 + rateAt,
12 + windowValue,
13 + type CounterModel,
14 + type CounterWindow,
15 + type DisplayHints,
16 +} from "@earth-now/counter";
17 +import type { Locale, MetricSummary } from "./api";
18 +
19 +/** Mean Gregorian year in seconds (365.2425 × 86 400) — matches YEAR_SECONDS in @earth-now/counter. */
20 +export const YEAR_SECONDS = 31_556_952;
21 +
22 +/**
23 + * The window a metric's card should display by default:
24 + * derived op=window → that window; cumulative → ytd; everything else → total.
25 + */
26 +export function resolvePrimaryWindow(metric: MetricSummary): CounterWindow {
27 + if (metric.derived?.op === "window" && metric.derived.window !== undefined) {
28 + return metric.derived.window;
29 + }
30 + // The registry declares window priority: windows[0] is the primary reading
31 + // (e.g. cigarettes/coffee/GDP lead with "today", births with "ytd").
32 + const declared = metric.windows[0];
33 + if (declared === "today" || declared === "ytd" || declared === "session" || declared === "total") {
34 + return declared;
35 + }
36 + if (metric.kind === "cumulative") return "ytd";
37 + return "total";
38 +}
39 +
40 +/**
41 + * Years until a depleting stock reaches zero at the current instantaneous rate:
42 + * value(t) / (−rate(t) × YEAR_SECONDS). NaN when the stock is not depleting
43 + * (rate ≥ 0) — the caller renders the "data pending" fallback.
44 + */
45 +export function depletionYears(model: CounterModel, tMs: number): number {
46 + const remaining = counterValue(model, tMs);
47 + const perSecond = rateAt(model, tMs);
48 + const perYear = -perSecond * YEAR_SECONDS;
49 + if (!Number.isFinite(remaining) || !(perYear > 0)) return NaN;
50 + return remaining / perYear;
51 +}
52 +
53 +/**
54 + * Model powering a metric's card: its own CounterModel when the API materialized
55 + * one, otherwise the model of its first derived input (rate-of / depletion-countdown
56 + * / window metrics are transforms over an input model).
57 + */
58 +export function resolveModel(
59 + metric: MetricSummary,
60 + models: Record<string, CounterModel>,
61 +): CounterModel | undefined {
62 + const own = models[metric.id];
63 + if (own !== undefined) return own;
64 + const inputId = metric.derived?.inputs[0]?.id;
65 + return inputId !== undefined ? models[inputId] : undefined;
66 +}
67 +
68 +/** DisplayHints for the shared formatters, with the unit localized from the registry. */
69 +export function hintsFor(metric: MetricSummary, locale: Locale): DisplayHints {
70 + return {
71 + decimals: metric.display.decimals,
72 + unit: metric.display.unit[locale],
73 + ...(metric.display.sigFigs !== undefined ? { sigFigs: metric.display.sigFigs } : {}),
74 + ...(metric.display.scale !== undefined ? { scale: metric.display.scale } : {}),
75 + };
76 +}
77 +
78 +/**
79 + * Raw (unformatted) number a card displays at time t, applying the metric's
80 + * derived transform and window. Pure — time is a parameter. Returns NaN instead
81 + * of throwing when a session window has no session start yet.
82 + */
83 +export function displayRawValue(
84 + metric: MetricSummary,
85 + model: CounterModel,
86 + tMs: number,
87 + window: CounterWindow,
88 + sessionStartMs?: number,
89 +): number {
90 + const op = metric.derived?.op;
91 + if (op === "rate-of") return rateAt(model, tMs);
92 + if (op === "depletion-countdown") return depletionYears(model, tMs);
93 + if (window === "total") return counterValue(model, tMs);
94 + if (window === "session" && sessionStartMs === undefined) return NaN;
95 + return windowValue(model, tMs, window, sessionStartMs);
96 +}
97 +
98 +/** Whether a card must carry the mandatory "estimation / estimate" label. */
99 +export function isEstimate(metric: MetricSummary): boolean {
100 + return metric.uncertaintyFraction !== undefined;
101 +}
added apps/web/lib/i18n.tsx +45 −0
@@ -0,0 +1,45 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/i18n.tsx
6 + * Purpose: Tiny client-side locale context (fr/en) — provider + useI18n hook, UI strings only (metric text comes from the API)
7 + */
8 +
9 +"use client";
10 +
11 +import { createContext, useContext, type ReactNode } from "react";
12 +import type { Locale } from "./api";
13 +import { getMessages, type MessageKey } from "./messages";
14 +
15 +interface I18nContextValue {
16 + locale: Locale;
17 + setLocale: (locale: Locale) => void;
18 + t: (key: MessageKey) => string;
19 +}
20 +
21 +const I18nContext = createContext<I18nContextValue | null>(null);
22 +
23 +export function I18nProvider({
24 + locale,
25 + setLocale,
26 + children,
27 +}: {
28 + locale: Locale;
29 + setLocale: (locale: Locale) => void;
30 + children: ReactNode;
31 +}) {
32 + const messages = getMessages(locale);
33 + const value: I18nContextValue = {
34 + locale,
35 + setLocale,
36 + t: (key) => messages[key],
37 + };
38 + return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
39 +}
40 +
41 +export function useI18n(): I18nContextValue {
42 + const ctx = useContext(I18nContext);
43 + if (ctx === null) throw new Error("useI18n must be used inside <I18nProvider>");
44 + return ctx;
45 +}
added apps/web/lib/messages.ts +41 −0
@@ -0,0 +1,41 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/messages.ts
6 + * Purpose: Pure locale/message helpers (fr + en UI strings) usable from both server and client components
7 + */
8 +
9 +import fr from "../messages/fr.json";
10 +import en from "../messages/en.json";
11 +import type { Locale } from "./api";
12 +
13 +export type MessageKey = keyof typeof fr;
14 +export type Messages = Record<MessageKey, string>;
15 +
16 +const MESSAGES: Record<Locale, Messages> = { fr, en };
17 +
18 +export const DEFAULT_LOCALE: Locale = "fr";
19 +
20 +export function isLocale(value: unknown): value is Locale {
21 + return value === "fr" || value === "en";
22 +}
23 +
24 +export function getMessages(locale: Locale): Messages {
25 + return MESSAGES[locale];
26 +}
27 +
28 +/** Translate a key for a locale — usable server-side (pages) and client-side (via useI18n). */
29 +export function translate(locale: Locale, key: MessageKey): string {
30 + return MESSAGES[locale][key];
31 +}
32 +
33 +/** Format an ISO date for display (UTC, no hand-rolled date arithmetic). */
34 +export function formatDateUtc(iso: string, locale: Locale): string {
35 + const ms = Date.parse(iso);
36 + if (Number.isNaN(ms)) return iso;
37 + return new Intl.DateTimeFormat(locale === "fr" ? "fr-FR" : "en-GB", {
38 + dateStyle: "medium",
39 + timeZone: "UTC",
40 + }).format(new Date(ms));
41 +}
added apps/web/lib/smoothing.ts +57 −0
@@ -0,0 +1,57 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/smoothing.ts
6 + * Purpose: Pure 60 s linear blend between an old and a new CounterModel — no visible jump when SSE delivers a refit (time is a parameter)
7 + */
8 +
9 +import { counterValue, type CounterModel } from "@earth-now/counter";
10 +
11 +/** Guardrail duration: a model swap is smoothed over 60 s (CLAUDE.md production rule). */
12 +export const SMOOTHING_DURATION_MS = 60_000;
13 +
14 +/** Blend weight for the new model at time t: 0 before swapStart, 1 after swapStart+duration. */
15 +export function smoothingAlpha(
16 + tMs: number,
17 + swapStartMs: number,
18 + durationMs: number = SMOOTHING_DURATION_MS,
19 +): number {
20 + if (durationMs <= 0 || tMs >= swapStartMs + durationMs) return 1;
21 + if (tMs <= swapStartMs) return 0;
22 + return (tMs - swapStartMs) / durationMs;
23 +}
24 +
25 +/** Linear blend of two already-evaluated values across the smoothing window. */
26 +export function blendValues(
27 + oldValue: number,
28 + newValue: number,
29 + tMs: number,
30 + swapStartMs: number,
31 + durationMs: number = SMOOTHING_DURATION_MS,
32 +): number {
33 + const alpha = smoothingAlpha(tMs, swapStartMs, durationMs);
34 + if (alpha === 0) return oldValue;
35 + if (alpha === 1) return newValue;
36 + return oldValue * (1 - alpha) + newValue * alpha;
37 +}
38 +
39 +/**
40 + * Evaluator that linearly cross-fades counterValue(oldModel) → counterValue(newModel)
41 + * over [swapStartMs, swapStartMs + durationMs]. Exactly the old value at swapStart,
42 + * exactly the new value from swapStart+duration onwards. Pure — never reads the clock.
43 + */
44 +export function makeSmoothedEvaluator(
45 + oldModel: CounterModel,
46 + newModel: CounterModel,
47 + swapStartMs: number,
48 + durationMs: number = SMOOTHING_DURATION_MS,
49 +): (tMs: number) => number {
50 + return (tMs: number): number => {
51 + const alpha = smoothingAlpha(tMs, swapStartMs, durationMs);
52 + if (alpha === 1) return counterValue(newModel, tMs);
53 + const oldValue = counterValue(oldModel, tMs);
54 + if (alpha === 0) return oldValue;
55 + return oldValue * (1 - alpha) + counterValue(newModel, tMs) * alpha;
56 + };
57 +}
added apps/web/lib/tiers.ts +69 −0
@@ -0,0 +1,69 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/lib/tiers.ts
6 + * Purpose: Pure display-tier classification — metrics whose last visible digit ticks fast lead the page; slow ones go to the reference strip
7 + */
8 +
9 +import { rateAt, type CounterModel, type DisplayHints } from "@earth-now/counter";
10 +import type { MetricSummary } from "./api";
11 +
12 +export type DisplayTier = "hero" | "live" | "country" | "realtime" | "reference";
13 +
14 +export const HERO_METRIC_ID = "world_population";
15 +export const COUNTRY_PREFIX = "country_population_";
16 +export const CONTINENT_PREFIX = "continent_population_";
17 +
18 +/**
19 + * Seconds between visible changes of the LAST displayed digit:
20 + * (10^-decimals) / (|rate| × displayScale). Infinity when the value doesn't move.
21 + * Pure — time is a parameter.
22 + */
23 +export function secondsPerVisibleTick(
24 + model: CounterModel,
25 + hints: Pick<DisplayHints, "decimals" | "scale">,
26 + tMs: number,
27 +): number {
28 + const rate = Math.abs(rateAt(model, tMs)) * (hints.scale ?? 1);
29 + if (rate === 0 || !Number.isFinite(rate)) return Infinity;
30 + return 10 ** -hints.decimals / rate;
31 +}
32 +
33 +/** A counter reads as "live" when its last digit changes at least every ~2.5 s. */
34 +export const LIVE_TICK_THRESHOLD_S = 2.5;
35 +
36 +export function tierFor(
37 + metric: MetricSummary,
38 + model: CounterModel | undefined,
39 + tMs: number,
40 +): DisplayTier {
41 + if (metric.id === HERO_METRIC_ID) return "hero";
42 + if (metric.id.startsWith(COUNTRY_PREFIX) || metric.id.startsWith(CONTINENT_PREFIX)) return "country";
43 + if (metric.level === "rt") return "realtime";
44 + if (model === undefined) return "reference";
45 + // rate-of / depletion displays are static text (the rate itself moves slowly).
46 + if (metric.derived?.op === "rate-of" || metric.derived?.op === "depletion-countdown") {
47 + return "reference";
48 + }
49 + const spv = secondsPerVisibleTick(
50 + model,
51 + { decimals: metric.display.decimals, ...(metric.display.scale !== undefined ? { scale: metric.display.scale } : {}) },
52 + tMs,
53 + );
54 + return spv <= LIVE_TICK_THRESHOLD_S ? "live" : "reference";
55 +}
56 +
57 +/** Sort key for the live grid: fastest visible tick first. */
58 +export function liveSortKey(
59 + metric: MetricSummary,
60 + model: CounterModel | undefined,
61 + tMs: number,
62 +): number {
63 + if (model === undefined) return Infinity;
64 + return secondsPerVisibleTick(
65 + model,
66 + { decimals: metric.display.decimals, ...(metric.display.scale !== undefined ? { scale: metric.display.scale } : {}) },
67 + tMs,
68 + );
69 +}
added apps/web/messages/en.json +87 −0
@@ -0,0 +1,87 @@
1 +{
2 + "site.title": "earth-now.co",
3 + "site.tagline": "The planet, live — every counter is a documented statistical model, not an opaque extrapolation.",
4 + "since.title": "Since you arrived",
5 + "since.subtitle": "What actually happened on Earth during your visit — modelled rates × your session length.",
6 + "domain.population": "Population",
7 + "domain.climate": "Climate",
8 + "domain.emissions": "Emissions",
9 + "domain.forest": "Forests",
10 + "domain.ocean": "Oceans",
11 + "domain.energy": "Energy",
12 + "domain.society": "Society",
13 + "domain.realtime": "Real time",
14 + "domain.space": "Space & Earth",
15 + "window.total": "total",
16 + "window.today": "today",
17 + "window.ytd": "this year",
18 + "window.session": "since you arrived",
19 + "chip.stale": "stale data",
20 + "chip.pending": "data pending",
21 + "chip.estimate": "estimate",
22 + "rate.current": "current rate",
23 + "info.cappedValue": "Value (honest precision)",
24 + "info.uncertainty": "Confidence interval (90%)",
25 + "info.estimateNote": "estimate",
26 + "info.source": "Source",
27 + "info.license": "License",
28 + "info.observed": "Last observation",
29 + "info.model": "Model",
30 + "info.close": "Close",
31 + "info.open": "Details and source",
32 + "kind.stock": "stock",
33 + "kind.cumulative": "cumulative",
34 + "kind.event": "event",
35 + "kind.derived": "derived",
36 + "footer.attribution": "Data: UN (WPP), NOAA GML, Global Carbon Project, Copernicus/ERA5, FAO, Global Forest Watch, Ember/IEA, USGS. Every counter interpolates real observations — method, source and uncertainty documented.",
37 + "footer.methodology": "Methodology",
38 + "offline.title": "API offline",
39 + "offline.body": "Counters are momentarily unavailable — the model server is not responding. Please try again shortly.",
40 + "methodology.title": "Methodology",
41 + "methodology.intro": "Every earth-now.co counter is a documented statistical model: we interpolate the latest real observations from authoritative sources (monotone splines, seasonality, observation/forecast fusion) and we display the source, observation date, model family and uncertainty. Never more precision than the model justifies — that is our commitment, and our difference from opaque tickers.",
42 + "methodology.metric": "Metric",
43 + "methodology.domain": "Domain",
44 + "methodology.kind": "Kind",
45 + "methodology.model": "Model",
46 + "methodology.source": "Source",
47 + "methodology.license": "License",
48 + "methodology.cadence": "Cadence",
49 + "methodology.observed": "Last obs.",
50 + "methodology.uncertainty": "Uncertainty",
51 + "methodology.note": "Note",
52 + "methodology.back": "← Back to the dashboard",
53 + "quakes.lastMajor": "Last major earthquake",
54 + "quakes.count24h": "earthquakes (24 h)",
55 + "embed.attribution": "earth-now.co",
56 + "embed.notFound": "Metric not found",
57 + "locale.toggle": "Français",
58 + "live.title": "Live now",
59 + "live.subtitle": "The counters moving before your eyes — fastest first.",
60 + "countries.title": "Top 10 countries by population",
61 + "countries.subtitle": "UN WPP interpolation — ranking and values update live.",
62 + "reference.title": "Planetary reference points",
63 + "reference.subtitle": "These values move slowly — updated on each new real observation.",
64 + "realtime.title": "True real-time events",
65 + "realtime.subtitle": "No interpolation — these counters only move on real events.",
66 + "theme.toDark": "Dark mode",
67 + "theme.toLight": "Light mode",
68 + "domain.tech": "Digital",
69 + "metric.back": "← All counters",
70 + "metric.methodTitle": "This metric's method",
71 + "metric.level": "level",
72 + "metric.windows": "Display windows",
73 + "metric.fullMethodology": "See the full methodology for every metric →",
74 + "explain.seasonal-spline-v2": "Observation/forecast fusion: a monotone spline (PCHIP) between the latest real observations and the source's projections — no overshoot, no artificial backtracking.",
75 + "explain.keeling-fusion-v1": "Least-squares fit of a trend + seasonal harmonics (Keeling curve) on the observation series; the annual cycle is projected beyond the last measurement.",
76 + "explain.seasonal-ytd-v1": "Year-to-date cumulative reset on Jan 1 UTC: the source's annual total spread along a modelled seasonality (day/week/year Fourier harmonics), rate always positive.",
77 + "explain.linear-ytd-v1": "Linear year-to-date cumulative (level 0): the source's annual total spread uniformly — used when no reliable seasonality is published.",
78 + "explain.linear-stock-v1": "Linearly-evolving value anchored on the latest observation (e.g. a day countdown).",
79 + "explain.static-rt-v1": "True event-driven real time: the value only moves when the source publishes a real event — no interpolation.",
80 + "explain.derived": "Derived metric: an exact combination of other registry counters (difference, scaling, window or rate) — the same function is animated client-side.",
81 + "explain.default": "Documented statistical model — see the full methodology.",
82 + "domain.health": "Health",
83 + "domain.economy": "Economy",
84 + "year.elapsed": "of the year elapsed",
85 + "continents.title": "Population by continent",
86 + "continents.subtitle": "UN WPP interpolation — Africa gains ~35 M people a year, Europe is shrinking."
87 +}
\ No newline at end of file
added apps/web/messages/fr.json +87 −0
@@ -0,0 +1,87 @@
1 +{
2 + "site.title": "earth-now.co",
3 + "site.tagline": "La planète en direct — chaque compteur est un modèle statistique documenté, pas une extrapolation opaque.",
4 + "since.title": "Depuis que vous êtes arrivé·e",
5 + "since.subtitle": "Ce qui s'est réellement passé sur Terre pendant votre visite — taux modélisés × durée de votre session.",
6 + "live.title": "En direct",
7 + "live.subtitle": "Les compteurs qui bougent sous vos yeux — triés du plus rapide au plus lent.",
8 + "countries.title": "Top 10 pays par population",
9 + "countries.subtitle": "Interpolation ONU WPP — le classement et les valeurs se mettent à jour en direct.",
10 + "reference.title": "Repères planétaires",
11 + "reference.subtitle": "Ces valeurs évoluent lentement — mises à jour à chaque nouvelle observation réelle.",
12 + "realtime.title": "Temps réel événementiel",
13 + "realtime.subtitle": "Aucune interpolation — ces compteurs ne bougent que sur événement réel.",
14 + "theme.toDark": "Mode sombre",
15 + "theme.toLight": "Mode clair",
16 + "domain.population": "Population",
17 + "domain.climate": "Climat",
18 + "domain.emissions": "Émissions",
19 + "domain.forest": "Forêts",
20 + "domain.ocean": "Océans",
21 + "domain.energy": "Énergie",
22 + "domain.society": "Société",
23 + "domain.realtime": "Temps réel",
24 + "domain.space": "Espace & Terre",
25 + "window.total": "total",
26 + "window.today": "aujourd'hui",
27 + "window.ytd": "cette année",
28 + "window.session": "depuis votre arrivée",
29 + "chip.stale": "données périmées",
30 + "chip.pending": "données en attente",
31 + "chip.estimate": "estimation",
32 + "rate.current": "rythme actuel",
33 + "info.cappedValue": "Valeur (précision honnête)",
34 + "info.uncertainty": "Intervalle de confiance (90 %)",
35 + "info.estimateNote": "estimation",
36 + "info.source": "Source",
37 + "info.license": "Licence",
38 + "info.observed": "Dernière observation",
39 + "info.model": "Modèle",
40 + "info.close": "Fermer",
41 + "info.open": "Détails et source",
42 + "kind.stock": "stock",
43 + "kind.cumulative": "cumul",
44 + "kind.event": "événement",
45 + "kind.derived": "dérivé",
46 + "footer.attribution": "Données : ONU (WPP), NOAA GML, Global Carbon Project, Copernicus/ERA5, FAO, Global Forest Watch, Ember/IEA, USGS. Chaque compteur interpole de vraies observations — méthode, source et incertitude documentées.",
47 + "footer.methodology": "Méthodologie",
48 + "offline.title": "API hors ligne",
49 + "offline.body": "Les compteurs sont momentanément indisponibles — le serveur de modèles ne répond pas. Réessayez dans un instant.",
50 + "methodology.title": "Méthodologie",
51 + "methodology.intro": "Chaque compteur d'earth-now.co est un modèle statistique documenté : nous interpolons les dernières observations réelles de sources faisant autorité (spline monotone, saisonnalité, fusion observation/prévision) et nous affichons la source, la date d'observation, la famille de modèle et l'incertitude. Jamais de précision au-delà de ce que le modèle justifie — c'est notre engagement, et notre différence avec les compteurs opaques.",
52 + "methodology.metric": "Métrique",
53 + "methodology.domain": "Domaine",
54 + "methodology.kind": "Type",
55 + "methodology.model": "Modèle",
56 + "methodology.source": "Source",
57 + "methodology.license": "Licence",
58 + "methodology.cadence": "Cadence",
59 + "methodology.observed": "Dernière obs.",
60 + "methodology.uncertainty": "Incertitude",
61 + "methodology.note": "Note",
62 + "methodology.back": "← Retour au tableau de bord",
63 + "quakes.lastMajor": "Dernier séisme majeur",
64 + "quakes.count24h": "séismes (24 h)",
65 + "embed.attribution": "earth-now.co",
66 + "embed.notFound": "Métrique introuvable",
67 + "locale.toggle": "English",
68 + "domain.tech": "Numérique",
69 + "metric.back": "← Tous les compteurs",
70 + "metric.methodTitle": "Méthode de cette métrique",
71 + "metric.level": "niveau",
72 + "metric.windows": "Fenêtres d'affichage",
73 + "metric.fullMethodology": "Voir la méthodologie complète de toutes les métriques →",
74 + "explain.seasonal-spline-v2": "Fusion observation/prévision : spline monotone (PCHIP) entre les dernières observations réelles et les projections de la source — jamais de dépassement, jamais de recul artificiel.",
75 + "explain.keeling-fusion-v1": "Ajustement par moindres carrés d'une tendance + harmoniques saisonnières (courbe de Keeling) sur la série d'observations ; le cycle annuel est projeté au-delà de la dernière mesure.",
76 + "explain.seasonal-ytd-v1": "Cumul annuel remis à zéro le 1er janvier UTC : total annuel de la source réparti selon une saisonnalité modélisée (harmoniques de Fourier jour/semaine/année), taux toujours positif.",
77 + "explain.linear-ytd-v1": "Cumul annuel linéaire (niveau 0) : total annuel de la source réparti uniformément sur l'année — utilisé quand aucune saisonnalité fiable n'est publiée.",
78 + "explain.linear-stock-v1": "Valeur à évolution linéaire ancrée sur la dernière observation (ex. décompte de jours).",
79 + "explain.static-rt-v1": "Vrai temps réel événementiel : la valeur ne bouge que lorsqu'un événement réel est publié par la source — aucune interpolation.",
80 + "explain.derived": "Métrique dérivée : combinaison exacte d'autres compteurs du registre (différence, échelle, fenêtre ou taux) — la même fonction est animée côté client.",
81 + "explain.default": "Modèle statistique documenté — voir la méthodologie complète.",
82 + "domain.health": "Santé",
83 + "domain.economy": "Économie",
84 + "year.elapsed": "de l'année écoulée",
85 + "continents.title": "Population par continent",
86 + "continents.subtitle": "Interpolation ONU WPP — l'Afrique gagne ~35 M d'habitants par an, l'Europe en perd."
87 +}
\ No newline at end of file
added apps/web/next-env.d.ts +5 −0
@@ -0,0 +1,5 @@
1 +/// <reference types="next" />
2 +/// <reference types="next/image-types/global" />
3 +
4 +// NOTE: This file should not be edited
5 +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
added apps/web/next.config.mjs +15 −0
@@ -0,0 +1,15 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/next.config.mjs
6 + * Purpose: Minimal Next.js configuration for the earth-now dashboard (transpile the shared counter runtime)
7 + */
8 +
9 +/** @type {import('next').NextConfig} */
10 +const nextConfig = {
11 + reactStrictMode: true,
12 + transpilePackages: ["@earth-now/counter"],
13 +};
14 +
15 +export default nextConfig;
added apps/web/package.json +32 −0
@@ -0,0 +1,32 @@
1 +{
2 + "name": "@earth-now/web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "earth-now.co web dashboard — Next.js 14 App Router, live planetary counters",
6 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
7 + "scripts": {
8 + "dev": "next dev -p 3000",
9 + "build": "next build",
10 + "start": "next start -p 3000",
11 + "test": "vitest run",
12 + "typecheck": "tsc --noEmit",
13 + "lint": "echo 'lint: covered by root header check (eslint config TBD)'"
14 + },
15 + "dependencies": {
16 + "@earth-now/counter": "workspace:*",
17 + "next": "^14.2.15",
18 + "react": "^18.3.1",
19 + "react-dom": "^18.3.1"
20 + },
21 + "devDependencies": {
22 + "@types/node": "^20.16.5",
23 + "@types/react": "^18.3.5",
24 + "@types/react-dom": "^18.3.0",
25 + "autoprefixer": "^10.4.20",
26 + "jsdom": "^24.1.3",
27 + "postcss": "^8.4.47",
28 + "tailwindcss": "^3.4.10",
29 + "typescript": "^5.5.4",
30 + "vitest": "^2.0.5"
31 + }
32 +}
added apps/web/postcss.config.mjs +14 −0
@@ -0,0 +1,14 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/postcss.config.mjs
6 + * Purpose: PostCSS pipeline for the web app — Tailwind CSS + Autoprefixer
7 + */
8 +
9 +export default {
10 + plugins: {
11 + tailwindcss: {},
12 + autoprefixer: {},
13 + },
14 +};
added apps/web/tailwind.config.ts +51 −0
@@ -0,0 +1,51 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/tailwind.config.ts
6 + * Purpose: Tailwind 3.4 configuration — semantic colors bound to the theme CSS variables (light default, dark toggle)
7 + */
8 +
9 +import type { Config } from "tailwindcss";
10 +
11 +const config: Config = {
12 + content: [
13 + "./app/**/*.{ts,tsx}",
14 + "./components/**/*.{ts,tsx}",
15 + "./lib/**/*.{ts,tsx}",
16 + ],
17 + theme: {
18 + extend: {
19 + colors: {
20 + page: "var(--page)",
21 + surface: "var(--surface)",
22 + ink: "var(--ink)",
23 + ink2: "var(--ink-2)",
24 + muted: "var(--muted)",
25 + line: "var(--line)",
26 + accent: "var(--accent)",
27 + good: "var(--good)",
28 + warn: "var(--warn)",
29 + critical: "var(--critical)",
30 + },
31 + borderColor: {
32 + DEFAULT: "var(--border)",
33 + },
34 + backgroundColor: {
35 + wash: "var(--accent-wash)",
36 + },
37 + fontFamily: {
38 + sans: [
39 + "system-ui",
40 + "-apple-system",
41 + "Segoe UI",
42 + "Roboto",
43 + "sans-serif",
44 + ],
45 + },
46 + },
47 + },
48 + plugins: [],
49 +};
50 +
51 +export default config;
added apps/web/test/consistency-shared.ts +83 −0
@@ -0,0 +1,83 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/consistency-shared.ts
6 + * Purpose: Shared fixture for the client/server consistency tests — models, instants and hand-derivable closed-form expected values
7 + */
8 +
9 +import {
10 + SEASONAL_EPOCH_MS,
11 + YEAR_SECONDS,
12 + parseIsoUtc,
13 + type CounterModel,
14 +} from "@earth-now/counter";
15 +
16 +export const ANCHOR_ISO = "2026-01-01T00:00:00.000Z";
17 +
18 +/** Simple linear model — closed-form value is hand-derivable. */
19 +export const LINEAR_MODEL: CounterModel = {
20 + metricId: "consistency_linear",
21 + anchorValue: 8_231_613_070,
22 + anchorTime: ANCHOR_ISO,
23 + rateFn: { kind: "linear", perSecond: 2.3 },
24 + observedAt: "2025-07-01T00:00:00.000Z",
25 + sourceId: "un_wpp_2024",
26 + modelVersion: "linear-stock-v1",
27 + displayHints: { decimals: 0, sigFigs: 7, unit: "people" },
28 +};
29 +
30 +/** Seasonal model with one annual harmonic — analytic integral, exact everywhere. */
31 +export const SEASONAL_MODEL: CounterModel = {
32 + metricId: "consistency_seasonal",
33 + anchorValue: 0,
34 + anchorTime: ANCHOR_ISO,
35 + rateFn: {
36 + kind: "seasonal",
37 + base: 4.3,
38 + harmonics: [{ period: "year", order: 1, amplitude: 0.5, phase: 0.25 }],
39 + },
40 + observedAt: "2025-12-01T00:00:00.000Z",
41 + sourceId: "un_wpp_2024",
42 + modelVersion: "seasonal-ytd-v1",
43 + displayHints: { decimals: 0, unit: "births" },
44 +};
45 +
46 +/** Five instants across the year, including the anchor itself. */
47 +export const INSTANTS: readonly number[] = [
48 + Date.UTC(2026, 0, 1, 0, 0, 0),
49 + Date.UTC(2026, 1, 15, 12, 0, 0),
50 + Date.UTC(2026, 5, 30, 23, 59, 59),
51 + Date.UTC(2026, 7, 9, 7, 30, 0),
52 + Date.UTC(2026, 11, 31, 23, 59, 59, 999),
53 +];
54 +
55 +/**
56 + * Closed-form linear value, replicating the exact arithmetic (and operation
57 + * order) of counterValue for kind=linear — provably v = a + r·Δt.
58 + */
59 +export function linearClosedForm(tMs: number): number {
60 + const anchorMs = parseIsoUtc(LINEAR_MODEL.anchorTime);
61 + const rateFn = LINEAR_MODEL.rateFn;
62 + if (rateFn.kind !== "linear") throw new Error("fixture must be linear");
63 + return LINEAR_MODEL.anchorValue + (rateFn.perSecond * (tMs - anchorMs)) / 1000;
64 +}
65 +
66 +/**
67 + * Closed-form seasonal value: v(t) = a + base·(τ₁−τ₀) + (A/ω)·(sin(ωτ₁+φ) − sin(ωτ₀+φ)),
68 + * with ω = 2π·order / YEAR_SECONDS and τ measured from the seasonal epoch —
69 + * the analytic integral of the declared rate, in the same operation order as the runtime.
70 + */
71 +export function seasonalClosedForm(tMs: number): number {
72 + const rateFn = SEASONAL_MODEL.rateFn;
73 + if (rateFn.kind !== "seasonal") throw new Error("fixture must be seasonal");
74 + const anchorMs = parseIsoUtc(SEASONAL_MODEL.anchorTime);
75 + const tau0 = (anchorMs - SEASONAL_EPOCH_MS) / 1000;
76 + const tau1 = (tMs - SEASONAL_EPOCH_MS) / 1000;
77 + let v = SEASONAL_MODEL.anchorValue + rateFn.base * (tau1 - tau0);
78 + for (const h of rateFn.harmonics) {
79 + const omega = (2 * Math.PI * h.order) / YEAR_SECONDS;
80 + v += (h.amplitude / omega) * (Math.sin(omega * tau1 + h.phase) - Math.sin(omega * tau0 + h.phase));
81 + }
82 + return v;
83 +}
added apps/web/test/consistency.jsdom.test.ts +55 −0
@@ -0,0 +1,55 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/consistency.jsdom.test.ts
6 + * Purpose: Client/server consistency — counterValue in the JSDOM (browser-like) environment must match the same closed-form values bit-for-bit
7 + *
8 + * @vitest-environment jsdom
9 + */
10 +
11 +import { describe, expect, it } from "vitest";
12 +import { counterValue, parseIsoUtc } from "@earth-now/counter";
13 +import {
14 + INSTANTS,
15 + LINEAR_MODEL,
16 + SEASONAL_MODEL,
17 + linearClosedForm,
18 + seasonalClosedForm,
19 +} from "./consistency-shared";
20 +
21 +describe("counterValue consistency (jsdom environment)", () => {
22 + it("runs in a browser-like environment (DOM present)", () => {
23 + expect(typeof globalThis.window).not.toBe("undefined");
24 + expect(typeof document).not.toBe("undefined");
25 + });
26 +
27 + it("value(anchorTime) === anchorValue exactly", () => {
28 + const anchorMs = parseIsoUtc(LINEAR_MODEL.anchorTime);
29 + expect(Object.is(counterValue(LINEAR_MODEL, anchorMs), LINEAR_MODEL.anchorValue)).toBe(true);
30 + expect(Object.is(counterValue(SEASONAL_MODEL, anchorMs), SEASONAL_MODEL.anchorValue)).toBe(
31 + true,
32 + );
33 + });
34 +
35 + it("matches the linear closed form bit-for-bit at all five instants (same values as node)", () => {
36 + for (const t of INSTANTS) {
37 + expect(Object.is(counterValue(LINEAR_MODEL, t), linearClosedForm(t))).toBe(true);
38 + }
39 + });
40 +
41 + it("matches the seasonal closed form bit-for-bit at all five instants (same values as node)", () => {
42 + for (const t of INSTANTS) {
43 + expect(Object.is(counterValue(SEASONAL_MODEL, t), seasonalClosedForm(t))).toBe(true);
44 + }
45 + });
46 +
47 + it("is deterministic: re-evaluation yields Object.is-identical numbers", () => {
48 + for (const t of INSTANTS) {
49 + expect(Object.is(counterValue(LINEAR_MODEL, t), counterValue(LINEAR_MODEL, t))).toBe(true);
50 + expect(Object.is(counterValue(SEASONAL_MODEL, t), counterValue(SEASONAL_MODEL, t))).toBe(
51 + true,
52 + );
53 + }
54 + });
55 +});
added apps/web/test/consistency.node.test.ts +52 −0
@@ -0,0 +1,52 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/consistency.node.test.ts
6 + * Purpose: Client/server consistency — counterValue in the NODE environment must match the closed-form values bit-for-bit (Object.is)
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { counterValue, parseIsoUtc } from "@earth-now/counter";
11 +import {
12 + INSTANTS,
13 + LINEAR_MODEL,
14 + SEASONAL_MODEL,
15 + linearClosedForm,
16 + seasonalClosedForm,
17 +} from "./consistency-shared";
18 +
19 +describe("counterValue consistency (node environment)", () => {
20 + it("runs in a server-like environment (no DOM)", () => {
21 + expect(typeof globalThis.window).toBe("undefined");
22 + });
23 +
24 + it("value(anchorTime) === anchorValue exactly", () => {
25 + const anchorMs = parseIsoUtc(LINEAR_MODEL.anchorTime);
26 + expect(Object.is(counterValue(LINEAR_MODEL, anchorMs), LINEAR_MODEL.anchorValue)).toBe(true);
27 + expect(Object.is(counterValue(SEASONAL_MODEL, anchorMs), SEASONAL_MODEL.anchorValue)).toBe(
28 + true,
29 + );
30 + });
31 +
32 + it("matches the linear closed form bit-for-bit at all five instants", () => {
33 + for (const t of INSTANTS) {
34 + expect(Object.is(counterValue(LINEAR_MODEL, t), linearClosedForm(t))).toBe(true);
35 + }
36 + });
37 +
38 + it("matches the seasonal closed form bit-for-bit at all five instants", () => {
39 + for (const t of INSTANTS) {
40 + expect(Object.is(counterValue(SEASONAL_MODEL, t), seasonalClosedForm(t))).toBe(true);
41 + }
42 + });
43 +
44 + it("is deterministic: re-evaluation yields Object.is-identical numbers", () => {
45 + for (const t of INSTANTS) {
46 + expect(Object.is(counterValue(LINEAR_MODEL, t), counterValue(LINEAR_MODEL, t))).toBe(true);
47 + expect(Object.is(counterValue(SEASONAL_MODEL, t), counterValue(SEASONAL_MODEL, t))).toBe(
48 + true,
49 + );
50 + }
51 + });
52 +});
added apps/web/test/derived.test.ts +202 −0
@@ -0,0 +1,202 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/derived.test.ts
6 + * Purpose: Tests for the pure derived-metric transforms — primary window, depletion countdown, model resolution, display hints, raw values
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import {
11 + counterValue,
12 + rateAt,
13 + startOfUtcYear,
14 + type CounterModel,
15 +} from "@earth-now/counter";
16 +import type { MetricSummary } from "../lib/api";
17 +import {
18 + depletionYears,
19 + displayRawValue,
20 + hintsFor,
21 + isEstimate,
22 + resolveModel,
23 + resolvePrimaryWindow,
24 + YEAR_SECONDS,
25 +} from "../lib/derived";
26 +
27 +const ANCHOR = "2026-01-01T00:00:00.000Z";
28 +
29 +function makeMetric(overrides: Partial<MetricSummary>): MetricSummary {
30 + return {
31 + id: "test_metric",
32 + name: { fr: "Métrique test", en: "Test metric" },
33 + domain: "climate",
34 + priority: "mvp",
35 + kind: "stock",
36 + level: 0,
37 + model: "linear-stock-v1",
38 + unit: "u",
39 + sources: [
40 + {
41 + id: "src",
42 + name: "Test source",
43 + url: "https://example.org",
44 + license: "CC BY 4.0",
45 + cadence: "yearly",
46 + },
47 + ],
48 + display: { decimals: 0, unit: { fr: "unités", en: "units" } },
49 + windows: ["total"],
50 + stale: false,
51 + ...overrides,
52 + };
53 +}
54 +
55 +function linearModel(anchorValue: number, perSecond: number, metricId = "test_metric"): CounterModel {
56 + return {
57 + metricId,
58 + anchorValue,
59 + anchorTime: ANCHOR,
60 + rateFn: { kind: "linear", perSecond },
61 + observedAt: "2025-12-31T00:00:00.000Z",
62 + sourceId: "src",
63 + modelVersion: "linear-test-v1",
64 + displayHints: { decimals: 0, unit: "u" },
65 + };
66 +}
67 +
68 +describe("resolvePrimaryWindow", () => {
69 + it("uses the derived window when op=window", () => {
70 + const metric = makeMetric({
71 + kind: "derived",
72 + derived: { op: "window", inputs: [{ id: "births_ytd", weight: 1 }], window: "today" },
73 + });
74 + expect(resolvePrimaryWindow(metric)).toBe("today");
75 + });
76 +
77 + it("honors the registry's declared window priority (windows[0])", () => {
78 + expect(
79 + resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["today", "ytd"] })),
80 + ).toBe("today");
81 + expect(
82 + resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["ytd", "today"] })),
83 + ).toBe("ytd");
84 + });
85 +
86 + it("falls back to ytd for cumulatives and total otherwise when nothing is declared", () => {
87 + expect(resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: [] }))).toBe("ytd");
88 + expect(resolvePrimaryWindow(makeMetric({ kind: "stock" }))).toBe("total");
89 + expect(resolvePrimaryWindow(makeMetric({ kind: "event" }))).toBe("total");
90 + });
91 +});
92 +
93 +describe("depletionYears", () => {
94 + it("computes remaining / (−rate × year) exactly for a linear model", () => {
95 + // Remaining stock worth exactly 2 years at 1 unit/s.
96 + const model = linearModel(2 * YEAR_SECONDS, -1);
97 + expect(depletionYears(model, Date.parse(ANCHOR))).toBe(2);
98 + });
99 +
100 + it("shrinks as the stock depletes", () => {
101 + const model = linearModel(2 * YEAR_SECONDS, -1);
102 + const later = Date.parse(ANCHOR) + 1_000 * YEAR_SECONDS; // one year later
103 + expect(depletionYears(model, later)).toBeCloseTo(1, 9);
104 + });
105 +
106 + it("returns NaN when the stock is not depleting (guardrail: no absurd countdown)", () => {
107 + expect(depletionYears(linearModel(1_000, 1), Date.parse(ANCHOR))).toBeNaN();
108 + expect(depletionYears(linearModel(1_000, 0), Date.parse(ANCHOR))).toBeNaN();
109 + });
110 +});
111 +
112 +describe("resolveModel", () => {
113 + const own = linearModel(1, 1, "own_metric");
114 + const input = linearModel(2, 2, "input_metric");
115 + const models = { own_metric: own, input_metric: input };
116 +
117 + it("prefers the metric's own model", () => {
118 + const metric = makeMetric({
119 + id: "own_metric",
120 + derived: { op: "rate-of", inputs: [{ id: "input_metric", weight: 1 }] },
121 + });
122 + expect(resolveModel(metric, models)).toBe(own);
123 + });
124 +
125 + it("falls back to the first derived input's model", () => {
126 + const metric = makeMetric({
127 + id: "not_materialized",
128 + derived: { op: "depletion-countdown", inputs: [{ id: "input_metric", weight: 1 }] },
129 + });
130 + expect(resolveModel(metric, models)).toBe(input);
131 + });
132 +
133 + it("returns undefined when nothing resolves", () => {
134 + expect(resolveModel(makeMetric({ id: "missing" }), models)).toBeUndefined();
135 + });
136 +});
137 +
138 +describe("displayRawValue", () => {
139 + const t = Date.parse("2026-08-09T12:00:00.000Z");
140 +
141 + it("returns the instantaneous rate for rate-of metrics", () => {
142 + const metric = makeMetric({
143 + derived: { op: "rate-of", inputs: [{ id: "x", weight: 1 }] },
144 + });
145 + const model = linearModel(0, 42);
146 + expect(displayRawValue(metric, model, t, "total")).toBe(rateAt(model, t));
147 + expect(displayRawValue(metric, model, t, "total")).toBe(42);
148 + });
149 +
150 + it("returns depletion years for depletion-countdown metrics", () => {
151 + const metric = makeMetric({
152 + derived: { op: "depletion-countdown", inputs: [{ id: "x", weight: 1 }] },
153 + });
154 + const model = linearModel(2 * YEAR_SECONDS, -1);
155 + expect(displayRawValue(metric, model, Date.parse(ANCHOR), "total")).toBe(2);
156 + });
157 +
158 + it("computes window values from the same model (ytd = v(t) − v(Jan 1 UTC))", () => {
159 + const metric = makeMetric({ kind: "cumulative" });
160 + const model = linearModel(1_000_000, 3);
161 + const expected = counterValue(model, t) - counterValue(model, startOfUtcYear(t));
162 + expect(displayRawValue(metric, model, t, "ytd")).toBe(expected);
163 + });
164 +
165 + it("returns NaN (not a throw) for a session window without a session start", () => {
166 + const metric = makeMetric({ kind: "cumulative" });
167 + expect(displayRawValue(metric, linearModel(0, 1), t, "session")).toBeNaN();
168 + });
169 +
170 + it("computes session values from the arrival instant", () => {
171 + const metric = makeMetric({ kind: "cumulative" });
172 + const model = linearModel(0, 2);
173 + const sessionStart = t - 30_000; // arrived 30 s ago at 2/s → 60
174 + expect(displayRawValue(metric, model, t, "session", sessionStart)).toBe(60);
175 + });
176 +});
177 +
178 +describe("hintsFor / isEstimate", () => {
179 + it("localizes the unit and forwards optional sigFigs/scale", () => {
180 + const metric = makeMetric({
181 + display: { decimals: 1, sigFigs: 3, scale: 1e-9, unit: { fr: "Gt CO₂", en: "Gt CO2" } },
182 + });
183 + expect(hintsFor(metric, "fr")).toEqual({
184 + decimals: 1,
185 + sigFigs: 3,
186 + scale: 1e-9,
187 + unit: "Gt CO₂",
188 + });
189 + expect(hintsFor(metric, "en").unit).toBe("Gt CO2");
190 + });
191 +
192 + it("omits sigFigs/scale keys entirely when the registry does not set them", () => {
193 + const hints = hintsFor(makeMetric({}), "fr");
194 + expect("sigFigs" in hints).toBe(false);
195 + expect("scale" in hints).toBe(false);
196 + });
197 +
198 + it("flags the mandatory estimate label if and only if uncertaintyFraction is set", () => {
199 + expect(isEstimate(makeMetric({}))).toBe(false);
200 + expect(isEstimate(makeMetric({ uncertaintyFraction: 0.5 }))).toBe(true);
201 + });
202 +});
added apps/web/test/smoothing.test.ts +95 −0
@@ -0,0 +1,95 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/smoothing.test.ts
6 + * Purpose: Tests for the 60 s model-swap smoothing — exact old value at swap start, exact new value at swap end, linear blend in between
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { counterValue, type CounterModel } from "@earth-now/counter";
11 +import {
12 + blendValues,
13 + makeSmoothedEvaluator,
14 + SMOOTHING_DURATION_MS,
15 + smoothingAlpha,
16 +} from "../lib/smoothing";
17 +
18 +const ANCHOR = "2026-01-01T00:00:00.000Z";
19 +
20 +function linearModel(anchorValue: number, perSecond: number): CounterModel {
21 + return {
22 + metricId: "test_metric",
23 + anchorValue,
24 + anchorTime: ANCHOR,
25 + rateFn: { kind: "linear", perSecond },
26 + observedAt: "2025-12-31T00:00:00.000Z",
27 + sourceId: "test_source",
28 + modelVersion: "linear-test-v1",
29 + displayHints: { decimals: 0, unit: "u" },
30 + };
31 +}
32 +
33 +describe("makeSmoothedEvaluator", () => {
34 + const oldModel = linearModel(1_000, 1);
35 + const newModel = linearModel(1_600, 2);
36 + const swapStart = Date.UTC(2026, 0, 2, 12, 0, 0);
37 + const duration = SMOOTHING_DURATION_MS;
38 + const evaluate = makeSmoothedEvaluator(oldModel, newModel, swapStart, duration);
39 +
40 + it("returns exactly the old model value at swap start (and before)", () => {
41 + expect(evaluate(swapStart)).toBe(counterValue(oldModel, swapStart));
42 + expect(evaluate(swapStart - 5_000)).toBe(counterValue(oldModel, swapStart - 5_000));
43 + });
44 +
45 + it("returns exactly the new model value at swap start + duration (and after)", () => {
46 + const end = swapStart + duration;
47 + expect(evaluate(end)).toBe(counterValue(newModel, end));
48 + expect(evaluate(end + 120_000)).toBe(counterValue(newModel, end + 120_000));
49 + });
50 +
51 + it("is the arithmetic mean of both models at the midpoint", () => {
52 + const mid = swapStart + duration / 2;
53 + const expected = (counterValue(oldModel, mid) + counterValue(newModel, mid)) / 2;
54 + expect(evaluate(mid)).toBeCloseTo(expected, 9);
55 + });
56 +
57 + it("blends monotonically from old towards new across the window", () => {
58 + const quarter = evaluate(swapStart + duration / 4);
59 + const mid = evaluate(swapStart + duration / 2);
60 + const threeQuarters = evaluate(swapStart + (3 * duration) / 4);
61 + const oldMid = counterValue(oldModel, swapStart + duration / 2);
62 + const newMid = counterValue(newModel, swapStart + duration / 2);
63 + const lo = Math.min(oldMid, newMid);
64 + const hi = Math.max(oldMid, newMid);
65 + expect(mid).toBeGreaterThanOrEqual(lo);
66 + expect(mid).toBeLessThanOrEqual(hi);
67 + // With new > old everywhere here, the blend weight grows with t.
68 + expect(quarter).toBeLessThan(mid);
69 + expect(mid).toBeLessThan(threeQuarters);
70 + });
71 +
72 + it("treats a non-positive duration as an instant swap to the new model", () => {
73 + const instant = makeSmoothedEvaluator(oldModel, newModel, swapStart, 0);
74 + expect(instant(swapStart)).toBe(counterValue(newModel, swapStart));
75 + expect(instant(swapStart - 1)).toBe(counterValue(newModel, swapStart - 1));
76 + });
77 +});
78 +
79 +describe("smoothingAlpha / blendValues", () => {
80 + const swapStart = 1_000_000;
81 +
82 + it("clamps alpha to [0, 1]", () => {
83 + expect(smoothingAlpha(swapStart - 1, swapStart, 60_000)).toBe(0);
84 + expect(smoothingAlpha(swapStart, swapStart, 60_000)).toBe(0);
85 + expect(smoothingAlpha(swapStart + 30_000, swapStart, 60_000)).toBe(0.5);
86 + expect(smoothingAlpha(swapStart + 60_000, swapStart, 60_000)).toBe(1);
87 + expect(smoothingAlpha(swapStart + 90_000, swapStart, 60_000)).toBe(1);
88 + });
89 +
90 + it("blends already-evaluated values linearly", () => {
91 + expect(blendValues(10, 20, swapStart, swapStart, 60_000)).toBe(10);
92 + expect(blendValues(10, 20, swapStart + 30_000, swapStart, 60_000)).toBeCloseTo(15, 12);
93 + expect(blendValues(10, 20, swapStart + 60_000, swapStart, 60_000)).toBe(20);
94 + });
95 +});
added apps/web/test/tiers.test.ts +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/test/tiers.test.ts
6 + * Purpose: Display-tier classification — fast tickers lead, slow stocks land in the reference strip, RT and countries route to their sections
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import type { CounterModel } from "@earth-now/counter";
11 +import type { MetricSummary } from "../lib/api";
12 +import { secondsPerVisibleTick, tierFor, LIVE_TICK_THRESHOLD_S } from "../lib/tiers";
13 +
14 +const T = Date.parse("2026-08-09T12:00:00Z");
15 +
16 +function model(perSecond: number): CounterModel {
17 + return {
18 + metricId: "m",
19 + anchorValue: 0,
20 + anchorTime: "2026-01-01T00:00:00.000Z",
21 + rateFn: { kind: "linear", perSecond },
22 + observedAt: "2026-01-01T00:00:00.000Z",
23 + sourceId: "s",
24 + modelVersion: "test-v1",
25 + displayHints: { decimals: 0, unit: "u" },
26 + };
27 +}
28 +
29 +function metric(over: Partial<MetricSummary>): MetricSummary {
30 + return {
31 + id: "some_metric",
32 + name: { fr: "x", en: "x" },
33 + domain: "population",
34 + priority: "mvp",
35 + kind: "cumulative",
36 + level: 1,
37 + model: "seasonal-ytd-v1",
38 + unit: "people",
39 + sources: [],
40 + display: { decimals: 0, unit: { fr: "u", en: "u" } },
41 + windows: ["ytd"],
42 + stale: false,
43 + ...over,
44 + };
45 +}
46 +
47 +describe("secondsPerVisibleTick", () => {
48 + it("computes the visible tick interval from rate, decimals and display scale", () => {
49 + // 4 units/s at 0 decimals → last digit changes every 0.25 s.
50 + expect(secondsPerVisibleTick(model(4), { decimals: 0 }, T)).toBeCloseTo(0.25, 9);
51 + // 1027 MWh/s displayed in TWh (scale 1e-6) with 2 decimals → 0.01 TWh per tick.
52 + expect(secondsPerVisibleTick(model(1027), { decimals: 2, scale: 1e-6 }, T)).toBeCloseTo(
53 + 0.01 / (1027e-6),
54 + 6,
55 + );
56 + expect(secondsPerVisibleTick(model(0), { decimals: 0 }, T)).toBe(Infinity);
57 + });
58 +});
59 +
60 +describe("tierFor", () => {
61 + it("fast tickers are live, slow stocks are reference", () => {
62 + expect(tierFor(metric({}), model(4.2), T)).toBe("live");
63 + // CO₂ ppm style: 2.6 ppm/yr, 2 decimals → hours per tick → reference.
64 + expect(
65 + tierFor(
66 + metric({ id: "co2_ppm", kind: "stock", display: { decimals: 2, unit: { fr: "ppm", en: "ppm" } } }),
67 + model(2.6 / 31_556_952),
68 + T,
69 + ),
70 + ).toBe("reference");
71 + });
72 +
73 + it("routes hero, countries and RT metrics to their sections regardless of rate", () => {
74 + expect(tierFor(metric({ id: "world_population" }), model(2.2), T)).toBe("hero");
75 + expect(tierFor(metric({ id: "country_population_india" }), model(0.37), T)).toBe("country");
76 + expect(tierFor(metric({ id: "earthquakes_24h", level: "rt" }), model(0), T)).toBe("realtime");
77 + });
78 +
79 + it("rate-of and depletion displays read as static → reference", () => {
80 + const rateOf = metric({
81 + id: "co2_rate",
82 + kind: "derived",
83 + derived: { op: "rate-of", inputs: [{ id: "co2_emissions_ytd", weight: 1 }] },
84 + });
85 + expect(tierFor(rateOf, model(1200), T)).toBe("reference");
86 + });
87 +
88 + it("no model → reference (stale metrics never fake liveness)", () => {
89 + expect(tierFor(metric({}), undefined, T)).toBe("reference");
90 + });
91 +
92 + it("threshold boundary respects LIVE_TICK_THRESHOLD_S", () => {
93 + const boundaryRate = 1 / LIVE_TICK_THRESHOLD_S;
94 + expect(tierFor(metric({}), model(boundaryRate * 1.01), T)).toBe("live");
95 + expect(tierFor(metric({}), model(boundaryRate * 0.99), T)).toBe("reference");
96 + });
97 +});
added apps/web/tsconfig.json +17 −0
@@ -0,0 +1,17 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "lib": ["ES2022", "DOM", "DOM.Iterable"],
5 + "jsx": "preserve",
6 + "allowJs": true,
7 + "noEmit": true,
8 + "incremental": true,
9 + "declaration": false,
10 + "declarationMap": false,
11 + "sourceMap": false,
12 + "plugins": [{ "name": "next" }],
13 + "paths": { "@/*": ["./*"] }
14 + },
15 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
16 + "exclude": ["node_modules", ".next"]
17 +}
added apps/web/tsconfig.tsbuildinfo +1 −0
@@ -0,0 +1 @@
1 +{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/styled-jsx/types/css.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+prop-types@15.7.15/node_modules/@types/prop-types/index.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/styled-jsx/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/styled-jsx/types/macro.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/styled-jsx/types/style.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/styled-jsx/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/amp.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/amp.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/get-page-files.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/canary.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/experimental.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/index.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/canary.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/experimental.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/webpack/webpack.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/config.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/load-custom-routes.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/image-config.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/body-streams.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-kind.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-matches/route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/app-router-headers.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/request-meta.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/config-shared.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/base-http/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/api-utils/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/node-environment.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/require-hook.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/node-polyfill-crypto.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/page-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/analysis/get-page-static-info.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/render-result.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/next-url.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/request.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/response.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/setup-exception-listeners.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/base-http/node.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/font-utils.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/route-module.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/deep-readonly.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/load-components.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/mitt.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/with-router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/route-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/page-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/bloom-filter.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/page-extensions-type.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/app-dir-module.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/response-cache/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/response-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/incremental-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/hooks-server-context.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/request-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/request-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/create-error-handler.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/app-render.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/error-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/app-router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/layout-router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/render-from-template-context.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/action-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/action-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/client-page.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/search-params.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/not-found-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/rsc/preloads.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/rsc/postpone.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/rsc/taint.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/entry-base.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/templates/app-page.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/builtin-request-context.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/app-render/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/templates/pages.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-modules/pages/module.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/render.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/action.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/base-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/image-optimizer.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/next-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/coalesced-function.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-utils/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/trace/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/trace/trace.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/trace/shared.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/trace/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/load-jsconfig.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack-config.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/swc/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/dev/parse-version-info.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/dev/hot-reloader-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/telemetry/storage.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/render-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/lib/dev-bundler-service.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/dev/static-paths-worker.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/dev/next-dev-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/next.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/extra-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","../../node_modules/.pnpm/@next+env@14.2.35/node_modules/@next/env/dist/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/utils.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/pages/_app.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/app.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/cache.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/runtime-config.external.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/config.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/pages/_document.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/document.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/pages/_error.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/error.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/head.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/head.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/draft-mode.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/get-img-props.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/image-component.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/shared/lib/image-external.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/image.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/link.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/link.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/redirect-status-code.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/redirect.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/not-found.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/navigation.react-server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/components/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/router.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/client/script.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/script.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/server/web/spec-extension/image-response.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/compiled/@vercel/og/types.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/server.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/types/compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/index.d.ts","../../node_modules/.pnpm/next@14.2.35_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/image-types/global.d.ts","./next-env.d.ts","../../node_modules/.pnpm/source-map-js@1.2.1/node_modules/source-map-js/source-map.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/previous-map.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/input.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/css-syntax-error.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/declaration.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/root.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/warning.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/lazy-result.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/no-work-result.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/processor.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/result.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/document.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/rule.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/node.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/comment.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/container.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/at-rule.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/list.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/postcss.d.ts","../../node_modules/.pnpm/postcss@8.5.26/node_modules/postcss/lib/postcss.d.mts","../../node_modules/.pnpm/tailwindcss@3.4.19_tsx@4.23.11_yaml@2.9.0/node_modules/tailwindcss/types/generated/corepluginlist.d.ts","../../node_modules/.pnpm/tailwindcss@3.4.19_tsx@4.23.11_yaml@2.9.0/node_modules/tailwindcss/types/generated/colors.d.ts","../../node_modules/.pnpm/tailwindcss@3.4.19_tsx@4.23.11_yaml@2.9.0/node_modules/tailwindcss/types/config.d.ts","../../node_modules/.pnpm/tailwindcss@3.4.19_tsx@4.23.11_yaml@2.9.0/node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","../../node_modules/.pnpm/@types+estree@1.0.9/node_modules/@types/estree/index.d.ts","../../node_modules/.pnpm/rollup@4.62.4/node_modules/rollup/dist/rollup.d.ts","../../node_modules/.pnpm/rollup@4.62.4/node_modules/rollup/dist/parseast.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/types/hmrpayload.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/types/customevent.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/types/hot.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","../../node_modules/.pnpm/esbuild@0.21.5/node_modules/esbuild/lib/main.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/dist/node/runtime.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/types/importglob.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/types/metadata.d.ts","../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.43/node_modules/vite/dist/node/index.d.ts","../../node_modules/.pnpm/@vitest+pretty-format@2.1.9/node_modules/@vitest/pretty-format/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/types.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/helpers.d.ts","../../node_modules/.pnpm/tinyrainbow@1.2.0/node_modules/tinyrainbow/dist/index-c1cfc5e9.d.ts","../../node_modules/.pnpm/tinyrainbow@1.2.0/node_modules/tinyrainbow/dist/node.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/index.d.ts","../../node_modules/.pnpm/@vitest+runner@2.1.9/node_modules/@vitest/runner/dist/tasks-3znpj1lr.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/types-bxe-2udy.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/diff.d.ts","../../node_modules/.pnpm/@vitest+runner@2.1.9/node_modules/@vitest/runner/dist/types.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/error.d.ts","../../node_modules/.pnpm/@vitest+runner@2.1.9/node_modules/@vitest/runner/dist/index.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/environment.looobwuu.d.ts","../../node_modules/.pnpm/@vitest+snapshot@2.1.9/node_modules/@vitest/snapshot/dist/environment-ddx0edty.d.ts","../../node_modules/.pnpm/@vitest+snapshot@2.1.9/node_modules/@vitest/snapshot/dist/rawsnapshot-cpnkto81.d.ts","../../node_modules/.pnpm/@vitest+snapshot@2.1.9/node_modules/@vitest/snapshot/dist/index.d.ts","../../node_modules/.pnpm/@vitest+snapshot@2.1.9/node_modules/@vitest/snapshot/dist/environment.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/config.cy0c388z.d.ts","../../node_modules/.pnpm/vite-node@2.1.9_@types+node@20.19.43/node_modules/vite-node/dist/trace-mapping.d-dlvdeqop.d.ts","../../node_modules/.pnpm/vite-node@2.1.9_@types+node@20.19.43/node_modules/vite-node/dist/index-z0r8hvru.d.ts","../../node_modules/.pnpm/vite-node@2.1.9_@types+node@20.19.43/node_modules/vite-node/dist/index.d.ts","../../node_modules/.pnpm/@vitest+utils@2.1.9/node_modules/@vitest/utils/dist/source-map.d.ts","../../node_modules/.pnpm/vite-node@2.1.9_@types+node@20.19.43/node_modules/vite-node/dist/client.d.ts","../../node_modules/.pnpm/vite-node@2.1.9_@types+node@20.19.43/node_modules/vite-node/dist/server.d.ts","../../node_modules/.pnpm/@vitest+runner@2.1.9/node_modules/@vitest/runner/dist/utils.d.ts","../../node_modules/.pnpm/tinybench@2.9.0/node_modules/tinybench/dist/index.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/benchmark.geerunq4.d.ts","../../node_modules/.pnpm/@vitest+snapshot@2.1.9/node_modules/@vitest/snapshot/dist/manager.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/reporters.nr4dxcka.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/vite.czkp4x9w.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/config.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/config.d.ts","./vitest.config.ts","../../packages/counter/dist/counter-model.d.ts","../../packages/counter/dist/value.d.ts","../../packages/counter/dist/windows.d.ts","../../packages/counter/dist/format.d.ts","../../packages/counter/dist/pchip.d.ts","../../packages/counter/dist/index.d.ts","./lib/api.ts","./lib/derived.ts","./messages/fr.json","./messages/en.json","./lib/messages.ts","./lib/smoothing.ts","./lib/tiers.ts","./test/consistency-shared.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/worker.tn5kgiih.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/worker.b9fxpcac.d.ts","../../node_modules/.pnpm/@vitest+expect@2.1.9/node_modules/@vitest/expect/dist/chai.d.cts","../../node_modules/.pnpm/@vitest+expect@2.1.9/node_modules/@vitest/expect/dist/index.d.ts","../../node_modules/.pnpm/@vitest+expect@2.1.9/node_modules/@vitest/expect/index.d.ts","../../node_modules/.pnpm/@vitest+spy@2.1.9/node_modules/@vitest/spy/dist/index.d.ts","../../node_modules/.pnpm/@vitest+mocker@2.1.9_vite@5.4.21_@types+node@20.19.43_/node_modules/@vitest/mocker/dist/types-dzoqtgin.d.ts","../../node_modules/.pnpm/@vitest+mocker@2.1.9_vite@5.4.21_@types+node@20.19.43_/node_modules/@vitest/mocker/dist/index.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/mocker.crtm890j.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/chunks/suite.b2jumifp.d.ts","../../node_modules/.pnpm/expect-type@1.4.0/node_modules/expect-type/dist/utils.d.ts","../../node_modules/.pnpm/expect-type@1.4.0/node_modules/expect-type/dist/overloads.d.ts","../../node_modules/.pnpm/expect-type@1.4.0/node_modules/expect-type/dist/branding.d.ts","../../node_modules/.pnpm/expect-type@1.4.0/node_modules/expect-type/dist/messages.d.ts","../../node_modules/.pnpm/expect-type@1.4.0/node_modules/expect-type/dist/index.d.ts","../../node_modules/.pnpm/vitest@2.1.9_@types+node@20.19.43_jsdom@24.1.3/node_modules/vitest/dist/index.d.ts","./test/consistency.jsdom.test.ts","./test/consistency.node.test.ts","./test/derived.test.ts","./test/smoothing.test.ts","./test/tiers.test.ts","./app/layout.tsx","./lib/i18n.tsx","./components/countryranking.tsx","./components/yearprogress.tsx","./components/fitvalue.tsx","./components/livecounter.tsx","./components/dashboard.tsx","./app/page.tsx","./components/embedcounter.tsx","./app/embed/[id]/page.tsx","./app/methodology/page.tsx","./components/metricdetail.tsx","./app/metric/[id]/page.tsx"],"fileIdsList":[[76,122,463,464,465,468,501],[64,76,122,366,385],[76,122,366,463,464,468],[76,122,372,385,464,504],[76,122,464,499],[64,76,122,463,464,494],[64,76,122,463,464,465,468,470,494,495,496,498],[64,76,122,463,464,494,498],[64,76,122],[64,76,122,366,463,464,465,468,469,494,497],[64,76,122,366,463,464,465,468,470,494,498],[64,76,122,463,494],[76,122,463],[76,122,463,464],[64,76,122,464,468],[76,122,464,466,467],[76,122],[76,122,385,386],[76,122,411],[76,122,463,471,487],[76,122,463,464,465,487],[76,122,463,469,487],[76,122,463,464,470,487],[76,122,163,456],[76,119,122],[76,121,122],[122],[76,122,127,155],[76,122,123,128,133,141,152,163],[76,122,123,124,133,141],[71,72,73,76,122],[76,122,125,164],[76,122,126,127,134,142],[76,122,127,152,160],[76,122,128,130,133,141],[76,121,122,129],[76,122,130,131],[76,122,132,133],[76,121,122,133],[76,122,133,134,135,152,163],[76,122,133,134,135,148,152,155],[76,122,130,133,136,141,152,163],[76,122,133,134,136,137,141,152,160,163],[76,122,136,138,152,160,163],[74,75,76,77,78,79,80,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169],[76,122,133,139],[76,122,140,163,168],[76,122,130,133,141,152],[76,122,142],[76,122,143],[76,121,122,144],[76,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169],[76,122,146],[76,122,147],[76,122,133,148,149],[76,122,148,150,164,166],[76,122,133,152,153,155],[76,122,154,155],[76,122,152,153],[76,122,155],[76,122,156],[76,119,122,152,157],[76,122,133,158,159],[76,122,158,159],[76,122,127,141,152,160],[76,122,161],[76,122,141,162],[76,122,136,147,163],[76,122,127,164],[76,122,152,165],[76,122,140,166],[76,122,167],[76,117,122],[76,117,122,133,135,144,152,155,163,166,168],[76,122,152,169],[64,76,122,174,175,176],[64,76,122,174,175],[64,68,76,122,173,338,381],[64,68,76,122,172,338,381],[61,62,63,76,122],[76,122,429,430,433],[76,122,475],[76,122,478],[76,122,430,431,433,434,435],[76,122,430],[76,122,430,431,433],[76,122,430,431],[76,122,438],[76,122,425,438,439],[76,122,425,438],[76,122,425,432],[76,122,426],[76,122,425,426,427,429],[76,122,425],[76,122,482,483],[76,122,482,483,484,485],[76,122,482,484],[76,122,482],[69,76,122],[76,122,342],[76,122,344,345,346],[76,122,348],[76,122,179,189,195,197,338],[76,122,179,186,188,191,209],[76,122,189],[76,122,189,191,316],[76,122,244,262,277,384],[76,122,286],[76,122,179,189,196,230,240,313,314,384],[76,122,196,384],[76,122,189,240,241,242,384],[76,122,189,196,230,384],[76,122,384],[76,122,179,196,197,384],[76,122,270],[76,121,122,170,269],[64,76,122,263,264,265,283,284],[64,76,122,263],[76,122,253],[76,122,252,254,358],[64,76,122,263,264,281],[76,122,259,284,370],[76,122,368,369],[76,122,203,367],[76,122,256],[76,121,122,170,203,219,252,253,254,255],[64,76,122,281,283,284],[76,122,281,283],[76,122,281,282,284],[76,122,147,170],[76,122,251],[76,121,122,170,188,190,247,248,249,250],[64,76,122,180,361],[64,76,122,163,170],[64,76,122,196,228],[64,76,122,196],[76,122,226,231],[64,76,122,227,341],[64,68,76,122,136,170,172,173,338,379,380],[76,122,338],[76,122,178],[76,122,331,332,333,334,335,336],[76,122,333],[64,76,122,227,263,341],[64,76,122,263,339,341],[64,76,122,263,341],[76,122,136,170,190,341],[76,122,136,170,187,188,199,217,219,251,256,257,279,281],[76,122,248,251,256,264,266,267,268,270,271,272,273,274,275,276,384],[76,122,249],[64,76,122,147,170,188,189,217,219,220,222,247,279,280,284,338,384],[76,122,136,170,190,191,203,204,252],[76,122,136,170,189,191],[76,122,136,152,170,187,190,191],[76,122,136,147,163,170,187,188,189,190,191,196,199,200,210,211,213,216,217,219,220,221,222,246,247,280,281,289,291,294,296,299,301,302,303,304],[76,122,136,152,170],[76,122,179,180,181,187,188,338,341,384],[76,122,136,152,163,170,184,315,317,318,384],[76,122,147,163,170,184,187,190,207,211,213,214,215,220,247,294,305,307,313,327,328],[76,122,189,193,247],[76,122,187,189],[76,122,200,295],[76,122,297,298],[76,122,297],[76,122,295],[76,122,297,300],[76,122,183,184],[76,122,183,223],[76,122,183],[76,122,185,200,293],[76,122,292],[76,122,184,185],[76,122,185,290],[76,122,184],[76,122,279],[76,122,136,170,187,199,218,238,244,258,261,278,281],[76,122,232,233,234,235,236,237,259,260,284,339],[76,122,288],[76,122,136,170,187,199,218,224,285,287,289,338,341],[76,122,136,163,170,180,187,189,246],[76,122,243],[76,122,136,170,321,326],[76,122,210,219,246,341],[76,122,309,313,327,330],[76,122,136,193,313,321,322,330],[76,122,179,189,210,221,324],[76,122,136,170,189,196,221,308,309,319,320,323,325],[76,122,171,217,218,219,338,341],[76,122,136,147,163,170,185,187,188,190,193,198,199,207,210,211,213,214,215,216,220,222,246,247,291,305,306,341],[76,122,136,170,187,189,193,307,329],[76,122,136,170,188,190],[64,76,122,136,147,170,178,180,187,188,191,199,216,217,219,220,222,288,338,341],[76,122,136,147,163,170,182,185,186,190],[76,122,183,245],[76,122,136,170,183,188,199],[76,122,136,170,189,200],[76,122,136,170],[76,122,203],[76,122,202],[76,122,204],[76,122,189,201,203,207],[76,122,189,201,203],[76,122,136,170,182,189,190,196,204,205,206],[64,76,122,281,282,283],[76,122,239],[64,76,122,180],[64,76,122,213],[64,76,122,171,216,219,222,338,341],[76,122,180,361,362],[64,76,122,231],[64,76,122,147,163,170,178,225,227,229,230,341],[76,122,190,196,213],[76,122,212],[64,76,122,134,136,147,170,178,231,240,338,339,340],[60,64,65,66,67,76,122,172,173,338,381],[76,122,127],[76,122,310,311,312],[76,122,310],[76,122,350],[76,122,352],[76,122,354],[76,122,356],[76,122,359],[76,122,363],[68,70,76,122,338,343,347,349,351,353,355,357,360,364,366,372,373,375,382,383,384],[76,122,365],[76,122,371],[76,122,227],[76,122,374],[76,121,122,204,205,206,207,376,377,378,381],[76,122,170],[64,68,76,122,136,138,147,170,172,173,174,176,178,191,330,337,341,381],[76,122,403],[76,122,401,403],[76,122,392,400,401,402,404,406],[76,122,390],[76,122,393,398,403,406],[76,122,389,406],[76,122,393,394,397,398,399,406],[76,122,393,394,395,397,398,406],[76,122,390,391,392,393,394,398,399,400,402,403,404,406],[76,122,406],[76,122,388,390,391,392,393,394,395,397,398,399,400,401,402,403,404,405],[76,122,388,406],[76,122,393,395,396,398,399,406],[76,122,397,406],[76,122,398,399,403,406],[76,122,391,401],[76,122,414,423],[76,122,413,414],[76,122,408,409],[76,122,407,410],[76,122,428],[76,89,93,122,163],[76,89,122,152,163],[76,84,122],[76,86,89,122,160,163],[76,122,141,160],[76,84,122,170],[76,86,89,122,141,163],[76,81,82,85,88,122,133,152,163],[76,89,96,122],[76,81,87,122],[76,89,110,111,122],[76,85,89,122,155,163,170],[76,110,122,170],[76,83,84,122,170],[76,89,122],[76,83,84,85,86,87,88,89,90,91,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,111,112,113,114,115,116,122],[76,89,104,122],[76,89,96,97,122],[76,87,89,97,98,122],[76,88,122],[76,81,84,89,122],[76,89,93,97,98,122],[76,93,122],[76,87,89,92,122,163],[76,81,86,89,96,122],[76,122,152],[76,84,89,110,122,168,170],[76,122,443,444],[76,122,443],[76,122,424,443,444,454],[76,122,133,134,136,137,138,141,152,160,163,169,170,407,414,415,416,417,418,419,420,421,422,423],[76,122,416,417,418,419],[76,122,416,417,418],[76,122,416],[76,122,417],[76,122,414],[76,122,455],[76,122,436,449,450,487],[76,122,425,436,440,441,487],[76,122,479],[76,122,134,152,424,425,430,436,437,440,442,445,446,447,448,451,452,454,474,487],[76,122,436,449,450,451,487],[76,122,424,453],[76,122,168,472],[76,122,436,437,440,442,445,487],[76,122,134,152,424,425,430,436,437,440,441,442,445,446,447,448,449,450,451,452,453,454,474,487],[76,122,134,152,168,424,425,430,433,436,437,440,441,442,445,446,447,448,449,450,451,452,453,454,472,473,474,476,477,479,480,481,486,487],[76,122,458],[76,122,458,459,460,461,462]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3a8bddb66b659f6bd2ff641fc71df8a8165bafe0f4b799cc298be5cd3755bb20","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"ea53732769832d0f127ae16620bd5345991d26bf0b74e85e41b61b27d74ea90f","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"faa03dffb64286e8304a2ca96dd1317a77db6bfc7b3fb385163648f67e535d77","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"0131e203d8560edb39678abe10db42564a068f98c4ebd1ed9ffe7279c78b3c81","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"e650298721abc4f6ae851e60ae93ee8199791ceec4b544c3379862f81f43178c","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"58b49e5c1def740360b5ae22ae2405cfac295fee74abd88d74ac4ea42502dc03","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"847e160d709c74cc714fbe1f99c41d3425b74cd47b1be133df1623cd87014089","impliedFormat":1},{"version":"9fee04f1e1afa50524862289b9f0b0fdc3735b80e2a0d684cec3b9ff3d94cecc","impliedFormat":1},{"version":"5cdc27fbc5c166fc5c763a30ac21cbac9859dc5ba795d3230db6d4e52a1965bb","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"9dd9d642cdb87d4d5b3173217e0c45429b3e47a6f5cf5fb0ead6c644ec5fed01",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},{"version":"56481de91257cf31f0c69afaea426dede9b8b92646a633f3c2f93bab760ee047","signature":"ce053344951319ebb90719259ef39317a053af0fea5c6f135877b2944884c518"},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"21944c138a48dc23382cb6558b1d4498908faad2104ba7ff390ba8b27c06f3c0","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"d2e64a6f25013b099e83bfadb2c388d7bef3e8f3fdb25528225bbc841e7e7e3a","impliedFormat":99},{"version":"369ba5259e66ca8c7d35e3234f7a2a0863a770fdb8266505747c65cf346a0804","impliedFormat":99},{"version":"64d984f55025daf604f670b7dfd090ea765f2098aee871174ef2ee3e94479098","impliedFormat":99},{"version":"f147b6710441cf3ec3234adf63b0593ce5e8c9b692959d21d3babc8454bcf743","impliedFormat":99},{"version":"e96d5373a66c2cfbbc7e6642cf274055aa2c7ff6bd37be7480c66faf9804db6d","impliedFormat":99},{"version":"02bcdd7a76c5c1c485cbf05626d24c86ac8f9a1d8dc31f8924108bbaa4cf3ba9","impliedFormat":99},{"version":"c874ab6feac6e0fdf9142727c9a876065777a5392f14b0bbcf869b1e69eb46b5","impliedFormat":99},{"version":"7c553fc9e34773ddbaabe0fa1367d4b109101d0868a008f11042bee24b5a925d","impliedFormat":99},{"version":"9962ce696fbdce2421d883ca4b062a54f982496625437ae4d3633376c5ad4a80","impliedFormat":99},{"version":"e3ea467c4a7f743f3548c9ed61300591965b1d12c08c8bb9aaff8a002ba95fce","impliedFormat":99},{"version":"4c17183a07a63bea2653fbfc0a942b027160ddbee823024789a415f9589de327","impliedFormat":99},{"version":"3e2203c892297ea44b87470fde51b3d48cfe3eeb6901995de429539462894464","impliedFormat":99},{"version":"c84bf7a4abc5e7fdf45971a71b25b0e0d34ccd5e720a866dd78bb71d60d41a3f","impliedFormat":99},{"version":"e01ea380015ed698c3c0e2ccd0db72f3fc3ef1abc4519f122aa1c1a8d419a505","impliedFormat":99},{"version":"5ada1f8a9580c0f7478fe03ae3e07e958f0b79bdfb9dd50eeb98c1324f40011b","impliedFormat":99},{"version":"a8301dc90b4bd9fba333226ee0f1681aeeff1bd90233a8f647e687cb4b7d3521","impliedFormat":99},{"version":"e3225dc0bec183183509d290f641786245e6652bc3dce755f7ef404060693c35","impliedFormat":99},{"version":"09a03870ed8c55d7453bc9ad684df88965f2f770f987481ca71b8a09be5205bc","impliedFormat":99},{"version":"e6233e1c976265e85aa8ad76c3881febe6264cb06ae3136f0257e1eab4a6cc5a","impliedFormat":99},{"version":"2cdd50ddc49e2d608ee848fc4ab0db9a2716624fabb4209c7c683d87e54d79c5","impliedFormat":99},{"version":"e431d664338b8470abb1750d699c7dfcebb1a25434559ef85bb96f1e82de5972","impliedFormat":99},{"version":"2c4254139d037c3caca66ce291c1308c1b5092cfcb151eb25980db932dd3b01a","impliedFormat":99},{"version":"970ae00ed018cb96352dc3f37355ef9c2d9f8aa94d7174ccd6d0ed855e462097","impliedFormat":99},{"version":"d2f8dee457ef7660b604226d471d55d927c3051766bdd80353553837492635c3","impliedFormat":99},{"version":"110a503289a2ef76141ffff3ffceb9a1c3662c32748eb9f6777a2bd0866d6fb1","impliedFormat":99},{"version":"69bf2422313487956e4dacf049f30cb91b34968912058d244cb19e4baa24da97","impliedFormat":99},{"version":"310e6b62c493ce991624169a1c1904015769d947be88dc67e00adc7ebebcfa87","impliedFormat":99},{"version":"62fefda288160bf6e435b21cc03d3fbac11193d8d3bd0e82d86623cca7691c29","impliedFormat":99},{"version":"fcc46a8bcbf9bef21023bba1995160a25f0bc590ca3563ec44c315b4f4c1b18a","impliedFormat":99},{"version":"669573548930fb7d0a0761b827e203dc623581e21febf0be80fb02414f217d74","impliedFormat":99},{"version":"f974db5be6d7428044e19c2848e72495a9b56a8d33b6fcab392e7fb5328eb8df","impliedFormat":99},{"version":"aa348c4fb2f8ac77df855f07fb66281c9f6e71746fdff3b13c7932aa7642b788","impliedFormat":99},"5280df7d3f2325c3025c27b5ba15736d6c215ccea0c57f60c679d0834fcdecc9","48fad500395915a73a595eadd107e755b66d70f2830d5eb0dd208db9577ff9ad","adf04258d9312863a5477885583144e1f23efea2bf90298a58232954bc69a281","90dfe84677196802dd3bf153534cfdf7c1440159f8a76b9e406ab090fe05c853","e5d85a429cf538383ba34a92bc13e92339aaab4e0581f0fa9d3f398c963119bf","ac0680e0ec4f6cb4235e5bc2d83cdc3b691908a28d22729c8880da6582b83f48","09fceb0b6fad0d3509e7ca4487ec33e5f458f234cc66204531f1aea2db211ffd",{"version":"6980a9dcdcba62cbfb773e35502f8b0b6a36738b6bc6b582284aa62bae85932c","signature":"a1e49cc831ae5566b5bd574bfbf6a5fa834e18a9974caf5e2eb87dd383e8bba3"},{"version":"8d9ae7c6f623073bbf6898e67b37cbb8dbfcdda4a4604684b66928f90911bbd6","signature":"044bd7687011cf038fd46364a5294abdcc82dfe9d6d49f4a8360dd90786f6d6e"},{"version":"fea0c30e0999d22ab97f466d8f599fda63300b589ace28881071fe7d38f4ee03","signature":"3ac0ea8b2bfbc75825f80930c275896442fa9ad88ef7e54d12c95422cad89400"},{"version":"d7a1a968f982d6a0c87fa38d52f8d8acdaac932706ce1c02e06bf66f449b2bb3","signature":"a2f5f877876813a1398574b00e97701c0934e12d632e3729e79daf4b62a00248"},"d87bae81e5e15c7f911dde72f0e74a40fdb79536769f74945b09f2483b60d94c","83b71271f419ce8f68ab7bb26851c74b3ef3908f4a7964454f48dd613306d175",{"version":"e633b186a9592e672adde0f080c95f6ef726129aadad1b9d4bb6e107c44b71f0","signature":"4aff941e300cf6f9cfa82b177f3cd5e03de402cb6f186229d96969e168639940"},"ab2faa87770311f4a36784df56dcf173aa367bec304e34a562b3be1ffb5f5677",{"version":"0309a01650023994ed96edbd675ea4fdc3779a823ce716ad876cc77afb792b62","impliedFormat":99},{"version":"f13d7beeea58e219daef3a40e0dc4f2bd7d9581ac04cedec236102a12dfd2090","impliedFormat":99},{"version":"48c411efce1848d1ed55de41d7deb93cbf7c04080912fd87aa517ed25ef42639","affectsGlobalScope":true,"impliedFormat":1},{"version":"a094636c05f3e75cb072684dd42cd25a4c1324bec4a866706c85c04cecd49613","affectsGlobalScope":true,"impliedFormat":99},{"version":"fe2d63fcfdde197391b6b70daf7be8c02a60afa90754a5f4a04bdc367f62793d","impliedFormat":99},{"version":"9a3e2c85ec1ab7a0874a19814cc73c691b716282cb727914093089c5a8475955","impliedFormat":99},{"version":"cbdc781d2429935c9c42acd680f2a53a9f633e8de03290ec6ea818e4f7bff19a","impliedFormat":99},{"version":"9f6d9f5dd710922f82f69abf9a324e28122b5f31ae6f6ce78427716db30a377e","impliedFormat":99},{"version":"ac2414a284bdecfd6ab7b87578744ab056cd04dd574b17853cd76830ef5b72f2","impliedFormat":99},{"version":"c3f921bbc9d2e65bd503a56fbc66da910e68467baedb0b9db0cc939e1876c0d7","impliedFormat":99},{"version":"dd51e53752b310bd20c9b1a87bbf12b1fe2be7fe40f505b43199496481096275","impliedFormat":1},{"version":"a87be4662442b3feeffc331ecafe6b36cafd08727e2d7f2425a5099577e7fd18","impliedFormat":1},{"version":"cd4cd9220a1ba793bc935e76d8e5481c110a90d9868ae7866a182ee71cdb6abb","impliedFormat":1},{"version":"0a7fb8619b10bc05fd933ca9ac1c8b2ab2220be7a57b57565c3ac158595494ef","impliedFormat":1},{"version":"c4a5f91feb9c5a6b2a91089d959c38391b79a961db3b9cc73b8877d57ad7dcdc","impliedFormat":1},{"version":"0cc99fbb161d78729d71fad66c6c363e3095862d6277160f29fa960744b785c6","affectsGlobalScope":true,"impliedFormat":99},"e375e9b02bcd1b48c258b9ec7e3c9f662ddbeadcff9cee2e15c7c70694b50d22","6bda2de4636dd89093c2d357cd75547d85b5dc723e5ee9156988fc416853adcb",{"version":"39496bfcccf6bd8fcd8ae5c95c2ced5eee2be865aca712d5bd3ed2f945c2f75e","signature":"e0004d669e42f7bad7c8145f17f16c41d3d98007ceb5262290cb87cb4c80cf05"},"cefb58ccdf600bdfc544b149c6c58f70dca68b12ca00eb10045acbba6f00155e","daa6cc015fe0773f66341a40b4052b45123c9ea9cb34bef817c1362b021c80e7",{"version":"ab9c5dff64db4c56af47db1ee8a5936ecd7fdbcd59c8d33a5c47ba006c2ab972","signature":"1aa90474779ab54be6eea477d0cd7fdf6ec0e10ade147d1981dd87576e93a50e"},"82eb3354b55f92904d5bcf4d977a7d14fb007405eef9f2575dac1aa0b6b7da33",{"version":"70f6b5dc790a2214f651ad6699aff2db1f9203e8d076ef09b308ee7613b367f1","signature":"7ed713ef190fc019f1034bffefe8458babf62f128f85c256ea1f3752a10856c4"},"8ac855b4681243043db86a808bf7b6899cbf7cd1df5445bfccec35e2199f43b0",{"version":"9716ed88e80b5b4a0507906e05475459f044781b88e92ae220a1073240de7d8a","signature":"21184e1d155c7d604d7bac17a46f67307ed3c7c0d4bd17a658bad3a685084452"},"4517b2d6dc6d1e9ba72a344271f1b419b6bbf855c4ea8e5cbbec1639a12fb723",{"version":"f302f8c411788fe53bb30c6ee76d3c14273d4cc155216d429a0b22d73fc78fa9","signature":"9c7c60a08adef3287d5062739fa09ab666d9ee86c2c9df346cf79a5c82bce3ae"},"a66650e4a02b9ca4f4c629df8254e7101a9e1b93c2476244cad6e2d7c32dd933","f4779e929d6b6a820b65ab960165e4ccb2c5349d360ea232fd82dbdf545bc65f","2f1d0c60464cd56f62f65bf578a6747152cb89e19c30f4409b1370a540dd23f1","791fcd4a998fd0f95b489f038ab41e94bea8e5b54c65bd7f1c8aca79e56bf235","0807ed8018ebc84532d72f3dd0738bef56f8e93742cf69a1bd66542b4de9d931","076567a66b90d786ef8ba5be0292a16548704ecbe16dd59b5afa0ec10eae1dc1"],"root":[387,412,457,464,465,[468,471],[488,505]],"options":{"allowJs":true,"declaration":false,"declarationMap":false,"esModuleInterop":true,"exactOptionalPropertyTypes":true,"jsx":1,"module":99,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":false,"strict":true,"target":9},"referencedMap":[[502,1],[493,2],[503,3],[505,4],[500,5],[495,6],[499,7],[501,8],[497,9],[498,10],[504,11],[496,12],[464,13],[465,14],[494,15],[468,16],[469,13],[470,14],[467,17],[466,17],[387,18],[412,19],[471,13],[488,20],[489,20],[490,21],[491,22],[492,23],[457,24],[340,17],[413,17],[119,25],[120,25],[121,26],[76,27],[122,28],[123,29],[124,30],[71,17],[74,31],[72,17],[73,17],[125,32],[126,33],[127,34],[128,35],[129,36],[130,37],[131,37],[132,38],[133,39],[134,40],[135,41],[77,17],[75,17],[136,42],[137,43],[138,44],[170,45],[139,46],[140,47],[141,48],[142,49],[143,50],[144,51],[145,52],[146,53],[147,54],[148,55],[149,55],[150,56],[151,17],[152,57],[154,58],[153,59],[155,60],[156,61],[157,62],[158,63],[159,64],[160,65],[161,66],[162,67],[163,68],[164,69],[165,70],[166,71],[167,72],[78,17],[79,17],[80,17],[118,73],[168,74],[169,75],[63,17],[175,76],[176,77],[174,9],[172,78],[173,79],[61,17],[64,80],[263,9],[474,17],[475,81],[476,82],[479,83],[478,17],[425,17],[436,84],[431,85],[434,86],[449,87],[438,17],[441,88],[440,89],[452,89],[439,90],[477,17],[433,91],[435,91],[427,92],[430,93],[446,92],[432,94],[426,17],[62,17],[420,17],[484,95],[486,96],[485,97],[483,98],[482,17],[70,99],[343,100],[347,101],[349,102],[196,103],[210,104],[314,105],[242,17],[317,106],[278,107],[287,108],[315,109],[197,110],[241,17],[243,111],[316,112],[217,113],[198,114],[222,113],[211,113],[181,113],[269,115],[270,116],[186,17],[266,117],[271,118],[358,119],[264,118],[359,120],[248,17],[267,121],[371,122],[370,123],[273,118],[369,17],[367,17],[368,124],[268,9],[255,125],[256,126],[265,127],[282,128],[283,129],[272,130],[250,131],[251,132],[362,133],[365,134],[229,135],[228,136],[227,137],[374,9],[226,138],[202,17],[377,17],[380,17],[379,9],[381,139],[177,17],[308,17],[209,140],[179,141],[331,17],[332,17],[334,17],[337,142],[333,17],[335,143],[336,143],[195,17],[208,17],[342,144],[350,145],[354,146],[191,147],[258,148],[257,17],[249,131],[277,149],[275,150],[274,17],[276,17],[281,151],[253,152],[190,153],[215,154],[305,155],[182,156],[189,157],[178,105],[319,158],[329,159],[318,17],[328,160],[216,17],[200,161],[296,162],[295,17],[302,163],[304,164],[297,165],[301,166],[303,163],[300,165],[299,163],[298,165],[238,167],[223,167],[290,168],[224,168],[184,169],[183,17],[294,170],[293,171],[292,172],[291,173],[185,174],[262,175],[279,176],[261,177],[286,178],[288,179],[285,177],[218,174],[171,17],[306,180],[244,181],[280,17],[327,182],[247,183],[322,184],[188,17],[323,185],[325,186],[326,187],[309,17],[321,156],[220,188],[307,189],[330,190],[192,17],[194,17],[199,191],[289,192],[187,193],[193,17],[246,194],[245,195],[201,196],[254,197],[252,198],[203,199],[205,200],[378,17],[204,201],[206,202],[345,17],[344,17],[346,17],[376,17],[207,203],[260,9],[69,17],[284,204],[230,17],[240,205],[219,17],[352,9],[361,206],[237,9],[356,118],[236,207],[339,208],[235,206],[180,17],[363,209],[233,9],[234,9],[225,17],[239,17],[232,210],[231,211],[221,212],[214,130],[324,17],[213,213],[212,17],[348,17],[259,9],[341,214],[60,17],[68,215],[65,9],[66,17],[67,17],[320,216],[313,217],[312,17],[311,218],[310,17],[351,219],[353,220],[355,221],[357,222],[360,223],[386,224],[364,224],[385,225],[366,226],[372,227],[373,228],[375,229],[382,230],[384,17],[383,231],[338,232],[404,233],[402,234],[403,235],[391,236],[392,234],[399,237],[390,238],[395,239],[405,17],[396,240],[401,241],[407,242],[406,243],[389,244],[397,245],[398,246],[393,247],[400,233],[394,248],[415,249],[414,250],[388,17],[410,251],[409,17],[408,17],[411,252],[450,17],[428,17],[429,253],[58,17],[59,17],[10,17],[11,17],[13,17],[12,17],[2,17],[14,17],[15,17],[16,17],[17,17],[18,17],[19,17],[20,17],[21,17],[3,17],[22,17],[23,17],[4,17],[24,17],[28,17],[25,17],[26,17],[27,17],[29,17],[30,17],[31,17],[5,17],[32,17],[33,17],[34,17],[35,17],[6,17],[39,17],[36,17],[37,17],[38,17],[40,17],[7,17],[41,17],[46,17],[47,17],[42,17],[43,17],[44,17],[45,17],[8,17],[51,17],[48,17],[49,17],[50,17],[52,17],[9,17],[53,17],[54,17],[55,17],[57,17],[56,17],[1,17],[96,254],[106,255],[95,254],[116,256],[87,257],[86,258],[115,231],[109,259],[114,260],[89,261],[103,262],[88,263],[112,264],[84,265],[83,231],[113,266],[85,267],[90,268],[91,17],[94,268],[81,17],[117,269],[107,270],[98,271],[99,272],[101,273],[97,274],[100,275],[110,231],[92,276],[93,277],[102,278],[82,279],[105,270],[104,268],[108,17],[111,280],[447,281],[444,282],[445,281],[448,283],[443,17],[424,284],[421,285],[419,286],[417,287],[416,17],[418,288],[422,17],[423,289],[456,290],[451,291],[442,292],[437,17],[480,293],[453,294],[481,295],[454,296],[473,297],[472,298],[455,299],[487,300],[458,17],[461,301],[463,302],[462,17],[459,301],[460,301]],"affectedFilesPendingEmit":[502,493,503,505,500,495,499,501,497,498,504,496,464,465,494,468,469,470,412,471,488,489,490,491,492,457],"version":"5.9.3"}
\ No newline at end of file
added apps/web/vitest.config.ts +23 −0
@@ -0,0 +1,23 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: apps/web/vitest.config.ts
6 + * Purpose: Vitest configuration for apps/web — node environment by default (jsdom via per-file pragma), excludes .next build output
7 + */
8 +
9 +import { defineConfig } from "vitest/config";
10 +import { fileURLToPath } from "node:url";
11 +
12 +export default defineConfig({
13 + resolve: {
14 + alias: {
15 + "@": fileURLToPath(new URL(".", import.meta.url)),
16 + },
17 + },
18 + test: {
19 + environment: "node",
20 + include: ["test/**/*.test.ts"],
21 + exclude: ["**/node_modules/**", "**/.next/**", "**/dist/**"],
22 + },
23 +});
added earth-now-metrics-catalog.md +234 −0
@@ -0,0 +1,234 @@
1 +# 🌍 earth-now.co — Catalogue légendaire des métriques live
2 +
3 +Registre complet des métriques du produit, organisé par domaine. Pour chaque métrique : **Type** (Stock = valeur instantanée / Cumul = compteur annuel ou "aujourd'hui" qui se remet à zéro / Taux = valeur par unité de temps / Événement = temps réel réel, sans interpolation), **Niveau de modèle** (L0 linéaire, L1 saisonnier, L2 fusion observation+forecast, RT = temps réel événementiel), source principale et cadence de publication.
4 +
5 +Légende priorité : 🥇 MVP · 🥈 V1 · 🥉 V2+
6 +
7 +---
8 +
9 +## 1. 👶 Population & démographie
10 +
11 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
12 +|---|---|---|---|---|---|---|
13 +| Population mondiale | personnes | Stock | L2 | UN WPP | annuelle (+ variantes low/med/high) | 🥇 |
14 +| Naissances aujourd'hui | personnes | Cumul (jour) | L1 (saisonnalité mensuelle + heure) | UN WPP dérivé | annuelle | 🥇 |
15 +| Naissances cette année | personnes | Cumul (an) | L1 | UN WPP | annuelle | 🥇 |
16 +| Décès aujourd'hui / cette année | personnes | Cumul | L1 | UN WPP | annuelle | 🥇 |
17 +| Croissance nette aujourd'hui | personnes | Cumul dérivé | L1 | dérivé naissances−décès | — | 🥇 |
18 +| Population par continent (6 compteurs) | personnes | Stock | L2 | UN WPP | annuelle | 🥈 |
19 +| Top 10 pays par population (live) | personnes | Stock | L2 | UN WPP | annuelle | 🥈 |
20 +| Âge médian mondial | années | Stock | L2 | UN WPP | annuelle | 🥉 |
21 +| Personnes de 100 ans et plus | personnes | Stock | L2 | UN WPP | annuelle | 🥉 |
22 +| Population urbaine vs rurale | personnes + % | Stock | L2 | UN DESA | annuelle | 🥈 |
23 +| Habitants des mégapoles (top 20 villes) | personnes | Stock | L2 | UN DESA | annuelle | 🥉 |
24 +| Migrants internationaux (stock) | personnes | Stock | L0 | UN DESA / IOM | ~2 ans | 🥉 |
25 +| Espérance de vie mondiale (live, interpolée) | années | Stock | L2 | UN WPP | annuelle | 🥈 |
26 +
27 +## 2. 🏥 Santé
28 +
29 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
30 +|---|---|---|---|---|---|---|
31 +| Décès par maladies cardiovasculaires cette année | personnes | Cumul | L1 | IHME GBD / OMS | annuelle | 🥈 |
32 +| Décès par cancer cette année | personnes | Cumul | L1 | IHME GBD | annuelle | 🥈 |
33 +| Décès liés au tabac cette année | personnes | Cumul | L0 | OMS | annuelle | 🥈 |
34 +| Décès liés à l'alcool cette année | personnes | Cumul | L0 | OMS | annuelle | 🥉 |
35 +| Décès par paludisme cette année | personnes | Cumul | L1 (forte saisonnalité) | OMS World Malaria Report | annuelle | 🥈 |
36 +| Décès d'enfants < 5 ans cette année | personnes | Cumul | L1 | UN IGME | annuelle | 🥈 |
37 +| Décès maternels cette année | personnes | Cumul | L0 | OMS | annuelle | 🥉 |
38 +| Décès par accidents de la route cette année | personnes | Cumul | L1 | OMS | ~3 ans | 🥈 |
39 +| Suicides cette année | personnes | Cumul | L0 | OMS | annuelle | 🥉 |
40 +| Décès par VIH/sida cette année | personnes | Cumul | L0 | ONUSIDA | annuelle | 🥉 |
41 +| Personnes vivant avec le VIH | personnes | Stock | L2 | ONUSIDA | annuelle | 🥉 |
42 +| Vaccins administrés cette année (routine) | doses | Cumul | L0 | OMS/UNICEF WUENIC | annuelle | 🥉 |
43 +| Dépenses de santé mondiales cette année | USD | Cumul | L0 | OMS GHED | annuelle | 🥉 |
44 +| Cigarettes fumées aujourd'hui | unités | Cumul | L1 | dérivé OMS/Euromonitor | annuelle | 🥈 |
45 +
46 +⚠️ Règle éditoriale : les compteurs de mortalité affichent toujours la source + méthode au clic ; ton sobre, pas de gamification.
47 +
48 +## 3. 🌡️ Climat & atmosphère
49 +
50 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
51 +|---|---|---|---|---|---|---|
52 +| Concentration CO₂ (Mauna Loa) | ppm | Stock | **L2 vedette** (tendance + courbe de Keeling) | NOAA GML | quotidienne/hebdo | 🥇 |
53 +| Concentration CH₄ (méthane) | ppb | Stock | L2 | NOAA GML | mensuelle | 🥈 |
54 +| Concentration N₂O | ppb | Stock | L2 | NOAA GML | mensuelle | 🥉 |
55 +| Anomalie de température globale (vs 1850-1900) | °C | Stock | L2 | Copernicus ERA5 / Berkeley Earth | mensuelle | 🥇 |
56 +| Anomalie du jour (ERA5 daily) | °C | Stock | L2 | Climate Reanalyzer / C3S | quotidienne | 🥈 |
57 +| "Budget carbone 1,5 °C" restant | t CO₂ | Cumul inversé (décompte) | L2 | IPCC AR6 + GCP | annuelle | 🥇 |
58 +| Temps restant avant épuisement du budget 1,5 °C | années/jours | Dérivé | L2 | dérivé | — | 🥇 |
59 +| Forçage radiatif total | W/m² | Stock | L0 | NOAA AGGI | annuelle | 🥉 |
60 +| Jours records de chaleur cette année (stations) | jours | Cumul | RT-ish | agrégateurs météo | quotidienne | 🥉 |
61 +
62 +## 4. 🌊 Océans & cryosphère
63 +
64 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
65 +|---|---|---|---|---|---|---|
66 +| Élévation du niveau de la mer (depuis 1993) | mm | Stock | L2 | NASA/AVISO altimétrie | mensuelle | 🥇 |
67 +| Température de surface des océans (SST globale) | °C | Stock | L2 | NOAA OISST | quotidienne | 🥈 |
68 +| Contenu thermique des océans | ZJ | Stock | L2 | NOAA/IAP | trimestrielle | 🥉 |
69 +| Étendue de la banquise arctique | M km² | Stock | L1 (très saisonnier) | NSIDC | quotidienne | 🥇 |
70 +| Étendue de la banquise antarctique | M km² | Stock | L1 | NSIDC | quotidienne | 🥈 |
71 +| Perte de masse Groenland (cette année) | Gt | Cumul | L2 | NASA GRACE-FO / PROMICE | mensuelle | 🥈 |
72 +| Perte de masse Antarctique (cette année) | Gt | Cumul | L2 | NASA GRACE-FO | mensuelle | 🥈 |
73 +| Acidification (pH océanique) | pH | Stock | L0 | NOAA PMEL | annuelle | 🥉 |
74 +| Plastique déversé dans l'océan cette année | tonnes | Cumul | L0 | Jambeck/OWID/UNEP | ~annuelle (large IC) | 🥈 |
75 +
76 +## 5. 🌳 Forêts, terres & biodiversité
77 +
78 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
79 +|---|---|---|---|---|---|---|
80 +| Forêt perdue cette année (brut) | hectares | Cumul | L1 (saison sèche/feux) | Global Forest Watch / GLAD | hebdo (alertes) + annuelle | 🥇 |
81 +| Forêt perdue aujourd'hui | hectares | Cumul (jour) | L1 | GFW | hebdo | 🥇 |
82 +| Équivalent terrains de football / seconde | terrains | Dérivé | L1 | dérivé GFW | — | 🥇 |
83 +| Déforestation Amazonie (alertes DETER) | km² | Cumul | RT-ish | INPE | mensuelle | 🥈 |
84 +| Surface brûlée cette année (feux) | hectares | Cumul | L1 + RT (FIRMS) | NASA FIRMS / GWIS | quotidienne | 🥈 |
85 +| Feux actifs détectés (dernières 24 h) | détections | Événement | RT | NASA FIRMS | 3–6 h | 🥈 |
86 +| Terres arables perdues (érosion) cette année | hectares | Cumul | L0 | FAO/UNCCD | annuelle | 🥉 |
87 +| Désertification cette année | hectares | Cumul | L0 | UNCCD | annuelle | 🥉 |
88 +| Espèces éteintes cette année (estimation) | espèces | Cumul | L0 (large IC, affiché) | IUCN + littérature | annuelle | 🥉 |
89 +| Indice Planète Vivante (populations de vertébrés) | % vs 1970 | Stock | L0 | WWF/ZSL | 2 ans | 🥉 |
90 +| Arbres plantés cette année (campagnes déclarées) | arbres | Cumul | L0 | Trillion Trees / one.org APIs | variable | 🥉 |
91 +
92 +## 6. ⚡ Énergie
93 +
94 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
95 +|---|---|---|---|---|---|---|
96 +| Électricité produite aujourd'hui (monde) | TWh | Cumul | L1 (jour/nuit, saison) | Ember / IEA | mensuelle→annuelle | 🥇 |
97 +| Part renouvelable de l'électricité (live estimé) | % | Stock | L1 | Ember + grilles temps réel | mensuelle | 🥇 |
98 +| Électricité solaire aujourd'hui | TWh | Cumul | **L1 vedette** (cycle solaire diurne) | Ember + modèles | mensuelle | 🥈 |
99 +| Électricité éolienne aujourd'hui | TWh | Cumul | L1 | Ember | mensuelle | 🥈 |
100 +| Charbon brûlé aujourd'hui | tonnes | Cumul | L1 | IEA/EI Statistical Review | annuelle | 🥇 |
101 +| Pétrole pompé aujourd'hui | barils | Cumul | L1 faible | EIA/OPEC | mensuelle | 🥇 |
102 +| Gaz naturel extrait aujourd'hui | m³ | Cumul | L1 | EI/EIA | annuelle | 🥈 |
103 +| Capacité solaire installée (cumul mondial) | GW | Stock | L2 (croissance quasi-exponentielle : fit log) | IRENA/BNEF | annuelle | 🥈 |
104 +| Capacité éolienne installée | GW | Stock | L2 | IRENA/GWEC | annuelle | 🥈 |
105 +| Batteries installées (stockage) | GWh | Stock | L2 | BNEF/IEA | annuelle | 🥉 |
106 +| Véhicules électriques vendus cette année | véhicules | Cumul | L1 | IEA Global EV Outlook | annuelle | 🥈 |
107 +| Énergie consommée par le Bitcoin aujourd'hui | GWh | Cumul | L0 | CBECI (Cambridge) | continue | 🥉 |
108 +| Jours d'"énergie du soleil reçue par la Terre" vs conso humaine annuelle | ratio | Dérivé pédagogique | L0 | dérivé | — | 🥉 |
109 +
110 +## 7. 🏭 Émissions & carbone
111 +
112 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
113 +|---|---|---|---|---|---|---|
114 +| CO₂ émis cette année (fossile + ciment) | tonnes | Cumul | **L2 vedette** | Global Carbon Project | annuelle + nowcast | 🥇 |
115 +| CO₂ émis aujourd'hui | tonnes | Cumul (jour) | L1 | GCP dérivé | — | 🥇 |
116 +| CO₂ émis depuis 1750 (dette historique) | Gt | Stock cumulatif | L0 | GCP/OWID | annuelle | 🥈 |
117 +| CO₂ par seconde (taux affiché) | t/s | Taux | L1 | dérivé | — | 🥇 |
118 +| Méthane émis cette année | t CH₄ | Cumul | L0 | Global Methane Budget | ~2 ans | 🥉 |
119 +| Émissions par top-5 pays (live) | tonnes | Cumul | L1 | GCP/EDGAR | annuelle | 🥈 |
120 +| CO₂ de l'aviation aujourd'hui | tonnes | Cumul | L1 + RT proxy (trafic) | ICAO + OpenSky proxy | annuelle | 🥉 |
121 +| Crédit compensation : arbres nécessaires pour absorber le CO₂ du jour | arbres | Dérivé pédagogique | L1 | dérivé | — | 🥉 |
122 +
123 +## 8. 💧 Eau & alimentation
124 +
125 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
126 +|---|---|---|---|---|---|---|
127 +| Eau douce consommée cette année | milliards m³ | Cumul | L1 (irrigation saisonnière) | FAO AQUASTAT | annuelle | 🥈 |
128 +| Personnes sans accès à l'eau potable | personnes | Stock | L2 (tendance décroissante) | JMP OMS/UNICEF | annuelle | 🥇 |
129 +| Personnes sous-alimentées | personnes | Stock | L2 | FAO SOFI | annuelle | 🥇 |
130 +| Personnes en situation d'obésité | personnes | Stock | L2 | OMS/NCD-RisC | annuelle | 🥈 |
131 +| Nourriture produite cette année | tonnes | Cumul | L1 (récoltes) | FAO | annuelle | 🥈 |
132 +| Nourriture gaspillée cette année | tonnes | Cumul | L0 | UNEP Food Waste Index | ~2 ans | 🥇 |
133 +| Repas gaspillés pendant que X personnes ont faim (juxtaposition) | dérivé | Dérivé éditorial | — | dérivé | — | 🥈 |
134 +| Animaux terrestres abattus cette année | animaux | Cumul | L1 | FAOSTAT | annuelle | 🥈 |
135 +| Poissons pêchés cette année | tonnes | Cumul | L1 | FAO SOFIA | annuelle | 🥉 |
136 +| Café bu aujourd'hui (tasses) | tasses | Cumul | L1 | ICO dérivé | annuelle | 🥉 |
137 +
138 +## 9. 💰 Économie & consommation
139 +
140 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
141 +|---|---|---|---|---|---|---|
142 +| PIB mondial produit cette année | USD | Cumul | L2 (prévisions FMI) | FMI WEO | 2×/an | 🥈 |
143 +| Dépenses militaires cette année | USD | Cumul | L0 | SIPRI | annuelle | 🥈 |
144 +| Dette publique mondiale | USD | Stock | L2 | FMI | 2×/an | 🥉 |
145 +| Personnes en extrême pauvreté (<2,15 $/j) | personnes | Stock | L2 | Banque mondiale PIP | annuelle | 🥇 |
146 +| Voitures produites cette année | véhicules | Cumul | L1 | OICA | annuelle | 🥉 |
147 +| Smartphones vendus cette année | unités | Cumul | L1 (pic T4) | IDC/Counterpoint | trimestrielle | 🥉 |
148 +| Vêtements produits cette année | unités | Cumul | L0 | Ellen MacArthur/McKinsey | ponctuelle (large IC) | 🥉 |
149 +| Ciment produit cette année | tonnes | Cumul | L1 | USGS | annuelle | 🥉 |
150 +| Acier produit cette année | tonnes | Cumul | L1 | worldsteel | mensuelle | 🥉 |
151 +| E-déchets générés cette année | tonnes | Cumul | L0 | Global E-waste Monitor | ~2 ans | 🥈 |
152 +
153 +## 10. 📱 Technologie & internet
154 +
155 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
156 +|---|---|---|---|---|---|---|
157 +| Utilisateurs d'internet dans le monde | personnes | Stock | L2 | ITU | annuelle | 🥇 |
158 +| Emails envoyés aujourd'hui | emails | Cumul | L1 (cycle jour + semaine) | Radicati | annuelle | 🥈 |
159 +| Recherches Google aujourd'hui | recherches | Cumul | L1 | estimations publiques | ponctuelle | 🥈 |
160 +| Vidéos vues (heures) aujourd'hui | heures | Cumul | L1 | estimations sectorielles | ponctuelle | 🥉 |
161 +| Données créées aujourd'hui | zettaoctets | Cumul | L2 (exponentiel) | IDC DataSphere | annuelle | 🥉 |
162 +| Sites web actifs | sites | Stock | L2 | Netcraft | mensuelle | 🥉 |
163 +| Appareils IoT connectés | appareils | Stock | L2 | IoT Analytics | annuelle | 🥉 |
164 +| Requêtes IA (LLM) aujourd'hui — estimation, large IC | requêtes | Cumul | L0 (IC affiché) | estimations publiques | ponctuelle | 🥉 |
165 +| Électricité des data centers aujourd'hui | GWh | Cumul | L1 | IEA | annuelle | 🥈 |
166 +
167 +## 11. 🕊️ Société, éducation & conflits
168 +
169 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
170 +|---|---|---|---|---|---|---|
171 +| Personnes déplacées de force (réfugiés + déplacés internes) | personnes | Stock | L2 | UNHCR | 2×/an | 🥈 |
172 +| Décès dans les conflits armés cette année | personnes | Cumul | L0 + révisions | ACLED/UCDP | hebdo/mensuelle | 🥉 (éditorial délicat) |
173 +| Enfants non scolarisés | personnes | Stock | L2 | UNESCO UIS | annuelle | 🥈 |
174 +| Adultes analphabètes | personnes | Stock | L2 | UNESCO | annuelle | 🥉 |
175 +| Livres publiés cette année | titres | Cumul | L0 | UNESCO/IPA | annuelle | 🥉 |
176 +| Argent envoyé (remittances) cette année | USD | Cumul | L1 | Banque mondiale | 2×/an | 🥉 |
177 +
178 +## 12. ⚡ Temps réel événementiel (pas d'interpolation — flux RT vrais)
179 +
180 +| Métrique | Unité | Type | Modèle | Source | Latence | Prio |
181 +|---|---|---|---|---|---|---|
182 +| Séismes (dernier + carte + compteur 24 h, M≥2.5) | événements | Événement | **RT vedette** | USGS FDSN API | minutes | 🥇 |
183 +| Dernier séisme majeur (M≥5) : bannière | — | Événement | RT | USGS | minutes | 🥇 |
184 +| Avions en vol en ce moment | avions | Stock RT | RT | OpenSky Network / ADS-B | 10–60 s | 🥈 |
185 +| Navires en mer (AIS) | navires | Stock RT | RT | AIS agrégé | minutes | 🥉 |
186 +| Éclairs détectés (dernière heure) | impacts | Événement | RT | Blitzortung | secondes | 🥉 |
187 +| Éruptions volcaniques en cours | volcans | Stock RT | RT | Smithsonian GVP | hebdo | 🥉 |
188 +| Cyclones/tempêtes nommées actives | tempêtes | Stock RT | RT | NOAA NHC/JTWC | horaire | 🥈 |
189 +| Kp index (météo spatiale / aurores) | Kp | Stock RT | RT | NOAA SWPC | 3 h | 🥉 |
190 +
191 +## 13. 🚀 Espace & Terre physique
192 +
193 +| Métrique | Unité | Type | Modèle | Source | Cadence | Prio |
194 +|---|---|---|---|---|---|---|
195 +| Humains dans l'espace en ce moment | personnes | Stock RT | RT | API whoisinspace / manuel | événementiel | 🥇 (fun) |
196 +| Satellites actifs en orbite | satellites | Stock | L2 | UCS/CelesTrak | mensuelle | 🥈 |
197 +| Débris spatiaux suivis | objets | Stock | L2 | ESA/Space-Track | mensuelle | 🥉 |
198 +| Lancements orbitaux cette année | lancements | Cumul | RT-ish | manuel/API | événementiel | 🥈 |
199 +| Position de l'ISS (live) | lat/lon | Stock RT | RT (propagation TLE côté client !) | CelesTrak TLE | quotidienne (TLE) | 🥈 |
200 +| Distance parcourue par la Terre autour du Soleil aujourd'hui | km | Cumul | Déterministe (astronomie pure) | calcul | — | 🥇 (fun) |
201 +| Rotation : temps restant avant minuit UTC partout | — | Dérivé | Déterministe | calcul | — | 🥉 |
202 +| Jours avant le prochain Earth Overshoot Day | jours | Décompte | L0 | Global Footprint Network | annuelle | 🥇 |
203 +
204 +## 14. 🎭 Méta-compteurs & juxtapositions (signature éditoriale du site)
205 +
206 +Compteurs dérivés qui croisent deux métriques pour créer du sens — c'est notre signature :
207 +
208 +| Métrique | Construction | Prio |
209 +|---|---|---|
210 +| "Depuis que vous avez ouvert cette page…" (naissances, CO₂, forêt, plastique, argent militaire dépensé) | tous les taux × durée de session | 🥇 **vedette absolue** |
211 +| Coût de X secondes de dépenses militaires en repas scolaires | SIPRI ÷ PAM coût/repas | 🥈 |
212 +| CO₂ émis vs capacité d'absorption des forêts (course visuelle) | GCP vs puits terrestres | 🥈 |
213 +| Solaire installé pendant que vous lisez | IRENA taux | 🥈 |
214 +| 1 espèce s'éteint pendant que N vidéos de chats sont vues | juxtaposition assumée | 🥉 |
215 +| Votre part personnelle : CO₂ mondial ÷ population, live | dérivé | 🥉 |
216 +
217 +## 15. ⏱️ Mécaniques transverses de compteurs
218 +
219 +- **Résets** : chaque Cumul existe en 3 fenêtres : *aujourd'hui* (reset minuit UTC), *cette année* (reset 1er janv UTC), *depuis votre arrivée* (session). Le widget embarquable laisse choisir la fenêtre.
220 +- **Vitesses affichables** : chaque métrique expose son taux courant (par seconde / minute / heure) en plus du cumul.
221 +- **Records** : min/max historiques stockés et affichables (ex. record de banquise minimale).
222 +- **Sparklines** : chaque compteur a un mini-graphe 30 j / 1 an / max intégré au widget.
223 +
224 +---
225 +
226 +## Priorisation résumée
227 +
228 +- **MVP (≈22 compteurs 🥇)** : population (5), CO₂ ppm + émissions (4), température, budget carbone (2), forêt (3), banquise arctique, niveau de la mer, énergie (3), pauvreté/eau/faim (3), séismes RT, humains dans l'espace, Overshoot Day, "depuis votre arrivée".
229 +- **V1 (🥈)** : santé, océans détaillés, énergie détaillée, tech, société — ~40 compteurs.
230 +- **V2+ (🥉)** : longue traîne, RT exotiques (éclairs, AIS), méta-compteurs additionnels.
231 +
232 +## Règles du registre (rappel CLAUDE.md)
233 +
234 +Chaque métrique ci-dessus doit être déclarée dans `packages/registry/metrics/*.yaml` avec : source(s) + licence vérifiée, cadence, niveau de modèle, contraintes (monotonie, bornes de taux), sigFigs honnêtes, et fenêtres de reset. Les métriques à large incertitude (espèces, vêtements, requêtes IA) affichent **obligatoirement** leur intervalle et la mention "estimation" — c'est ce qui nous distingue de Worldometer.
added infra/deploy-m3u96b-docker.sh +63 −0
@@ -0,0 +1,63 @@
1 +#!/usr/bin/env bash
2 +# earth-now.co
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +# File: infra/deploy-m3u96b-docker.sh
6 +# Purpose: Deploy to production node m3u96b — rsync, docker compose up, then MANDATORY health checks against www.earth-now.co
7 +
8 +set -euo pipefail
9 +
10 +REMOTE="m3u96b"
11 +REMOTE_DIR="~/earth-now"
12 +BASE_URL="https://www.earth-now.co"
13 +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14 +
15 +ok() { printf '✓ %s\n' "$1"; }
16 +fail() { printf '✗ %s\n' "$1"; }
17 +
18 +echo "== earth-now deploy → ${REMOTE} =="
19 +
20 +# --- 1. Sync sources (build happens on the node inside Docker) ---------------
21 +echo "-- rsync ${REPO_ROOT}/ → ${REMOTE}:${REMOTE_DIR}/"
22 +rsync -az --delete \
23 + --exclude "node_modules" \
24 + --exclude ".next" \
25 + --exclude "dist" \
26 + --exclude ".turbo" \
27 + --exclude ".git" \
28 + --exclude "data/" \
29 + "${REPO_ROOT}/" "${REMOTE}:${REMOTE_DIR}/"
30 +ok "rsync complete"
31 +
32 +# --- 2. Build & (re)start the stack -------------------------------------------
33 +echo "-- docker compose up -d --build on ${REMOTE}"
34 +ssh "${REMOTE}" "cd ${REMOTE_DIR} && docker compose -f infra/docker-compose.prod.yml up -d --build"
35 +ok "docker compose up"
36 +
37 +# --- 3. Health checks (per CLAUDE.md the deploy is NOT done until both pass) --
38 +FAILED=0
39 +
40 +echo "-- health check: GET ${BASE_URL}/api/health"
41 +if curl -fsS --max-time 15 "${BASE_URL}/api/health" > /dev/null; then
42 + ok "api health returned 200"
43 +else
44 + fail "api health check failed (${BASE_URL}/api/health)"
45 + FAILED=1
46 +fi
47 +
48 +echo "-- health check: hold SSE ${BASE_URL}/sse/health ≥ 10 s"
49 +SSE_START=$(date +%s)
50 +SSE_OUTPUT="$(curl -sN --max-time 12 "${BASE_URL}/sse/health" | head -c 100 || true)"
51 +SSE_ELAPSED=$(( $(date +%s) - SSE_START ))
52 +if [ -n "${SSE_OUTPUT}" ]; then
53 + ok "SSE stream produced output (held ~${SSE_ELAPSED}s, first bytes: $(printf '%s' "${SSE_OUTPUT}" | head -c 40 | tr -d '\n'))"
54 +else
55 + fail "SSE health check produced no output after ${SSE_ELAPSED}s (${BASE_URL}/sse/health)"
56 + FAILED=1
57 +fi
58 +
59 +if [ "${FAILED}" -ne 0 ]; then
60 + fail "deploy NOT healthy — investigate before declaring the deploy done"
61 + exit 1
62 +fi
63 +ok "deploy to ${REMOTE} complete and healthy → ${BASE_URL}"
added infra/deploy-m3u96b.sh +67 −0
@@ -0,0 +1,67 @@
1 +#!/usr/bin/env bash
2 +# earth-now.co
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +# File: infra/deploy-m3u96b.sh
6 +# Purpose: Deploy to production node m3u96b via PM2 + ngrok (node's standard pattern; Docker variant in deploy-m3u96b-docker.sh), then MANDATORY health checks
7 +
8 +set -euo pipefail
9 +
10 +REMOTE="M3U96b"
11 +REMOTE_DIR="apps/earth-now"
12 +BASE_URL="https://www.earth-now.co"
13 +API_PORT=4000
14 +WEB_PORT=4100
15 +PROXY_PORT=8080
16 +
17 +ROOT="$(cd "$(dirname "$0")/.." && pwd)"
18 +
19 +echo "==> rsync source to ${REMOTE}:~/${REMOTE_DIR}"
20 +rsync -az --delete \
21 + --exclude node_modules --exclude .next --exclude dist --exclude .turbo \
22 + --exclude .git --exclude data --exclude .env \
23 + "${ROOT}/" "${REMOTE}:${REMOTE_DIR}/"
24 +
25 +echo "==> install + build on ${REMOTE}"
26 +# NEXT_PUBLIC_API_URL="" is inlined at build time → client fetches stay same-origin
27 +# (routed by infra/proxy.mjs behind the single ngrok domain).
28 +ssh "${REMOTE}" "cd ${REMOTE_DIR} && pnpm install --frozen-lockfile=false && NEXT_PUBLIC_API_URL='' pnpm build"
29 +
30 +echo "==> (re)start PM2 processes"
31 +ssh "${REMOTE}" bash -s <<EOF
32 +set -euo pipefail
33 +cd ${REMOTE_DIR}
34 +pm2 delete earthnow-api earthnow-web earthnow-proxy earthnow-ngrok 2>/dev/null || true
35 +PORT=${API_PORT} pm2 start /opt/homebrew/bin/node --name earthnow-api --interpreter none -- apps/api/dist/server.js
36 +cd apps/web && API_URL=http://127.0.0.1:${API_PORT} pm2 start ./node_modules/.bin/next --name earthnow-web --interpreter none -- start -H 127.0.0.1 -p ${WEB_PORT} && cd ../..
37 +PROXY_PORT=${PROXY_PORT} API_PORT=${API_PORT} WEB_PORT=${WEB_PORT} pm2 start /opt/homebrew/bin/node --name earthnow-proxy --interpreter none -- infra/proxy.mjs
38 +pm2 start /opt/homebrew/bin/ngrok --name earthnow-ngrok --interpreter none -- http --url=www.earth-now.co ${PROXY_PORT}
39 +pm2 save
40 +EOF
41 +
42 +echo "==> health checks (deploy is NOT done until both pass)"
43 +sleep 8
44 +
45 +ok=0
46 +if curl -fsS --max-time 15 "${BASE_URL}/api/health" | grep -q '"status":"ok"'; then
47 + echo " ✓ ${BASE_URL}/api/health"
48 +else
49 + echo " ✗ ${BASE_URL}/api/health FAILED"
50 + ok=1
51 +fi
52 +
53 +# /sse/health must hold an SSE connection open >= 10 s (CLAUDE.md requirement).
54 +# curl exiting 28 (max-time reached) is the EXPECTED outcome — the stream never ends.
55 +sse_bytes=$( (curl -sN --max-time 12 "${BASE_URL}/sse/health" || true) | head -c 200 | wc -c | tr -d ' ')
56 +if [ "${sse_bytes}" -gt 0 ]; then
57 + echo " ✓ ${BASE_URL}/sse/health held an SSE stream (received ${sse_bytes} bytes over ~12 s)"
58 +else
59 + echo " ✗ ${BASE_URL}/sse/health FAILED (no SSE bytes received)"
60 + ok=1
61 +fi
62 +
63 +if [ "${ok}" -ne 0 ]; then
64 + echo "DEPLOY FAILED — health checks did not pass."
65 + exit 1
66 +fi
67 +echo "==> DEPLOY OK — ${BASE_URL} is live."
added infra/docker-compose.prod.yml +93 −0
@@ -0,0 +1,93 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: infra/docker-compose.prod.yml
5 +# Purpose: Production stack on node m3u96b — web, api, ingest, TimescaleDB, Redis, nginx proxy (ngrok fronts port 8080)
6 +#
7 +# Secrets come from a `.env` file next to this compose file (or the shell env).
8 +# NEVER commit tokens or passwords — POSTGRES_PASSWORD and NGROK_AUTHTOKEN live in the
9 +# environment / secret manager only.
10 +
11 +services:
12 + web:
13 + build:
14 + context: ..
15 + dockerfile: infra/docker/web.Dockerfile
16 + restart: unless-stopped
17 + environment:
18 + NODE_ENV: production
19 + PORT: "3000"
20 + API_ORIGIN: http://api:4000
21 + expose:
22 + - "3000"
23 + depends_on:
24 + - api
25 +
26 + api:
27 + build:
28 + context: ..
29 + dockerfile: infra/docker/api.Dockerfile
30 + restart: unless-stopped
31 + environment:
32 + NODE_ENV: production
33 + PORT: "4000"
34 + DATABASE_URL: postgres://earthnow:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}@db:5432/earthnow
35 + REDIS_URL: redis://redis:6379
36 + expose:
37 + - "4000"
38 + depends_on:
39 + - db
40 + - redis
41 +
42 + ingest:
43 + build:
44 + context: ..
45 + dockerfile: infra/docker/ingest.Dockerfile
46 + restart: unless-stopped
47 + environment:
48 + NODE_ENV: production
49 + DATABASE_URL: postgres://earthnow:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}@db:5432/earthnow
50 + REDIS_URL: redis://redis:6379
51 + RAW_ARCHIVE_DIR: /data/raw
52 + volumes:
53 + # We keep ALL raw ingested data for re-fitting (S3 later).
54 + - raw_archive:/data/raw
55 + depends_on:
56 + - db
57 + - redis
58 +
59 + db:
60 + image: timescale/timescaledb:latest-pg16
61 + restart: unless-stopped
62 + environment:
63 + POSTGRES_USER: earthnow
64 + POSTGRES_DB: earthnow
65 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
66 + volumes:
67 + - db_data:/var/lib/postgresql/data
68 + # Bootstrap schema on first start; later migrations run via `pnpm db:migrate`.
69 + - ./migrations:/docker-entrypoint-initdb.d:ro
70 + expose:
71 + - "5432"
72 +
73 + redis:
74 + image: redis:7-alpine
75 + restart: unless-stopped
76 + expose:
77 + - "6379"
78 +
79 + proxy:
80 + image: nginx:alpine
81 + restart: unless-stopped
82 + ports:
83 + # ngrok (reserved domain www.earth-now.co) fronts this port on the host.
84 + - "8080:80"
85 + volumes:
86 + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
87 + depends_on:
88 + - web
89 + - api
90 +
91 +volumes:
92 + db_data:
93 + raw_archive:
added infra/docker/api.Dockerfile +30 −0
@@ -0,0 +1,30 @@
1 +# syntax=docker/dockerfile:1
2 +# Build from the repo root: docker build -f infra/docker/api.Dockerfile .
3 +
4 +FROM node:20-alpine AS base
5 +RUN corepack enable
6 +WORKDIR /app
7 +
8 +# ---- Install workspace dependencies (cached until a package.json changes) ----
9 +FROM base AS deps
10 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.json ./
11 +COPY apps/api/package.json apps/api/
12 +COPY apps/web/package.json apps/web/
13 +COPY apps/ingest/package.json apps/ingest/
14 +COPY packages/counter/package.json packages/counter/
15 +COPY packages/models/package.json packages/models/
16 +COPY packages/registry/package.json packages/registry/
17 +COPY packages/widget/package.json packages/widget/
18 +RUN pnpm install --frozen-lockfile
19 +
20 +# ---- Build the api and its workspace dependencies ----------------------------
21 +FROM deps AS build
22 +COPY . .
23 +RUN pnpm turbo build --filter=@earth-now/api...
24 +
25 +# ---- Runtime ------------------------------------------------------------------
26 +FROM base AS runtime
27 +ENV NODE_ENV=production
28 +COPY --from=build /app /app
29 +EXPOSE 4000
30 +CMD ["node", "apps/api/dist/index.js"]
added infra/docker/ingest.Dockerfile +31 −0
@@ -0,0 +1,31 @@
1 +# syntax=docker/dockerfile:1
2 +# Build from the repo root: docker build -f infra/docker/ingest.Dockerfile .
3 +
4 +FROM node:20-alpine AS base
5 +RUN corepack enable
6 +WORKDIR /app
7 +
8 +# ---- Install workspace dependencies (cached until a package.json changes) ----
9 +FROM base AS deps
10 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.json ./
11 +COPY apps/api/package.json apps/api/
12 +COPY apps/web/package.json apps/web/
13 +COPY apps/ingest/package.json apps/ingest/
14 +COPY packages/counter/package.json packages/counter/
15 +COPY packages/models/package.json packages/models/
16 +COPY packages/registry/package.json packages/registry/
17 +COPY packages/widget/package.json packages/widget/
18 +RUN pnpm install --frozen-lockfile
19 +
20 +# ---- Build the ingest workers and their workspace dependencies ---------------
21 +FROM deps AS build
22 +COPY . .
23 +RUN pnpm turbo build --filter=@earth-now/ingest...
24 +
25 +# ---- Runtime ------------------------------------------------------------------
26 +FROM base AS runtime
27 +ENV NODE_ENV=production
28 +# Raw archive mount point (compose maps the raw_archive volume here).
29 +RUN mkdir -p /data/raw
30 +COPY --from=build /app /app
31 +CMD ["node", "apps/ingest/dist/index.js"]
added infra/docker/web.Dockerfile +30 −0
@@ -0,0 +1,30 @@
1 +# syntax=docker/dockerfile:1
2 +# Build from the repo root: docker build -f infra/docker/web.Dockerfile .
3 +
4 +FROM node:20-alpine AS base
5 +RUN corepack enable
6 +WORKDIR /app
7 +
8 +# ---- Install workspace dependencies (cached until a package.json changes) ----
9 +FROM base AS deps
10 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.json ./
11 +COPY apps/api/package.json apps/api/
12 +COPY apps/web/package.json apps/web/
13 +COPY apps/ingest/package.json apps/ingest/
14 +COPY packages/counter/package.json packages/counter/
15 +COPY packages/models/package.json packages/models/
16 +COPY packages/registry/package.json packages/registry/
17 +COPY packages/widget/package.json packages/widget/
18 +RUN pnpm install --frozen-lockfile
19 +
20 +# ---- Build the Next.js app and its workspace dependencies --------------------
21 +FROM deps AS build
22 +COPY . .
23 +RUN pnpm turbo build --filter=@earth-now/web...
24 +
25 +# ---- Runtime ------------------------------------------------------------------
26 +FROM base AS runtime
27 +ENV NODE_ENV=production
28 +COPY --from=build /app /app
29 +EXPOSE 3000
30 +CMD ["pnpm", "--filter", "@earth-now/web", "start"]
added infra/migrations/001_initial.sql +89 −0
@@ -0,0 +1,89 @@
1 +-- earth-now.co
2 +-- Author: Simon-Pierre Boucher
3 +-- Contact: contact@spboucher.ai
4 +-- File: infra/migrations/001_initial.sql
5 +-- Purpose: Initial TimescaleDB schema — observations (hypertable), counter_models, registry sync, share tokens, ingest audit
6 +
7 +CREATE EXTENSION IF NOT EXISTS timescaledb;
8 +
9 +-- ---------------------------------------------------------------------------
10 +-- observations — APPEND-ONLY.
11 +-- Normalized ingested data points. Rows are NEVER updated or deleted: a
12 +-- correction from a source is a new row (later ingested_at wins at read time).
13 +-- This is what makes every counter value traceable and re-fittable forever.
14 +-- ---------------------------------------------------------------------------
15 +CREATE TABLE observations (
16 + id bigserial,
17 + metric_id text NOT NULL,
18 + source_id text NOT NULL,
19 + observed_time timestamptz NOT NULL,
20 + value double precision NOT NULL,
21 + ingested_at timestamptz NOT NULL DEFAULT now(),
22 + raw_ref text,
23 + -- Hypertables require the partitioning column in every unique constraint.
24 + PRIMARY KEY (id, observed_time)
25 +);
26 +
27 +SELECT create_hypertable('observations', 'observed_time');
28 +
29 +CREATE INDEX observations_metric_time_idx
30 + ON observations (metric_id, observed_time DESC);
31 +CREATE INDEX observations_source_time_idx
32 + ON observations (source_id, observed_time DESC);
33 +
34 +-- ---------------------------------------------------------------------------
35 +-- counter_models — versioned, NEVER UPDATEd.
36 +-- Every re-fit inserts a NEW row; `deployed` marks the row currently served.
37 +-- Deploying a model = insert new row with deployed = true (the previous
38 +-- deployed row is superseded by fitted_at ordering, not mutated).
39 +-- ---------------------------------------------------------------------------
40 +CREATE TABLE counter_models (
41 + id bigserial PRIMARY KEY,
42 + metric_id text NOT NULL,
43 + model_version text NOT NULL,
44 + model jsonb NOT NULL,
45 + fitted_at timestamptz NOT NULL DEFAULT now(),
46 + deployed boolean NOT NULL DEFAULT false
47 +);
48 +
49 +CREATE INDEX counter_models_metric_fitted_idx
50 + ON counter_models (metric_id, fitted_at DESC);
51 +CREATE INDEX counter_models_deployed_idx
52 + ON counter_models (metric_id) WHERE deployed;
53 +
54 +-- ---------------------------------------------------------------------------
55 +-- metrics_registry_sync — last synced state of packages/registry per metric.
56 +-- ---------------------------------------------------------------------------
57 +CREATE TABLE metrics_registry_sync (
58 + metric_id text PRIMARY KEY,
59 + yaml_hash text NOT NULL,
60 + synced_at timestamptz NOT NULL DEFAULT now()
61 +);
62 +
63 +-- ---------------------------------------------------------------------------
64 +-- share_tokens — revocable tokens for /m/:token pages (per-metric visibility).
65 +-- Revocation = set revoked_at (kept for audit, never hard-deleted).
66 +-- ---------------------------------------------------------------------------
67 +CREATE TABLE share_tokens (
68 + token text PRIMARY KEY,
69 + metric_ids text[] NOT NULL,
70 + revoked_at timestamptz,
71 + created_at timestamptz NOT NULL DEFAULT now()
72 +);
73 +
74 +-- ---------------------------------------------------------------------------
75 +-- ingest_runs — full auditability of every ingestion run (success or failure).
76 +-- ---------------------------------------------------------------------------
77 +CREATE TABLE ingest_runs (
78 + id bigserial PRIMARY KEY,
79 + source_id text NOT NULL,
80 + started_at timestamptz NOT NULL DEFAULT now(),
81 + finished_at timestamptz,
82 + status text NOT NULL DEFAULT 'running',
83 + error text,
84 + raw_ref text,
85 + checksum text
86 +);
87 +
88 +CREATE INDEX ingest_runs_source_started_idx
89 + ON ingest_runs (source_id, started_at DESC);
added infra/nginx.conf +68 −0
@@ -0,0 +1,68 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: infra/nginx.conf
5 +# Purpose: Reverse proxy — / → web:3000, /api|/v1|/sse|/badge → api:4000; SSE-safe (no buffering, 1 h read timeout)
6 +#
7 +# Kept portable on purpose: swapping ngrok for Cloudflare Tunnel or a plain LB
8 +# later must not require app changes.
9 +
10 +server {
11 + listen 80;
12 + server_name _;
13 +
14 + # --- API ---------------------------------------------------------------
15 + location /api {
16 + proxy_pass http://api:4000;
17 + proxy_http_version 1.1;
18 + proxy_set_header Host $host;
19 + proxy_set_header X-Real-IP $remote_addr;
20 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
21 + proxy_set_header X-Forwarded-Proto $scheme;
22 + }
23 +
24 + location /v1 {
25 + proxy_pass http://api:4000;
26 + proxy_http_version 1.1;
27 + proxy_set_header Host $host;
28 + proxy_set_header X-Real-IP $remote_addr;
29 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
30 + proxy_set_header X-Forwarded-Proto $scheme;
31 + }
32 +
33 + # --- SSE: model distribution — never buffer, hold connections open ------
34 + location /sse {
35 + proxy_pass http://api:4000;
36 + proxy_http_version 1.1;
37 + proxy_set_header Host $host;
38 + proxy_set_header X-Real-IP $remote_addr;
39 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40 + proxy_set_header X-Forwarded-Proto $scheme;
41 + proxy_set_header Connection "";
42 + proxy_buffering off;
43 + proxy_cache off;
44 + proxy_read_timeout 1h;
45 + proxy_send_timeout 1h;
46 + chunked_transfer_encoding off;
47 + }
48 +
49 + # --- Badges: rendered by the api, CDN-cached upstream --------------------
50 + location /badge {
51 + proxy_pass http://api:4000;
52 + proxy_http_version 1.1;
53 + proxy_set_header Host $host;
54 + proxy_set_header X-Forwarded-Proto $scheme;
55 + }
56 +
57 + # --- Everything else: Next.js web app ------------------------------------
58 + location / {
59 + proxy_pass http://web:3000;
60 + proxy_http_version 1.1;
61 + proxy_set_header Host $host;
62 + proxy_set_header X-Real-IP $remote_addr;
63 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
64 + proxy_set_header X-Forwarded-Proto $scheme;
65 + proxy_set_header Upgrade $http_upgrade;
66 + proxy_set_header Connection "upgrade";
67 + }
68 +}
added infra/ngrok.yml +27 −0
@@ -0,0 +1,27 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: infra/ngrok.yml
5 +# Purpose: ngrok v3 agent config — reserved domain www.earth-now.co → nginx proxy on port 8080
6 +#
7 +# ##########################################################################
8 +# ## NEVER COMMIT THE AUTHTOKEN. ##
9 +# ## There is deliberately NO `authtoken:` key in this file — the token ##
10 +# ## is provided at runtime via the NGROK_AUTHTOKEN environment variable ##
11 +# ## (or `ngrok config add-authtoken` on the node), sourced from the ##
12 +# ## secret manager. Any PR adding a token here must be rejected. ##
13 +# ##########################################################################
14 +#
15 +# Start on m3u96b with:
16 +# NGROK_AUTHTOKEN=*** ngrok start --config infra/ngrok.yml earth-now
17 +
18 +# Agent config format version 2 — the format used by the ngrok v3 agent.
19 +version: "2"
20 +
21 +tunnels:
22 + earth-now:
23 + proto: http
24 + # nginx proxy published by docker-compose.prod.yml on the host.
25 + addr: localhost:8080
26 + # Reserved domain — serves https://www.earth-now.co
27 + domain: www.earth-now.co
added infra/proxy.mjs +54 −0
@@ -0,0 +1,54 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: infra/proxy.mjs
6 + * Purpose: Zero-dependency reverse proxy (nginx.conf equivalent) — routes /api,/v1,/sse,/badge to the API, the rest to web; SSE-safe (no buffering)
7 + */
8 +
9 +import http from "node:http";
10 +
11 +const PORT = Number(process.env.PROXY_PORT ?? 8080);
12 +const API = { host: "127.0.0.1", port: Number(process.env.API_PORT ?? 4000) };
13 +const WEB = { host: "127.0.0.1", port: Number(process.env.WEB_PORT ?? 4100) };
14 +
15 +const API_PREFIXES = ["/api", "/v1", "/sse", "/badge"];
16 +
17 +function targetFor(url) {
18 + return API_PREFIXES.some((p) => url === p || url.startsWith(`${p}/`) || url.startsWith(`${p}?`))
19 + ? API
20 + : WEB;
21 +}
22 +
23 +const server = http.createServer((req, res) => {
24 + const target = targetFor(req.url ?? "/");
25 + const upstream = http.request(
26 + {
27 + host: target.host,
28 + port: target.port,
29 + path: req.url,
30 + method: req.method,
31 + headers: { ...req.headers, host: req.headers.host ?? "www.earth-now.co" },
32 + },
33 + (up) => {
34 + res.writeHead(up.statusCode ?? 502, up.headers);
35 + // pipe() flushes chunks as they arrive — SSE streams pass through unbuffered.
36 + up.pipe(res);
37 + },
38 + );
39 + upstream.on("error", () => {
40 + if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
41 + res.end(JSON.stringify({ error: "upstream unavailable" }));
42 + });
43 + req.pipe(upstream);
44 + // If the client disconnects (closed SSE tab), tear down the upstream leg too.
45 + res.on("close", () => upstream.destroy());
46 +});
47 +
48 +// Long-lived SSE connections: disable the default 5-minute socket timeout.
49 +server.requestTimeout = 0;
50 +server.headersTimeout = 60_000;
51 +
52 +server.listen(PORT, "127.0.0.1", () => {
53 + console.log(`earth-now proxy on 127.0.0.1:${PORT} → api :${API.port}, web :${WEB.port}`);
54 +});
added infra/tunnel-status.sh +45 −0
@@ -0,0 +1,45 @@
1 +#!/usr/bin/env bash
2 +# earth-now.co
3 +# Author: Simon-Pierre Boucher
4 +# Contact: contact@spboucher.ai
5 +# File: infra/tunnel-status.sh
6 +# Purpose: Query the ngrok local API on node m3u96b and pretty-print tunnel state + public URL
7 +
8 +set -euo pipefail
9 +
10 +REMOTE="m3u96b"
11 +
12 +echo "== ngrok tunnel status on ${REMOTE} =="
13 +
14 +TUNNELS_JSON="$(ssh "${REMOTE}" "curl -s --max-time 5 http://localhost:4040/api/tunnels" 2>/dev/null || true)"
15 +
16 +if [ -z "${TUNNELS_JSON}" ]; then
17 + echo "✗ ngrok is NOT running on ${REMOTE} (no local API on :4040)."
18 + echo " Start it with: ssh ${REMOTE} 'cd ~/earth-now && ngrok start --config infra/ngrok.yml earth-now'"
19 + exit 1
20 +fi
21 +
22 +python3 - "$TUNNELS_JSON" <<'PYEOF'
23 +# Pretty-print the ngrok /api/tunnels payload passed as argv[1].
24 +import json
25 +import sys
26 +
27 +try:
28 + data = json.loads(sys.argv[1])
29 +except json.JSONDecodeError:
30 + print("✗ ngrok local API returned non-JSON output — agent may be starting up or misconfigured.")
31 + sys.exit(1)
32 +
33 +tunnels = data.get("tunnels", [])
34 +if not tunnels:
35 + print("✗ ngrok agent is running but NO tunnels are established.")
36 + sys.exit(1)
37 +
38 +for t in tunnels:
39 + name = t.get("name", "?")
40 + public_url = t.get("public_url", "?")
41 + proto = t.get("proto", "?")
42 + addr = t.get("config", {}).get("addr", "?")
43 + conns = t.get("metrics", {}).get("conns", {}).get("count", "?")
44 + print(f"✓ tunnel '{name}' [{proto}] {public_url} → {addr} (connections: {conns})")
45 +PYEOF
added package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "earth-now",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "earth-now.co — real-time planetary dashboard driven by documented statistical models",
6 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
7 + "license": "UNLICENSED",
8 + "packageManager": "pnpm@9.12.0",
9 + "engines": {
10 + "node": ">=20"
11 + },
12 + "scripts": {
13 + "dev": "turbo dev",
14 + "dev:web": "turbo dev --filter=@earth-now/web",
15 + "build": "turbo build",
16 + "test": "turbo test",
17 + "test:models": "turbo test --filter=@earth-now/models --filter=@earth-now/counter",
18 + "lint": "tsx scripts/check-headers.ts && turbo lint",
19 + "typecheck": "turbo typecheck",
20 + "db:migrate": "pnpm --filter @earth-now/api db:migrate",
21 + "ingest:run": "pnpm --filter @earth-now/ingest run:source",
22 + "models:refit": "pnpm --filter @earth-now/api models:refit",
23 + "deploy:m3u96b": "bash infra/deploy-m3u96b.sh",
24 + "tunnel:status": "bash infra/tunnel-status.sh"
25 + },
26 + "devDependencies": {
27 + "tsx": "^4.19.0",
28 + "turbo": "^2.1.0",
29 + "typescript": "^5.5.4"
30 + }
31 +}
added packages/counter/package.json +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "name": "@earth-now/counter",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Shared pure client/server counter runtime: value(model, t), formatting, reset windows",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "exports": {
9 + ".": {
10 + "types": "./dist/index.d.ts",
11 + "import": "./dist/index.js"
12 + }
13 + },
14 + "main": "./dist/index.js",
15 + "types": "./dist/index.d.ts",
16 + "scripts": {
17 + "build": "tsc -p tsconfig.json",
18 + "dev": "tsc -p tsconfig.json --watch",
19 + "test": "tsc -p tsconfig.json && vitest run",
20 + "typecheck": "tsc -p tsconfig.json --noEmit",
21 + "lint": "echo 'lint: covered by root header check'"
22 + },
23 + "devDependencies": {
24 + "fast-check": "^3.22.0",
25 + "jsdom": "^30.0.1",
26 + "typescript": "^5.5.4",
27 + "vitest": "^2.0.5"
28 + }
29 +}
added packages/counter/src/counter-model.ts +96 −0
@@ -0,0 +1,96 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/counter-model.ts
6 + * Purpose: The project's central contract — CounterModel and RateFunction types shared by server, client, widget and badge
7 + */
8 +
9 +/**
10 + * A single Fourier harmonic of the instantaneous rate.
11 + *
12 + * The rate contribution at time t is:
13 + * amplitude * cos(2π * order * τ / period + phase)
14 + * where τ is the number of seconds between t and SEASONAL_EPOCH (2000-01-01T00:00:00Z),
15 + * so phases are absolute calendar phases, identical for every client.
16 + */
17 +export interface Harmonic {
18 + /** Base period of the cycle. "year" = mean Gregorian year, "week" = 604 800 s, "day" = 86 400 s. */
19 + period: "year" | "week" | "day";
20 + /** Harmonic order (1 = fundamental, 2 = half-period, ...). Must be >= 1. */
21 + order: number;
22 + /** Amplitude, in the metric's base unit per second. */
23 + amplitude: number;
24 + /** Phase offset in radians at the seasonal epoch. */
25 + phase: number;
26 +}
27 +
28 +export type RateFunction =
29 + /** Constant rate: v(t) = anchorValue + perSecond * (t - anchorTime). */
30 + | { kind: "linear"; perSecond: number }
31 + /**
32 + * Piecewise-constant rate. Segments must be sorted by `from` ascending and the
33 + * first segment must start at or before anchorTime. Used e.g. for Jan-1-UTC resets.
34 + */
35 + | { kind: "piecewise"; segments: Array<{ from: string; perSecond: number }> }
36 + /**
37 + * Seasonal rate: base + Fourier harmonics. v(t) is the analytic integral of the
38 + * rate from anchorTime, so evaluation is exact and identical on every runtime.
39 + */
40 + | { kind: "seasonal"; base: number; harmonics: Harmonic[] }
41 + /**
42 + * Monotone-spline (PCHIP) interpolation of (time, value) knots — value knots,
43 + * not rate knots. Outside the knot range the value extrapolates linearly with
44 + * the endpoint derivative. anchorValue must equal the spline value at anchorTime.
45 + */
46 + | { kind: "spline"; knots: Array<[time: string, value: number]> };
47 +
48 +export interface DisplayHints {
49 + /** Fraction digits to render. */
50 + decimals: number;
51 + /** Hard cap on significant digits — never display more precision than the model justifies. */
52 + sigFigs?: number;
53 + /** Display unit label (SI in the data layer; conversions live here, never in the pipeline). */
54 + unit: string;
55 + /** Display conversion factor applied before formatting (e.g. 1e-9 for t → Gt). Default 1. */
56 + scale?: number;
57 +}
58 +
59 +export interface CounterModel {
60 + /** e.g. "co2_emissions_ytd" — key in packages/registry. */
61 + metricId: string;
62 + /** Value at the anchor point. */
63 + anchorValue: number;
64 + /** ISO 8601 UTC of the anchor point. */
65 + anchorTime: string;
66 + /** How the value evolves from the anchor. */
67 + rateFn: RateFunction;
68 + /** 90 % CI of the value at anchorTime (widens with model-specific rules client-side). */
69 + uncertainty?: { low: number; high: number };
70 + /** Date of the last REAL observation this model is anchored on. */
71 + observedAt: string;
72 + /** Key in the source registry. */
73 + sourceId: string;
74 + /** e.g. "seasonal-spline-v2" — bump on ANY output-changing modification. */
75 + modelVersion: string;
76 + displayHints: DisplayHints;
77 +}
78 +
79 +/** Epoch used to phase seasonal harmonics: 2000-01-01T00:00:00Z, in ms. */
80 +export const SEASONAL_EPOCH_MS = Date.UTC(2000, 0, 1);
81 +
82 +/** Mean Gregorian year, in seconds. Shared by fitting (packages/models) and evaluation. */
83 +export const YEAR_SECONDS = 365.2425 * 86_400;
84 +
85 +/** One UTC day, in seconds. */
86 +export const DAY_SECONDS = 86_400;
87 +
88 +/** One week, in seconds. The seasonal epoch (2000-01-01) is a Saturday — weekly phases are relative to Saturday 00:00 UTC. */
89 +export const WEEK_SECONDS = 604_800;
90 +
91 +/** Parse a strict ISO 8601 UTC timestamp to ms since epoch; throws on invalid input. */
92 +export function parseIsoUtc(iso: string): number {
93 + const ms = Date.parse(iso);
94 + if (Number.isNaN(ms)) throw new Error(`Invalid ISO 8601 timestamp: ${iso}`);
95 + return ms;
96 +}
added packages/counter/src/format.ts +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/format.ts
6 + * Purpose: All UI number formatting — sigFigs honesty cap, Intl.NumberFormat, rates and uncertainty; never inline toFixed elsewhere
7 + */
8 +
9 +import type { DisplayHints } from "./counter-model.js";
10 +
11 +/** Round to n significant digits (the honesty cap: never display precision the model can't justify). */
12 +export function roundToSigFigs(value: number, sigFigs: number): number {
13 + if (value === 0 || !Number.isFinite(value)) return value;
14 + const magnitude = Math.floor(Math.log10(Math.abs(value)));
15 + const factor = 10 ** (sigFigs - 1 - magnitude);
16 + return Math.round(value * factor) / factor;
17 +}
18 +
19 +export interface FormatOptions {
20 + locale?: string;
21 + /**
22 + * Animated tickers keep the trailing digits moving (the units digit animates)
23 + * while static contexts (badge alt text, tooltips, share cards) apply the
24 + * sigFigs cap strictly. Default: true (strict).
25 + */
26 + applySigFigs?: boolean;
27 +}
28 +
29 +/** Format a counter value according to its display hints. Fallback text on non-finite input. */
30 +export function formatValue(
31 + value: number,
32 + hints: DisplayHints,
33 + options: FormatOptions = {},
34 +): string {
35 + if (!Number.isFinite(value)) return "—";
36 + const { locale = "en", applySigFigs = true } = options;
37 + const scaled = value * (hints.scale ?? 1);
38 + const v =
39 + applySigFigs && hints.sigFigs !== undefined ? roundToSigFigs(scaled, hints.sigFigs) : scaled;
40 + return new Intl.NumberFormat(locale, {
41 + minimumFractionDigits: hints.decimals,
42 + maximumFractionDigits: hints.decimals,
43 + }).format(v);
44 +}
45 +
46 +/** Format an instantaneous rate at a human-friendly cadence (/s, /min, /h). */
47 +export function formatRate(
48 + perSecond: number,
49 + hints: DisplayHints,
50 + options: FormatOptions = {},
51 +): string {
52 + if (!Number.isFinite(perSecond)) return "—";
53 + const { locale = "en" } = options;
54 + const perSecondScaled = perSecond * (hints.scale ?? 1);
55 + const abs = Math.abs(perSecondScaled);
56 + let scaled = perSecondScaled;
57 + let suffix = "/s";
58 + if (abs < 1 / 60) {
59 + scaled = perSecondScaled * 3600;
60 + suffix = "/h";
61 + } else if (abs < 1) {
62 + scaled = perSecondScaled * 60;
63 + suffix = "/min";
64 + }
65 + const formatted = new Intl.NumberFormat(locale, {
66 + maximumSignificantDigits: Math.min(hints.sigFigs ?? 3, 3),
67 + }).format(scaled);
68 + return `${formatted}${suffix}`;
69 +}
70 +
71 +/**
72 + * Compact display for chart axis ticks (8.31 Md / 8.31B) — never used for the
73 + * ticking value itself. Honors the display scale and caps significant digits.
74 + */
75 +export function formatCompact(
76 + value: number,
77 + hints: DisplayHints,
78 + options: FormatOptions = {},
79 +): string {
80 + if (!Number.isFinite(value)) return "—";
81 + const { locale = "en" } = options;
82 + const scaled = value * (hints.scale ?? 1);
83 + return new Intl.NumberFormat(locale, {
84 + notation: "compact",
85 + maximumSignificantDigits: Math.min(hints.sigFigs ?? 3, 4),
86 + }).format(scaled);
87 +}
88 +
89 +/** Format a 90 % CI as "low – high" with the same hints (for tooltips and the methodology page). */
90 +export function formatUncertainty(
91 + low: number,
92 + high: number,
93 + hints: DisplayHints,
94 + options: FormatOptions = {},
95 +): string {
96 + return `${formatValue(low, hints, options)} – ${formatValue(high, hints, options)}`;
97 +}
added packages/counter/src/index.ts +35 −0
@@ -0,0 +1,35 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/index.ts
6 + * Purpose: Public entrypoint of the shared counter runtime (types, evaluation, windows, formatting)
7 + */
8 +
9 +export {
10 + type CounterModel,
11 + type DisplayHints,
12 + type Harmonic,
13 + type RateFunction,
14 + DAY_SECONDS,
15 + SEASONAL_EPOCH_MS,
16 + WEEK_SECONDS,
17 + YEAR_SECONDS,
18 + parseIsoUtc,
19 +} from "./counter-model.js";
20 +export { counterValue, rateAt } from "./value.js";
21 +export {
22 + type CounterWindow,
23 + startOfUtcDay,
24 + startOfUtcYear,
25 + windowValue,
26 +} from "./windows.js";
27 +export {
28 + type FormatOptions,
29 + formatCompact,
30 + formatRate,
31 + formatUncertainty,
32 + formatValue,
33 + roundToSigFigs,
34 +} from "./format.js";
35 +export { pchipDerivative, pchipEvaluate, pchipSlopes } from "./pchip.js";
added packages/counter/src/pchip.ts +122 −0
@@ -0,0 +1,122 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/pchip.ts
6 + * Purpose: Monotone cubic (PCHIP, Fritsch–Carlson) evaluation — never overshoots, shared by client, server and badge
7 + */
8 +
9 +/**
10 + * Compute PCHIP endpoint/interior slopes for strictly increasing xs.
11 + * Fritsch–Carlson: interior slopes are a weighted harmonic mean of adjacent
12 + * secants, zero at local extrema — this is what guarantees no overshoot
13 + * (a natural cubic spline could send a counter backwards; PCHIP cannot).
14 + */
15 +export function pchipSlopes(xs: readonly number[], ys: readonly number[]): number[] {
16 + const n = xs.length;
17 + if (n !== ys.length) throw new Error("pchip: xs and ys length mismatch");
18 + if (n === 0) throw new Error("pchip: need at least one knot");
19 + if (n === 1) return [0];
20 +
21 + const h: number[] = new Array(n - 1);
22 + const delta: number[] = new Array(n - 1);
23 + for (let i = 0; i < n - 1; i++) {
24 + const dx = xs[i + 1]! - xs[i]!;
25 + if (dx <= 0) throw new Error("pchip: knots must be strictly increasing in time");
26 + h[i] = dx;
27 + delta[i] = (ys[i + 1]! - ys[i]!) / dx;
28 + }
29 + if (n === 2) return [delta[0]!, delta[0]!];
30 +
31 + const m: number[] = new Array(n).fill(0);
32 + for (let i = 1; i < n - 1; i++) {
33 + const d0 = delta[i - 1]!;
34 + const d1 = delta[i]!;
35 + if (d0 * d1 <= 0) {
36 + m[i] = 0;
37 + } else {
38 + const w1 = 2 * h[i]! + h[i - 1]!;
39 + const w2 = h[i]! + 2 * h[i - 1]!;
40 + m[i] = (w1 + w2) / (w1 / d0 + w2 / d1);
41 + }
42 + }
43 +
44 + m[0] = endpointSlope(h[0]!, h[1]!, delta[0]!, delta[1]!);
45 + m[n - 1] = endpointSlope(h[n - 2]!, h[n - 3]!, delta[n - 2]!, delta[n - 3]!);
46 + return m;
47 +}
48 +
49 +/** One-sided three-point endpoint slope with the standard monotonicity clamps. */
50 +function endpointSlope(h0: number, h1: number, d0: number, d1: number): number {
51 + let m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
52 + if (m * d0 <= 0) m = 0;
53 + else if (d0 * d1 < 0 && Math.abs(m) > 3 * Math.abs(d0)) m = 3 * d0;
54 + return m;
55 +}
56 +
57 +/**
58 + * Evaluate the PCHIP interpolant at x. Outside the knot range the value
59 + * extrapolates linearly with the endpoint derivative (bounded, no polynomial blowup).
60 + */
61 +export function pchipEvaluate(
62 + xs: readonly number[],
63 + ys: readonly number[],
64 + slopes: readonly number[],
65 + x: number,
66 +): number {
67 + const n = xs.length;
68 + if (n === 1) return ys[0]!;
69 + if (x <= xs[0]!) return ys[0]! + slopes[0]! * (x - xs[0]!);
70 + if (x >= xs[n - 1]!) return ys[n - 1]! + slopes[n - 1]! * (x - xs[n - 1]!);
71 +
72 + // Binary search for the segment containing x.
73 + let lo = 0;
74 + let hi = n - 1;
75 + while (hi - lo > 1) {
76 + const mid = (lo + hi) >> 1;
77 + if (xs[mid]! <= x) lo = mid;
78 + else hi = mid;
79 + }
80 +
81 + const h = xs[lo + 1]! - xs[lo]!;
82 + const t = (x - xs[lo]!) / h;
83 + const t2 = t * t;
84 + const t3 = t2 * t;
85 + const h00 = 2 * t3 - 3 * t2 + 1;
86 + const h10 = t3 - 2 * t2 + t;
87 + const h01 = -2 * t3 + 3 * t2;
88 + const h11 = t3 - t2;
89 + return (
90 + h00 * ys[lo]! + h10 * h * slopes[lo]! + h01 * ys[lo + 1]! + h11 * h * slopes[lo + 1]!
91 + );
92 +}
93 +
94 +/** First derivative of the PCHIP interpolant at x (endpoint slope outside the range). */
95 +export function pchipDerivative(
96 + xs: readonly number[],
97 + ys: readonly number[],
98 + slopes: readonly number[],
99 + x: number,
100 +): number {
101 + const n = xs.length;
102 + if (n === 1) return 0;
103 + if (x <= xs[0]!) return slopes[0]!;
104 + if (x >= xs[n - 1]!) return slopes[n - 1]!;
105 +
106 + let lo = 0;
107 + let hi = n - 1;
108 + while (hi - lo > 1) {
109 + const mid = (lo + hi) >> 1;
110 + if (xs[mid]! <= x) lo = mid;
111 + else hi = mid;
112 + }
113 +
114 + const h = xs[lo + 1]! - xs[lo]!;
115 + const t = (x - xs[lo]!) / h;
116 + const t2 = t * t;
117 + const dh00 = (6 * t2 - 6 * t) / h;
118 + const dh10 = 3 * t2 - 4 * t + 1;
119 + const dh01 = (-6 * t2 + 6 * t) / h;
120 + const dh11 = 3 * t2 - 2 * t;
121 + return dh00 * ys[lo]! + dh10 * slopes[lo]! + dh01 * ys[lo + 1]! + dh11 * slopes[lo + 1]!;
122 +}
added packages/counter/src/value.ts +129 −0
@@ -0,0 +1,129 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/value.ts
6 + * Purpose: Pure evaluation of a CounterModel — value(model, t) and rateAt(model, t), bit-identical client/server
7 + */
8 +
9 +import {
10 + type CounterModel,
11 + type Harmonic,
12 + type RateFunction,
13 + DAY_SECONDS,
14 + SEASONAL_EPOCH_MS,
15 + WEEK_SECONDS,
16 + YEAR_SECONDS,
17 + parseIsoUtc,
18 +} from "./counter-model.js";
19 +import { pchipDerivative, pchipEvaluate, pchipSlopes } from "./pchip.js";
20 +
21 +/** Parsed spline data is cached per rateFn object identity — evaluation stays pure. */
22 +const splineCache = new WeakMap<
23 + object,
24 + { xs: number[]; ys: number[]; slopes: number[] }
25 +>();
26 +
27 +function splineData(rateFn: Extract<RateFunction, { kind: "spline" }>): {
28 + xs: number[];
29 + ys: number[];
30 + slopes: number[];
31 +} {
32 + const cached = splineCache.get(rateFn);
33 + if (cached) return cached;
34 + const xs = rateFn.knots.map(([t]) => parseIsoUtc(t));
35 + const ys = rateFn.knots.map(([, v]) => v);
36 + const data = { xs, ys, slopes: pchipSlopes(xs, ys) };
37 + splineCache.set(rateFn, data);
38 + return data;
39 +}
40 +
41 +function harmonicOmega(h: Harmonic): number {
42 + const period =
43 + h.period === "year" ? YEAR_SECONDS : h.period === "week" ? WEEK_SECONDS : DAY_SECONDS;
44 + return (2 * Math.PI * h.order) / period;
45 +}
46 +
47 +/** ∫ rate dt of one harmonic between two instants (seconds since the seasonal epoch). */
48 +function harmonicIntegral(h: Harmonic, tau0: number, tau1: number): number {
49 + const omega = harmonicOmega(h);
50 + return (h.amplitude / omega) * (Math.sin(omega * tau1 + h.phase) - Math.sin(omega * tau0 + h.phase));
51 +}
52 +
53 +function harmonicRate(h: Harmonic, tau: number): number {
54 + return h.amplitude * Math.cos(harmonicOmega(h) * tau + h.phase);
55 +}
56 +
57 +/**
58 + * Value of the counter at time t (ms since Unix epoch, UTC).
59 + * Time is always a parameter — this package never reads the system clock.
60 + */
61 +export function counterValue(model: CounterModel, tMs: number): number {
62 + const anchorMs = parseIsoUtc(model.anchorTime);
63 + const rateFn = model.rateFn;
64 + switch (rateFn.kind) {
65 + case "linear":
66 + return model.anchorValue + (rateFn.perSecond * (tMs - anchorMs)) / 1000;
67 +
68 + case "piecewise": {
69 + // Signed integral of the piecewise-constant rate from anchorTime to t.
70 + const froms = rateFn.segments.map((s) => parseIsoUtc(s.from));
71 + let acc = model.anchorValue;
72 + const [a, b] = anchorMs <= tMs ? [anchorMs, tMs] : [tMs, anchorMs];
73 + let integral = 0;
74 + for (let i = 0; i < rateFn.segments.length; i++) {
75 + const segStart = froms[i]!;
76 + const segEnd = i + 1 < froms.length ? froms[i + 1]! : Infinity;
77 + const lo = Math.max(a, segStart);
78 + const hi = Math.min(b, segEnd);
79 + if (hi > lo) integral += (rateFn.segments[i]!.perSecond * (hi - lo)) / 1000;
80 + }
81 + acc += anchorMs <= tMs ? integral : -integral;
82 + return acc;
83 + }
84 +
85 + case "seasonal": {
86 + const tau0 = (anchorMs - SEASONAL_EPOCH_MS) / 1000;
87 + const tau1 = (tMs - SEASONAL_EPOCH_MS) / 1000;
88 + let v = model.anchorValue + rateFn.base * (tau1 - tau0);
89 + for (const h of rateFn.harmonics) v += harmonicIntegral(h, tau0, tau1);
90 + return v;
91 + }
92 +
93 + case "spline": {
94 + const { xs, ys, slopes } = splineData(rateFn);
95 + return pchipEvaluate(xs, ys, slopes, tMs);
96 + }
97 + }
98 +}
99 +
100 +/** Instantaneous rate (base unit per second) at time t. */
101 +export function rateAt(model: CounterModel, tMs: number): number {
102 + const rateFn = model.rateFn;
103 + switch (rateFn.kind) {
104 + case "linear":
105 + return rateFn.perSecond;
106 +
107 + case "piecewise": {
108 + let rate = rateFn.segments[0]?.perSecond ?? 0;
109 + for (const seg of rateFn.segments) {
110 + if (parseIsoUtc(seg.from) <= tMs) rate = seg.perSecond;
111 + else break;
112 + }
113 + return rate;
114 + }
115 +
116 + case "seasonal": {
117 + const tau = (tMs - SEASONAL_EPOCH_MS) / 1000;
118 + let r = rateFn.base;
119 + for (const h of rateFn.harmonics) r += harmonicRate(h, tau);
120 + return r;
121 + }
122 +
123 + case "spline": {
124 + const { xs, ys, slopes } = splineData(rateFn);
125 + // pchip derivative is per ms of x-axis; convert to per second.
126 + return pchipDerivative(xs, ys, slopes, tMs) * 1000;
127 + }
128 + }
129 +}
added packages/counter/src/windows.ts +49 −0
@@ -0,0 +1,49 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/src/windows.ts
6 + * Purpose: Reset-window helpers (today UTC / this year UTC / session) computed from the same model — pure, time is a parameter
7 + */
8 +
9 +import type { CounterModel } from "./counter-model.js";
10 +import { counterValue } from "./value.js";
11 +
12 +export type CounterWindow = "today" | "ytd" | "session" | "total";
13 +
14 +/** Start of the UTC day containing t (ms). */
15 +export function startOfUtcDay(tMs: number): number {
16 + const d = new Date(tMs);
17 + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
18 +}
19 +
20 +/** Start of the UTC year containing t (ms). */
21 +export function startOfUtcYear(tMs: number): number {
22 + return Date.UTC(new Date(tMs).getUTCFullYear(), 0, 1);
23 +}
24 +
25 +/**
26 + * Value of a cumulative counter over a display window, derived from the SAME
27 + * model (same function server/client/badge): v(t) − v(windowStart).
28 + * "session" needs the caller-provided session start (user arrival time).
29 + */
30 +export function windowValue(
31 + model: CounterModel,
32 + tMs: number,
33 + window: CounterWindow,
34 + sessionStartMs?: number,
35 +): number {
36 + switch (window) {
37 + case "total":
38 + return counterValue(model, tMs);
39 + case "today":
40 + return counterValue(model, tMs) - counterValue(model, startOfUtcDay(tMs));
41 + case "ytd":
42 + return counterValue(model, tMs) - counterValue(model, startOfUtcYear(tMs));
43 + case "session": {
44 + if (sessionStartMs === undefined)
45 + throw new Error("windowValue: 'session' window requires sessionStartMs");
46 + return counterValue(model, tMs) - counterValue(model, sessionStartMs);
47 + }
48 + }
49 +}
added packages/counter/test/consistency.test.ts +107 −0
@@ -0,0 +1,107 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/test/consistency.test.ts
6 + * Purpose: Client/server consistency — jsdom (browser-like) and a spawned Node process must produce bit-identical values
7 + *
8 + * @vitest-environment jsdom
9 + */
10 +
11 +import { execFileSync } from "node:child_process";
12 +import { fileURLToPath } from "node:url";
13 +import { dirname, join } from "node:path";
14 +import { describe, expect, it } from "vitest";
15 +import { type CounterModel, counterValue } from "../src/index";
16 +
17 +const MODELS: CounterModel[] = [
18 + {
19 + metricId: "consistency_linear",
20 + anchorValue: 12345.678,
21 + anchorTime: "2026-01-01T00:00:00.000Z",
22 + rateFn: { kind: "linear", perSecond: 1.2345 },
23 + observedAt: "2026-01-01T00:00:00.000Z",
24 + sourceId: "test",
25 + modelVersion: "test-v1",
26 + displayHints: { decimals: 0, unit: "u" },
27 + },
28 + {
29 + metricId: "consistency_seasonal",
30 + anchorValue: 1e9,
31 + anchorTime: "2026-01-01T00:00:00.000Z",
32 + rateFn: {
33 + kind: "seasonal",
34 + base: 4.17,
35 + harmonics: [
36 + { period: "year", order: 1, amplitude: 0.146, phase: 1.8449 },
37 + { period: "day", order: 1, amplitude: 0.02, phase: 2.88 },
38 + ],
39 + },
40 + observedAt: "2025-07-01T00:00:00.000Z",
41 + sourceId: "test",
42 + modelVersion: "test-v1",
43 + displayHints: { decimals: 0, unit: "u" },
44 + },
45 + {
46 + metricId: "consistency_spline",
47 + anchorValue: 8231000000,
48 + anchorTime: "2025-07-01T00:00:00.000Z",
49 + rateFn: {
50 + kind: "spline",
51 + knots: [
52 + ["2023-07-01T00:00:00.000Z", 8091000000],
53 + ["2024-07-01T00:00:00.000Z", 8161000000],
54 + ["2025-07-01T00:00:00.000Z", 8231000000],
55 + ["2026-07-01T00:00:00.000Z", 8299000000],
56 + ],
57 + },
58 + observedAt: "2025-07-01T00:00:00.000Z",
59 + sourceId: "test",
60 + modelVersion: "test-v1",
61 + displayHints: { decimals: 0, unit: "u" },
62 + },
63 +];
64 +
65 +const INSTANTS = [
66 + "2026-01-01T00:00:00.000Z",
67 + "2026-03-14T15:09:26.535Z",
68 + "2026-08-09T12:00:00.000Z",
69 + "2026-12-31T23:59:59.999Z",
70 + "2027-06-15T06:30:00.000Z",
71 +].map((iso) => Date.parse(iso));
72 +
73 +describe("client/server consistency (bit-identical values)", () => {
74 + it("runs under jsdom", () => {
75 + expect(typeof document).toBe("object");
76 + });
77 +
78 + it("jsdom and a separate Node process agree exactly on every model × instant", () => {
79 + const here = counterValue !== undefined;
80 + expect(here).toBe(true);
81 +
82 + const inJsdom = MODELS.map((m) => INSTANTS.map((t) => counterValue(m, t)));
83 +
84 + // Evaluate the SAME models in a plain Node child process against the built dist.
85 + const distIndex = join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "index.js");
86 + const script = `
87 + const { counterValue } = await import(${JSON.stringify(distIndex)});
88 + const models = ${JSON.stringify(MODELS)};
89 + const instants = ${JSON.stringify(INSTANTS)};
90 + const out = models.map((m) => instants.map((t) => counterValue(m, t)));
91 + console.log(JSON.stringify(out));
92 + `;
93 + const stdout = execFileSync(process.execPath, ["--input-type=module", "-e", script], {
94 + encoding: "utf8",
95 + });
96 + const inNode = JSON.parse(stdout) as number[][];
97 +
98 + // JSON round-trips finite doubles exactly (shortest round-trip repr), so
99 + // toEqual here really is bit-identity.
100 + expect(inNode).toEqual(inJsdom);
101 + for (let i = 0; i < MODELS.length; i++) {
102 + for (let j = 0; j < INSTANTS.length; j++) {
103 + expect(Object.is(inNode[i]![j]!, inJsdom[i]![j]!)).toBe(true);
104 + }
105 + }
106 + });
107 +});
added packages/counter/test/format.test.ts +85 −0
@@ -0,0 +1,85 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/test/format.test.ts
6 + * Purpose: Formatting tests — sigFigs honesty cap, locales, rates, fallback on non-finite values
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import {
11 + formatCompact,
12 + formatRate,
13 + formatUncertainty,
14 + formatValue,
15 + roundToSigFigs,
16 +} from "../src/index";
17 +
18 +describe("roundToSigFigs", () => {
19 + it("caps significant digits", () => {
20 + expect(roundToSigFigs(8_231_456_789, 7)).toBe(8_231_457_000);
21 + expect(roundToSigFigs(426.912345, 5)).toBeCloseTo(426.91, 9);
22 + expect(roundToSigFigs(0.0012349, 3)).toBeCloseTo(0.00123, 12);
23 + expect(roundToSigFigs(-1234, 2)).toBe(-1200);
24 + expect(roundToSigFigs(0, 3)).toBe(0);
25 + });
26 +});
27 +
28 +describe("formatValue", () => {
29 + it("applies sigFigs then decimals", () => {
30 + const s = formatValue(8_231_456_789, { decimals: 0, sigFigs: 7, unit: "people" });
31 + expect(s).toBe("8,231,457,000");
32 + });
33 +
34 + it("keeps full precision when applySigFigs=false (animated ticker)", () => {
35 + const s = formatValue(8_231_456_789, { decimals: 0, sigFigs: 7, unit: "people" }, {
36 + applySigFigs: false,
37 + });
38 + expect(s).toBe("8,231,456,789");
39 + });
40 +
41 + it("honors locale", () => {
42 + const s = formatValue(1234.5, { decimals: 1, unit: "t" }, { locale: "fr" });
43 + // fr uses narrow no-break space grouping and comma decimal.
44 + expect(s).toMatch(/1[\s  ]234,5/);
45 + });
46 +
47 + it("falls back on non-finite values (never NaN on screen)", () => {
48 + expect(formatValue(Number.NaN, { decimals: 0, unit: "x" })).toBe("—");
49 + expect(formatValue(Infinity, { decimals: 0, unit: "x" })).toBe("—");
50 + });
51 +});
52 +
53 +describe("formatRate", () => {
54 + it("chooses a human cadence", () => {
55 + expect(formatRate(4.2, { decimals: 0, unit: "people" })).toBe("4.2/s");
56 + expect(formatRate(0.5, { decimals: 0, unit: "people" })).toBe("30/min");
57 + expect(formatRate(0.002, { decimals: 0, unit: "people" })).toBe("7.2/h");
58 + });
59 +
60 + it("applies the display scale in every cadence band", () => {
61 + // 1027 MWh/s with scale 1e-6 → 0.001027 TWh/s → shown per hour: 3.7 TWh/h.
62 + expect(formatRate(1027, { decimals: 2, unit: "TWh", scale: 0.000001 })).toBe("3.7/h");
63 + // 500 m/s with scale 1e-3 → 0.5 km/s → per minute: 30 km/min.
64 + expect(formatRate(500, { decimals: 0, unit: "km", scale: 0.001 })).toBe("30/min");
65 + });
66 +});
67 +
68 +describe("formatCompact", () => {
69 + it("compacts axis-tick values with scale and sigFigs cap", () => {
70 + expect(formatCompact(8_310_000_000, { decimals: 0, sigFigs: 3, unit: "people" })).toBe(
71 + "8.31B",
72 + );
73 + expect(
74 + formatCompact(23_400_000_000, { decimals: 0, sigFigs: 4, unit: "Gt", scale: 1e-9 }),
75 + ).toBe("23.4");
76 + expect(formatCompact(Number.NaN, { decimals: 0, unit: "x" })).toBe("—");
77 + });
78 +});
79 +
80 +describe("formatUncertainty", () => {
81 + it("renders a CI range", () => {
82 + const s = formatUncertainty(8.1e9, 8.3e9, { decimals: 0, sigFigs: 3, unit: "people" });
83 + expect(s).toBe("8,100,000,000 – 8,300,000,000");
84 + });
85 +});
added packages/counter/test/property.test.ts +141 −0
@@ -0,0 +1,141 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/test/property.test.ts
6 + * Purpose: Property-based tests (fast-check) — monotonicity, continuity, anchor identity, determinism
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import fc from "fast-check";
11 +import { type CounterModel, counterValue } from "../src/index";
12 +
13 +const T0 = "2026-01-01T00:00:00.000Z";
14 +const t0 = Date.parse(T0);
15 +
16 +function model(rateFn: CounterModel["rateFn"], anchorValue = 0): CounterModel {
17 + return {
18 + metricId: "prop_metric",
19 + anchorValue,
20 + anchorTime: T0,
21 + rateFn,
22 + observedAt: T0,
23 + sourceId: "test_source",
24 + modelVersion: "test-v1",
25 + displayHints: { decimals: 0, unit: "units" },
26 + };
27 +}
28 +
29 +describe("property: cumulative monotonicity", () => {
30 + it("non-negative linear/piecewise rates never go backwards", () => {
31 + fc.assert(
32 + fc.property(
33 + fc.array(fc.double({ min: 0, max: 1e6, noNaN: true }), { minLength: 1, maxLength: 5 }),
34 + fc.integer({ min: 0, max: 86_400_000 }),
35 + fc.integer({ min: 0, max: 86_400_000 }),
36 + (rates, dtA, dtB) => {
37 + const segments = rates.map((perSecond, i) => ({
38 + from: new Date(t0 + i * 3_600_000).toISOString(),
39 + perSecond,
40 + }));
41 + const m = model({ kind: "piecewise", segments });
42 + const [lo, hi] = dtA <= dtB ? [dtA, dtB] : [dtB, dtA];
43 + expect(counterValue(m, t0 + hi)).toBeGreaterThanOrEqual(
44 + counterValue(m, t0 + lo) - 1e-9,
45 + );
46 + },
47 + ),
48 + );
49 + });
50 +
51 + it("monotone spline knots produce monotone values (no overshoot, ever)", () => {
52 + fc.assert(
53 + fc.property(
54 + fc.array(fc.double({ min: 0.001, max: 1e6, noNaN: true }), {
55 + minLength: 2,
56 + maxLength: 8,
57 + }),
58 + fc.integer({ min: 0, max: 999 }),
59 + (increments, sample) => {
60 + let v = 0;
61 + const knots: Array<[string, number]> = increments.map((inc, i) => {
62 + v += inc;
63 + return [new Date(t0 + i * 86_400_000).toISOString(), v];
64 + });
65 + const m = model({ kind: "spline", knots }, knots[0]![1]);
66 + const span = (knots.length - 1) * 86_400_000;
67 + const tA = t0 + (span * sample) / 1000;
68 + const tB = t0 + (span * Math.min(sample + 1, 1000)) / 1000;
69 + expect(counterValue(m, tB)).toBeGreaterThanOrEqual(counterValue(m, tA) - 1e-6);
70 + },
71 + ),
72 + );
73 + });
74 +});
75 +
76 +describe("property: continuity at piecewise junctions", () => {
77 + it("value is continuous through every segment boundary", () => {
78 + fc.assert(
79 + fc.property(
80 + fc.array(fc.double({ min: -1e3, max: 1e3, noNaN: true }), {
81 + minLength: 2,
82 + maxLength: 6,
83 + }),
84 + (rates) => {
85 + const segments = rates.map((perSecond, i) => ({
86 + from: new Date(t0 + i * 60_000).toISOString(),
87 + perSecond,
88 + }));
89 + const m = model({ kind: "piecewise", segments });
90 + for (let i = 1; i < rates.length; i++) {
91 + const tj = t0 + i * 60_000;
92 + const before = counterValue(m, tj - 1);
93 + const after = counterValue(m, tj + 1);
94 + // Max drift across 2 ms is bounded by max |rate| * 2 ms.
95 + expect(Math.abs(after - before)).toBeLessThanOrEqual(1e3 * 0.002 + 1e-9);
96 + }
97 + },
98 + ),
99 + );
100 + });
101 +});
102 +
103 +describe("property: anchor identity & determinism", () => {
104 + it("value(anchorTime) === anchorValue for every kind", () => {
105 + fc.assert(
106 + fc.property(
107 + fc.double({ min: -1e9, max: 1e9, noNaN: true }),
108 + fc.double({ min: -1e3, max: 1e3, noNaN: true }),
109 + (anchorValue, perSecond) => {
110 + for (const rateFn of [
111 + { kind: "linear", perSecond } as const,
112 + {
113 + kind: "seasonal",
114 + base: perSecond,
115 + harmonics: [{ period: "year", order: 1, amplitude: 1, phase: 0.5 }],
116 + } as const,
117 + ]) {
118 + const m = model(rateFn, anchorValue);
119 + expect(counterValue(m, t0)).toBeCloseTo(anchorValue, 6);
120 + }
121 + },
122 + ),
123 + );
124 + });
125 +
126 + it("same model + same t ⇒ identical value (repeatable)", () => {
127 + fc.assert(
128 + fc.property(fc.integer({ min: -1e9, max: 1e9 }), (dt) => {
129 + const m = model({
130 + kind: "seasonal",
131 + base: 3,
132 + harmonics: [
133 + { period: "year", order: 1, amplitude: 2, phase: 1 },
134 + { period: "day", order: 1, amplitude: 0.5, phase: 2 },
135 + ],
136 + });
137 + expect(counterValue(m, t0 + dt)).toBe(counterValue(m, t0 + dt));
138 + }),
139 + );
140 + });
141 +});
added packages/counter/test/value.test.ts +183 −0
@@ -0,0 +1,183 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/counter/test/value.test.ts
6 + * Purpose: Golden tests for counterValue/rateAt across all RateFunction kinds at precise instants t
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import {
11 + type CounterModel,
12 + counterValue,
13 + rateAt,
14 + startOfUtcDay,
15 + startOfUtcYear,
16 + windowValue,
17 + YEAR_SECONDS,
18 + SEASONAL_EPOCH_MS,
19 +} from "../src/index";
20 +
21 +const T0 = "2026-01-01T00:00:00.000Z";
22 +const t0 = Date.parse(T0);
23 +
24 +function base(over: Partial<CounterModel>): CounterModel {
25 + return {
26 + metricId: "test_metric",
27 + anchorValue: 0,
28 + anchorTime: T0,
29 + rateFn: { kind: "linear", perSecond: 1 },
30 + observedAt: T0,
31 + sourceId: "test_source",
32 + modelVersion: "test-v1",
33 + displayHints: { decimals: 0, unit: "units" },
34 + ...over,
35 + };
36 +}
37 +
38 +describe("linear", () => {
39 + it("value(anchorTime) === anchorValue", () => {
40 + const m = base({ anchorValue: 42 });
41 + expect(counterValue(m, t0)).toBe(42);
42 + });
43 +
44 + it("advances by perSecond", () => {
45 + const m = base({ rateFn: { kind: "linear", perSecond: 2.5 } });
46 + expect(counterValue(m, t0 + 10_000)).toBeCloseTo(25, 10);
47 + expect(counterValue(m, t0 - 4_000)).toBeCloseTo(-10, 10);
48 + expect(rateAt(m, t0)).toBe(2.5);
49 + });
50 +});
51 +
52 +describe("piecewise", () => {
53 + const m = base({
54 + anchorValue: 100,
55 + rateFn: {
56 + kind: "piecewise",
57 + segments: [
58 + { from: "2026-01-01T00:00:00.000Z", perSecond: 1 },
59 + { from: "2026-01-01T00:01:00.000Z", perSecond: 3 },
60 + ],
61 + },
62 + });
63 +
64 + it("integrates across the junction", () => {
65 + // 60 s at 1/s + 30 s at 3/s = 60 + 90.
66 + expect(counterValue(m, t0 + 90_000)).toBeCloseTo(250, 10);
67 + });
68 +
69 + it("is continuous at the junction", () => {
70 + const eps = 1;
71 + const before = counterValue(m, t0 + 60_000 - eps);
72 + const after = counterValue(m, t0 + 60_000 + eps);
73 + expect(Math.abs(after - before)).toBeLessThan(0.01);
74 + });
75 +
76 + it("reports the active segment's rate", () => {
77 + expect(rateAt(m, t0 + 30_000)).toBe(1);
78 + expect(rateAt(m, t0 + 61_000)).toBe(3);
79 + });
80 +});
81 +
82 +describe("seasonal", () => {
83 + const m = base({
84 + anchorValue: 1000,
85 + rateFn: {
86 + kind: "seasonal",
87 + base: 2,
88 + harmonics: [{ period: "year", order: 1, amplitude: 1, phase: 0 }],
89 + },
90 + });
91 +
92 + it("value(anchorTime) === anchorValue", () => {
93 + expect(counterValue(m, t0)).toBeCloseTo(1000, 9);
94 + });
95 +
96 + it("harmonic integrates to ~zero over a full year (only base remains)", () => {
97 + const oneYearMs = YEAR_SECONDS * 1000;
98 + const v = counterValue(m, t0 + oneYearMs);
99 + expect(v).toBeCloseTo(1000 + 2 * YEAR_SECONDS, 4);
100 + });
101 +
102 + it("rate oscillates around base with the harmonic amplitude", () => {
103 + // At the seasonal epoch itself, cos(0 + 0) = 1 → rate = base + amplitude.
104 + expect(rateAt(m, SEASONAL_EPOCH_MS)).toBeCloseTo(3, 9);
105 + });
106 +});
107 +
108 +describe("seasonal week period", () => {
109 + const m = base({
110 + rateFn: {
111 + kind: "seasonal",
112 + base: 10,
113 + harmonics: [{ period: "week", order: 1, amplitude: 2, phase: 0 }],
114 + },
115 + });
116 +
117 + it("weekly harmonic integrates to ~zero over a full week", () => {
118 + const oneWeekMs = 604_800_000;
119 + expect(counterValue(m, t0 + oneWeekMs)).toBeCloseTo(10 * 604_800, 4);
120 + });
121 +
122 + it("weekly phase is anchored to Saturday 00:00 UTC (the 2000-01-01 epoch)", () => {
123 + // 2026-01-03 is a Saturday: cos(0) = 1 → rate = base + amplitude.
124 + expect(rateAt(m, Date.parse("2026-01-03T00:00:00Z"))).toBeCloseTo(12, 9);
125 + // Half a week later (Tue 12:00): cos(π) = −1 → base − amplitude.
126 + expect(rateAt(m, Date.parse("2026-01-06T12:00:00Z"))).toBeCloseTo(8, 9);
127 + });
128 +});
129 +
130 +describe("spline (PCHIP)", () => {
131 + const m = base({
132 + anchorValue: 20,
133 + anchorTime: "2026-01-02T00:00:00.000Z",
134 + rateFn: {
135 + kind: "spline",
136 + knots: [
137 + ["2026-01-01T00:00:00.000Z", 10],
138 + ["2026-01-02T00:00:00.000Z", 20],
139 + ["2026-01-03T00:00:00.000Z", 40],
140 + ["2026-01-04T00:00:00.000Z", 45],
141 + ],
142 + },
143 + });
144 +
145 + it("interpolates exactly at knots and anchor", () => {
146 + expect(counterValue(m, Date.parse("2026-01-01T00:00:00Z"))).toBeCloseTo(10, 9);
147 + expect(counterValue(m, Date.parse("2026-01-02T00:00:00Z"))).toBeCloseTo(20, 9);
148 + expect(counterValue(m, Date.parse("2026-01-04T00:00:00Z"))).toBeCloseTo(45, 9);
149 + });
150 +
151 + it("never overshoots between monotone knots", () => {
152 + const a = Date.parse("2026-01-01T00:00:00Z");
153 + const b = Date.parse("2026-01-04T00:00:00Z");
154 + for (let i = 0; i <= 200; i++) {
155 + const v = counterValue(m, a + ((b - a) * i) / 200);
156 + expect(v).toBeGreaterThanOrEqual(10 - 1e-9);
157 + expect(v).toBeLessThanOrEqual(45 + 1e-9);
158 + }
159 + });
160 +
161 + it("extrapolates linearly beyond the last knot", () => {
162 + const end = Date.parse("2026-01-04T00:00:00Z");
163 + const slopeEnd = rateAt(m, end);
164 + const v = counterValue(m, end + 3_600_000);
165 + expect(v).toBeCloseTo(45 + slopeEnd * 3600, 6);
166 + });
167 +});
168 +
169 +describe("windows", () => {
170 + it("today/ytd windows subtract the model value at window start", () => {
171 + const m = base({ rateFn: { kind: "linear", perSecond: 1 } });
172 + const t = Date.parse("2026-03-10T06:00:00Z");
173 + expect(windowValue(m, t, "today")).toBeCloseTo(6 * 3600, 6);
174 + expect(windowValue(m, t, "ytd")).toBeCloseTo((t - t0) / 1000, 6);
175 + expect(windowValue(m, t, "session", t - 90_000)).toBeCloseTo(90, 9);
176 + });
177 +
178 + it("UTC boundaries are correct", () => {
179 + const t = Date.parse("2026-03-10T06:07:08Z");
180 + expect(new Date(startOfUtcDay(t)).toISOString()).toBe("2026-03-10T00:00:00.000Z");
181 + expect(new Date(startOfUtcYear(t)).toISOString()).toBe("2026-01-01T00:00:00.000Z");
182 + });
183 +});
added packages/counter/tsconfig.json +8 −0
@@ -0,0 +1,8 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "outDir": "dist",
5 + "rootDir": "src"
6 + },
7 + "include": ["src"]
8 +}
added packages/models/package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "@earth-now/models",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Pure statistical fitting, interpolation and validation — no I/O, time is always a parameter",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "exports": {
9 + ".": {
10 + "types": "./dist/index.d.ts",
11 + "import": "./dist/index.js"
12 + }
13 + },
14 + "main": "./dist/index.js",
15 + "types": "./dist/index.d.ts",
16 + "scripts": {
17 + "build": "tsc -p tsconfig.json",
18 + "dev": "tsc -p tsconfig.json --watch",
19 + "test": "vitest run",
20 + "typecheck": "tsc -p tsconfig.json --noEmit",
21 + "lint": "echo 'lint: covered by root header check'"
22 + },
23 + "dependencies": {
24 + "@earth-now/counter": "workspace:*"
25 + },
26 + "devDependencies": {
27 + "fast-check": "^3.22.0",
28 + "typescript": "^5.5.4",
29 + "vitest": "^2.0.5"
30 + }
31 +}
added packages/models/src/builders.ts +255 −0
@@ -0,0 +1,255 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/builders.ts
6 + * Purpose: Model builders (fitters) producing versioned CounterModels — one per model family declared in the registry
7 + */
8 +
9 +import {
10 + type CounterModel,
11 + type DisplayHints,
12 + type Harmonic,
13 + YEAR_SECONDS,
14 + counterValue,
15 + parseIsoUtc,
16 +} from "@earth-now/counter";
17 +import { fitFourier } from "./fourier.js";
18 +
19 +/**
20 + * Model family versions. Bump on ANY output-changing modification and update the
21 + * golden fixtures — CI fails otherwise.
22 + */
23 +export const MODEL_VERSIONS = {
24 + linearYtd: "linear-ytd-v1",
25 + linearStock: "linear-stock-v1",
26 + seasonalYtd: "seasonal-ytd-v1",
27 + stockSpline: "seasonal-spline-v2",
28 + keeling: "keeling-fusion-v1",
29 + staticRt: "static-rt-v1",
30 + composed: "composed-v1",
31 +} as const;
32 +
33 +interface CommonMeta {
34 + metricId: string;
35 + sourceId: string;
36 + observedAt: string;
37 + displayHints: DisplayHints;
38 + uncertainty?: { low: number; high: number };
39 +}
40 +
41 +/** Seconds in a specific UTC calendar year (leap-aware). */
42 +export function secondsInUtcYear(year: number): number {
43 + return (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 1000;
44 +}
45 +
46 +/**
47 + * L0 — Level 0 linear YTD cumulative: annual total spread uniformly over the
48 + * year, anchored at Jan 1 UTC with value 0. Fallback / genuinely quasi-linear
49 + * metrics only.
50 + */
51 +export function buildLinearYtdModel(
52 + meta: CommonMeta,
53 + params: { year: number; annualTotal: number },
54 +): CounterModel {
55 + const { year, annualTotal } = params;
56 + if (annualTotal < 0) throw new Error("buildLinearYtdModel: cumulative total must be >= 0");
57 + return {
58 + ...meta,
59 + anchorValue: 0,
60 + anchorTime: new Date(Date.UTC(year, 0, 1)).toISOString(),
61 + rateFn: { kind: "linear", perSecond: annualTotal / secondsInUtcYear(year) },
62 + modelVersion: MODEL_VERSIONS.linearYtd,
63 + };
64 +}
65 +
66 +export interface SeasonalShape {
67 + period: "year" | "week" | "day";
68 + order: number;
69 + /** Relative amplitude as a fraction of the base rate (|Σ| must stay < 1). */
70 + relativeAmplitude: number;
71 + /** Phase in radians at the seasonal epoch. */
72 + phase: number;
73 +}
74 +
75 +/**
76 + * L1 — seasonal YTD cumulative: annual total + declared seasonal shape
77 + * (Fourier harmonics as fractions of the base rate). The relative-amplitude sum
78 + * is checked < 1 so the instantaneous rate stays strictly positive — a cumulative
79 + * counter can never tick backwards.
80 + */
81 +export function buildSeasonalYtdModel(
82 + meta: CommonMeta,
83 + params: { year: number; annualTotal: number; shape: SeasonalShape[] },
84 +): CounterModel {
85 + const { year, annualTotal, shape } = params;
86 + if (annualTotal < 0) throw new Error("buildSeasonalYtdModel: cumulative total must be >= 0");
87 + const totalRel = shape.reduce((s, h) => s + Math.abs(h.relativeAmplitude), 0);
88 + if (totalRel >= 1) {
89 + throw new Error(
90 + `buildSeasonalYtdModel: Σ|relativeAmplitude| = ${totalRel} would allow a negative rate`,
91 + );
92 + }
93 + const base = annualTotal / secondsInUtcYear(year);
94 + const harmonics: Harmonic[] = shape.map((h) => ({
95 + period: h.period,
96 + order: h.order,
97 + amplitude: h.relativeAmplitude * base,
98 + phase: h.phase,
99 + }));
100 + const anchorTime = new Date(Date.UTC(year, 0, 1)).toISOString();
101 + return {
102 + ...meta,
103 + anchorValue: 0,
104 + anchorTime,
105 + rateFn: { kind: "seasonal", base, harmonics },
106 + modelVersion: MODEL_VERSIONS.seasonalYtd,
107 + };
108 +}
109 +
110 +export interface TimeValuePoint {
111 + time: string;
112 + value: number;
113 +}
114 +
115 +/**
116 + * L2 — observation/forecast fusion for stocks: monotone PCHIP spline through
117 + * past observations and source forecasts (e.g. UN WPP median). Anchored at the
118 + * last real observation. Family name kept from the registry contract
119 + * ("seasonal-spline-v2"); the seasonal component is zero for aseasonal stocks.
120 + */
121 +export function buildStockSplineModel(
122 + meta: CommonMeta,
123 + params: { observations: TimeValuePoint[]; forecasts: TimeValuePoint[] },
124 +): CounterModel {
125 + const points = [...params.observations, ...params.forecasts].sort(
126 + (a, b) => parseIsoUtc(a.time) - parseIsoUtc(b.time),
127 + );
128 + if (points.length < 2) throw new Error("buildStockSplineModel: need >= 2 points");
129 + const lastObs = params.observations[params.observations.length - 1];
130 + if (!lastObs) throw new Error("buildStockSplineModel: need at least one observation");
131 + return {
132 + ...meta,
133 + anchorValue: lastObs.value,
134 + anchorTime: lastObs.time,
135 + rateFn: { kind: "spline", knots: points.map((p) => [p.time, p.value]) },
136 + modelVersion: MODEL_VERSIONS.stockSpline,
137 + };
138 +}
139 +
140 +/**
141 + * L2 vedette — Keeling-style stock: least-squares fit of linear trend + annual
142 + * harmonics (orders 1 & 2) on the observation series. The anchor is the FITTED
143 + * value at the last observation (smooth junction, no step at deploy time).
144 + */
145 +export function buildKeelingModel(
146 + meta: CommonMeta,
147 + params: {
148 + observations: TimeValuePoint[];
149 + harmonics?: Array<{ period: "year" | "day"; order: number }>;
150 + },
151 +): CounterModel {
152 + const harmonics = params.harmonics ?? [
153 + { period: "year", order: 1 },
154 + { period: "year", order: 2 },
155 + ];
156 + const fit = fitFourier({ observations: params.observations, harmonics });
157 + const last = params.observations[params.observations.length - 1];
158 + if (!last) throw new Error("buildKeelingModel: empty observations");
159 + const anchorMs = parseIsoUtc(last.time);
160 + return {
161 + ...meta,
162 + anchorValue: fit.valueAt(anchorMs),
163 + anchorTime: last.time,
164 + rateFn: { kind: "seasonal", base: fit.trendPerSecond, harmonics: fit.rateHarmonics },
165 + modelVersion: MODEL_VERSIONS.keeling,
166 + };
167 +}
168 +
169 +/**
170 + * RT — static value for event-driven metrics between true real-time updates
171 + * (earthquake counts, humans in space). Re-anchored by the API on each event;
172 + * never interpolated.
173 + */
174 +export function buildStaticRtModel(meta: CommonMeta, params: { value: number; at: string }): CounterModel {
175 + return {
176 + ...meta,
177 + anchorValue: params.value,
178 + anchorTime: params.at,
179 + rateFn: { kind: "linear", perSecond: 0 },
180 + modelVersion: MODEL_VERSIONS.staticRt,
181 + };
182 +}
183 +
184 +/** Convenience: expected mean rate of an annual total (per second, mean Gregorian year). */
185 +export function annualTotalToMeanRate(annualTotal: number): number {
186 + return annualTotal / YEAR_SECONDS;
187 +}
188 +
189 +/**
190 + * L0 — linear stock (e.g. a countdown: days before the next Earth Overshoot Day,
191 + * perSecond = −1/86400). Anchored on the given observation.
192 + */
193 +export function buildLinearStockModel(
194 + meta: CommonMeta,
195 + params: { at: string; value: number; perSecond: number },
196 +): CounterModel {
197 + return {
198 + ...meta,
199 + anchorValue: params.value,
200 + anchorTime: params.at,
201 + rateFn: { kind: "linear", perSecond: params.perSecond },
202 + modelVersion: MODEL_VERSIONS.linearStock,
203 + };
204 +}
205 +
206 +/**
207 + * Derived metric composition: constant + Σ weightᵢ · modelᵢ(t), exact for
208 + * linear and seasonal inputs (rates add analytically). Used server-side so the
209 + * badge, widget and dashboard all animate the same composed function
210 + * (net growth = births − deaths; carbon budget = budget − emissions; scalings).
211 + */
212 +export function composeLinearCombination(
213 + meta: CommonMeta,
214 + inputs: Array<{ model: CounterModel; weight: number }>,
215 + constant = 0,
216 +): CounterModel {
217 + if (inputs.length === 0) throw new Error("composeLinearCombination: need >= 1 input");
218 + for (const { model } of inputs) {
219 + const kind = model.rateFn.kind;
220 + if (kind !== "linear" && kind !== "seasonal") {
221 + throw new Error(
222 + `composeLinearCombination: unsupported input rateFn '${kind}' (linear/seasonal only)`,
223 + );
224 + }
225 + }
226 + // Reference instant: the latest input anchor.
227 + const refMs = Math.max(...inputs.map(({ model }) => parseIsoUtc(model.anchorTime)));
228 + const refIso = new Date(refMs).toISOString();
229 +
230 + let base = 0;
231 + const harmonics: Harmonic[] = [];
232 + let anchorValue = constant;
233 + for (const { model, weight } of inputs) {
234 + anchorValue += weight * counterValue(model, refMs);
235 + if (model.rateFn.kind === "linear") {
236 + base += weight * model.rateFn.perSecond;
237 + } else if (model.rateFn.kind === "seasonal") {
238 + base += weight * model.rateFn.base;
239 + for (const h of model.rateFn.harmonics) {
240 + harmonics.push({ ...h, amplitude: h.amplitude * weight });
241 + }
242 + }
243 + }
244 +
245 + return {
246 + ...meta,
247 + anchorValue,
248 + anchorTime: refIso,
249 + rateFn:
250 + harmonics.length === 0
251 + ? { kind: "linear", perSecond: base }
252 + : { kind: "seasonal", base, harmonics },
253 + modelVersion: MODEL_VERSIONS.composed,
254 + };
255 +}
added packages/models/src/fourier.ts +111 −0
@@ -0,0 +1,111 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/fourier.ts
6 + * Purpose: Least-squares fit of trend + Fourier harmonics in the VALUE domain, converted to rate harmonics for CounterModel
7 + */
8 +
9 +import {
10 + type Harmonic,
11 + DAY_SECONDS,
12 + SEASONAL_EPOCH_MS,
13 + WEEK_SECONDS,
14 + YEAR_SECONDS,
15 + parseIsoUtc,
16 +} from "@earth-now/counter";
17 +import { leastSquares } from "./linalg.js";
18 +
19 +export interface FourierFitInput {
20 + /** Observations: ISO time → value. Needs >= 2·harmonics + 2 points. */
21 + observations: Array<{ time: string; value: number }>;
22 + /** Harmonic spec to fit, e.g. [{period:"year", order:1}, {period:"year", order:2}]. */
23 + harmonics: Array<{ period: "year" | "week" | "day"; order: number }>;
24 +}
25 +
26 +export interface FourierFitResult {
27 + /** Value at the seasonal epoch (intercept). */
28 + intercept: number;
29 + /** Linear trend of the value, per second. Becomes rateFn.base. */
30 + trendPerSecond: number;
31 + /** Rate-domain harmonics ready for a `seasonal` RateFunction. */
32 + rateHarmonics: Harmonic[];
33 + /** Evaluate the fitted VALUE curve at t (ms). */
34 + valueAt: (tMs: number) => number;
35 + /** Root-mean-square residual of the fit. */
36 + rmse: number;
37 +}
38 +
39 +function omegaOf(period: "year" | "week" | "day", order: number): number {
40 + const seconds =
41 + period === "year" ? YEAR_SECONDS : period === "week" ? WEEK_SECONDS : DAY_SECONDS;
42 + return (2 * Math.PI * order) / seconds;
43 +}
44 +
45 +/**
46 + * Fit value(τ) = a + b·τ + Σ_h [ c_h·cos(ω_h τ) + s_h·sin(ω_h τ) ], τ in seconds
47 + * since the seasonal epoch. The derivative of each fitted value harmonic is an
48 + * exact rate harmonic, so the returned Harmonics reproduce the fitted curve when
49 + * integrated by packages/counter — the client animates the very curve we fitted.
50 + */
51 +export function fitFourier(input: FourierFitInput): FourierFitResult {
52 + const { observations, harmonics } = input;
53 + const k = 2 + 2 * harmonics.length;
54 + if (observations.length < k) {
55 + throw new Error(
56 + `fitFourier: need at least ${k} observations for ${harmonics.length} harmonics, got ${observations.length}`,
57 + );
58 + }
59 +
60 + const taus = observations.map((o) => (parseIsoUtc(o.time) - SEASONAL_EPOCH_MS) / 1000);
61 + // Center τ for conditioning; un-center the intercept afterwards.
62 + const tauMean = taus.reduce((s, t) => s + t, 0) / taus.length;
63 + const omegas = harmonics.map((h) => omegaOf(h.period, h.order));
64 +
65 + const X = taus.map((tau) => {
66 + const row = [1, tau - tauMean];
67 + for (const omega of omegas) row.push(Math.cos(omega * tau), Math.sin(omega * tau));
68 + return row;
69 + });
70 + const y = observations.map((o) => o.value);
71 + const beta = leastSquares(X, y);
72 +
73 + const b = beta[1]!;
74 + const a = beta[0]! - b * tauMean;
75 + const coefs = harmonics.map((h, i) => ({
76 + ...h,
77 + c: beta[2 + 2 * i]!,
78 + s: beta[3 + 2 * i]!,
79 + omega: omegas[i]!,
80 + }));
81 +
82 + const valueAt = (tMs: number): number => {
83 + const tau = (tMs - SEASONAL_EPOCH_MS) / 1000;
84 + let v = a + b * tau;
85 + for (const { c, s, omega } of coefs) v += c * Math.cos(omega * tau) + s * Math.sin(omega * tau);
86 + return v;
87 + };
88 +
89 + let sse = 0;
90 + for (let i = 0; i < taus.length; i++) {
91 + const r = valueAt(parseIsoUtc(observations[i]!.time)) - y[i]!;
92 + sse += r * r;
93 + }
94 +
95 + // d/dτ [c·cos(ωτ) + s·sin(ωτ)] = (s·ω)·cos(ωτ) − (c·ω)·sin(ωτ)
96 + // = A·ω·cos(ωτ + φ) with A = √(c²+s²), φ = atan2(c, s)... derived below.
97 + const rateHarmonics: Harmonic[] = coefs.map(({ period, order, c, s, omega }) => {
98 + const amplitude = Math.hypot(c, s) * omega;
99 + // Write derivative as R·cos(ωτ + φ): R·cosφ = s·ω, R·sinφ = c·ω ⇒ φ = atan2(c, s).
100 + const phase = Math.atan2(c, s);
101 + return { period, order, amplitude, phase };
102 + });
103 +
104 + return {
105 + intercept: a,
106 + trendPerSecond: b,
107 + rateHarmonics,
108 + valueAt,
109 + rmse: Math.sqrt(sse / taus.length),
110 + };
111 +}
added packages/models/src/holt-winters.ts +74 −0
@@ -0,0 +1,74 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/holt-winters.ts
6 + * Purpose: Additive Holt-Winters (level/trend/seasonal) for nowcasting frequently-published metrics without a source forecast
7 + */
8 +
9 +export interface HoltWintersParams {
10 + /** Level smoothing 0..1. */
11 + alpha: number;
12 + /** Trend smoothing 0..1. */
13 + beta: number;
14 + /** Seasonal smoothing 0..1. */
15 + gamma: number;
16 + /** Season length in observations (e.g. 12 for monthly data with yearly seasonality). */
17 + seasonLength: number;
18 +}
19 +
20 +export interface HoltWintersFit {
21 + level: number;
22 + trend: number;
23 + seasonals: number[];
24 + /** Forecast h steps ahead of the last observation. */
25 + forecast: (h: number) => number;
26 + /** One-step-ahead fitted values (same length as input, first season is initialization). */
27 + fitted: number[];
28 +}
29 +
30 +/** Additive Holt-Winters. Needs at least two full seasons of data. */
31 +export function fitHoltWinters(series: readonly number[], params: HoltWintersParams): HoltWintersFit {
32 + const { alpha, beta, gamma, seasonLength: m } = params;
33 + if (m < 2) throw new Error("holt-winters: seasonLength must be >= 2");
34 + if (series.length < 2 * m)
35 + throw new Error(`holt-winters: need >= ${2 * m} observations, got ${series.length}`);
36 + for (const p of [alpha, beta, gamma]) {
37 + if (p < 0 || p > 1) throw new Error("holt-winters: smoothing params must be in [0,1]");
38 + }
39 +
40 + // Initialization: first-season mean level, trend from season-over-season means,
41 + // seasonal indices as DETRENDED deviations from the first-season mean — without
42 + // detrending, a linear trend ramp pollutes the seasonal profile.
43 + const season1 = series.slice(0, m);
44 + const season2 = series.slice(m, 2 * m);
45 + const mean1 = season1.reduce((s, v) => s + v, 0) / m;
46 + const mean2 = season2.reduce((s, v) => s + v, 0) / m;
47 + let level = mean1;
48 + let trend = (mean2 - mean1) / m;
49 + const seasonals = season1.map((v, i) => v - (mean1 + (i - (m - 1) / 2) * trend));
50 +
51 + const fitted: number[] = [];
52 + for (let i = 0; i < series.length; i++) {
53 + const si = i % m;
54 + const predicted = level + trend + seasonals[si]!;
55 + fitted.push(predicted);
56 + const v = series[i]!;
57 + const prevLevel = level;
58 + level = alpha * (v - seasonals[si]!) + (1 - alpha) * (level + trend);
59 + trend = beta * (level - prevLevel) + (1 - beta) * trend;
60 + seasonals[si] = gamma * (v - level) + (1 - gamma) * seasonals[si]!;
61 + }
62 +
63 + return {
64 + level,
65 + trend,
66 + seasonals,
67 + fitted,
68 + forecast: (h: number) => {
69 + if (h < 1) throw new Error("holt-winters: forecast horizon must be >= 1");
70 + const si = (series.length + h - 1) % m;
71 + return level + h * trend + seasonals[si]!;
72 + },
73 + };
74 +}
added packages/models/src/index.ts +34 −0
@@ -0,0 +1,34 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/index.ts
6 + * Purpose: Public entrypoint of the pure statistical layer (fitters, filters, validation)
7 + */
8 +
9 +export { leastSquares, solveLinearSystem } from "./linalg.js";
10 +export { type FourierFitInput, type FourierFitResult, fitFourier } from "./fourier.js";
11 +export { type HoltWintersFit, type HoltWintersParams, fitHoltWinters } from "./holt-winters.js";
12 +export { type KalmanOptions, type KalmanStep, blendWithForecast, kalmanFilter } from "./kalman.js";
13 +export {
14 + type MetricConstraints,
15 + type ValidationHorizon,
16 + type ValidationIssue,
17 + assertDeployable,
18 + diffModels,
19 + validateModel,
20 +} from "./validate.js";
21 +export {
22 + type SeasonalShape,
23 + type TimeValuePoint,
24 + MODEL_VERSIONS,
25 + annualTotalToMeanRate,
26 + buildKeelingModel,
27 + buildLinearStockModel,
28 + buildLinearYtdModel,
29 + buildSeasonalYtdModel,
30 + buildStaticRtModel,
31 + buildStockSplineModel,
32 + composeLinearCombination,
33 + secondsInUtcYear,
34 +} from "./builders.js";
added packages/models/src/kalman.ts +59 −0
@@ -0,0 +1,59 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/kalman.ts
6 + * Purpose: 1D Kalman filter (local-level) — observation/forecast fusion: track observations near them, converge to forecast further out
7 + */
8 +
9 +export interface KalmanOptions {
10 + /** Process noise variance per step (how fast truth can drift). */
11 + processVariance: number;
12 + /** Measurement noise variance (how much we trust each observation). */
13 + measurementVariance: number;
14 + /** Initial state estimate. */
15 + initialValue: number;
16 + /** Initial estimate variance. */
17 + initialVariance: number;
18 +}
19 +
20 +export interface KalmanStep {
21 + value: number;
22 + variance: number;
23 +}
24 +
25 +/** Run a local-level Kalman filter over the series; returns the filtered state at each step. */
26 +export function kalmanFilter(series: readonly number[], opts: KalmanOptions): KalmanStep[] {
27 + const { processVariance: q, measurementVariance: r } = opts;
28 + let x = opts.initialValue;
29 + let p = opts.initialVariance;
30 + const out: KalmanStep[] = [];
31 + for (const z of series) {
32 + // Predict.
33 + p += q;
34 + // Update.
35 + const k = p / (p + r);
36 + x += k * (z - x);
37 + p *= 1 - k;
38 + out.push({ value: x, variance: p });
39 + }
40 + return out;
41 +}
42 +
43 +/**
44 + * Blend the last filtered observation state with an external forecast:
45 + * inverse-variance weighting where the observation's variance grows with the
46 + * horizon (q per step), so weight shifts smoothly toward the forecast.
47 + */
48 +export function blendWithForecast(
49 + lastState: KalmanStep,
50 + stepsAhead: number,
51 + processVariance: number,
52 + forecast: { value: number; variance: number },
53 +): KalmanStep {
54 + const obsVariance = lastState.variance + Math.max(0, stepsAhead) * processVariance;
55 + const wObs = 1 / obsVariance;
56 + const wFc = 1 / forecast.variance;
57 + const value = (lastState.value * wObs + forecast.value * wFc) / (wObs + wFc);
58 + return { value, variance: 1 / (wObs + wFc) };
59 +}
added packages/models/src/linalg.ts +60 −0
@@ -0,0 +1,60 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/linalg.ts
6 + * Purpose: Minimal dense linear algebra — least-squares via normal equations with partial-pivot Gaussian elimination
7 + */
8 +
9 +/** Solve A x = b (A: n×n, row-major) with partial pivoting. Throws on singular systems. */
10 +export function solveLinearSystem(A: number[][], b: number[]): number[] {
11 + const n = b.length;
12 + // Augmented copy so callers' arrays are never mutated.
13 + const M = A.map((row, i) => [...row, b[i]!]);
14 +
15 + for (let col = 0; col < n; col++) {
16 + let pivot = col;
17 + for (let r = col + 1; r < n; r++) {
18 + if (Math.abs(M[r]![col]!) > Math.abs(M[pivot]![col]!)) pivot = r;
19 + }
20 + if (Math.abs(M[pivot]![col]!) < 1e-12) throw new Error("solveLinearSystem: singular matrix");
21 + if (pivot !== col) {
22 + const tmp = M[col]!;
23 + M[col] = M[pivot]!;
24 + M[pivot] = tmp;
25 + }
26 + for (let r = col + 1; r < n; r++) {
27 + const f = M[r]![col]! / M[col]![col]!;
28 + for (let c = col; c <= n; c++) M[r]![c]! -= f * M[col]![c]!;
29 + }
30 + }
31 +
32 + const x = new Array<number>(n).fill(0);
33 + for (let r = n - 1; r >= 0; r--) {
34 + let acc = M[r]![n]!;
35 + for (let c = r + 1; c < n; c++) acc -= M[r]![c]! * x[c]!;
36 + x[r] = acc / M[r]![r]!;
37 + }
38 + return x;
39 +}
40 +
41 +/**
42 + * Ordinary least squares: minimize ||X β − y||² via normal equations XᵀX β = Xᵀy.
43 + * X is m×k row-major (m observations, k basis functions).
44 + */
45 +export function leastSquares(X: number[][], y: number[]): number[] {
46 + const m = X.length;
47 + if (m === 0 || m !== y.length) throw new Error("leastSquares: dimension mismatch");
48 + const k = X[0]!.length;
49 + const XtX: number[][] = Array.from({ length: k }, () => new Array<number>(k).fill(0));
50 + const Xty = new Array<number>(k).fill(0);
51 + for (let i = 0; i < m; i++) {
52 + const row = X[i]!;
53 + for (let a = 0; a < k; a++) {
54 + Xty[a]! += row[a]! * y[i]!;
55 + for (let b = a; b < k; b++) XtX[a]![b]! += row[a]! * row[b]!;
56 + }
57 + }
58 + for (let a = 0; a < k; a++) for (let b = 0; b < a; b++) XtX[a]![b] = XtX[b]![a]!;
59 + return solveLinearSystem(XtX, Xty);
60 +}
added packages/models/src/validate.ts +127 −0
@@ -0,0 +1,127 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/src/validate.ts
6 + * Purpose: Validity guardrails — monotonicity of cumulatives, rate bounds, anchor identity, NaN checks, and jump diff between model versions
7 + */
8 +
9 +import { type CounterModel, counterValue, parseIsoUtc, rateAt } from "@earth-now/counter";
10 +
11 +export interface MetricConstraints {
12 + /** "cumulative" counters must never go backwards; "stock" may move both ways. */
13 + kind: "stock" | "cumulative";
14 + /** Max |instantaneous rate| in base unit per second, from the registry. */
15 + maxAbsRatePerSec?: number;
16 + /**
17 + * Max allowed value jump (base unit) between the outgoing and incoming model at
18 + * swap time. Exceeding it BLOCKS deployment (no counter teleportation in prod).
19 + */
20 + maxJumpOnRefit?: number;
21 +}
22 +
23 +export interface ValidationIssue {
24 + code:
25 + | "nan-value"
26 + | "negative-rate"
27 + | "rate-bound-exceeded"
28 + | "anchor-mismatch"
29 + | "jump-exceeded";
30 + message: string;
31 +}
32 +
33 +export interface ValidationHorizon {
34 + fromMs: number;
35 + toMs: number;
36 + /** Sampling resolution (default 720 points across the horizon). */
37 + samples?: number;
38 +}
39 +
40 +/**
41 + * Sample the model across the horizon and check every guardrail. Returns [] when
42 + * the model is deployable. Pure — the horizon is a parameter, never the clock.
43 + */
44 +export function validateModel(
45 + model: CounterModel,
46 + constraints: MetricConstraints,
47 + horizon: ValidationHorizon,
48 +): ValidationIssue[] {
49 + const issues: ValidationIssue[] = [];
50 + const { fromMs, toMs } = horizon;
51 + const samples = horizon.samples ?? 720;
52 + if (toMs <= fromMs) throw new Error("validateModel: empty horizon");
53 +
54 + const anchorMs = parseIsoUtc(model.anchorTime);
55 + const atAnchor = counterValue(model, anchorMs);
56 + if (!Number.isFinite(atAnchor) || Math.abs(atAnchor - model.anchorValue) > tolerance(model.anchorValue)) {
57 + issues.push({
58 + code: "anchor-mismatch",
59 + message: `value(anchorTime)=${atAnchor} differs from anchorValue=${model.anchorValue}`,
60 + });
61 + }
62 +
63 + let prev = counterValue(model, fromMs);
64 + for (let i = 0; i <= samples; i++) {
65 + const t = fromMs + ((toMs - fromMs) * i) / samples;
66 + const v = counterValue(model, t);
67 + const r = rateAt(model, t);
68 +
69 + if (!Number.isFinite(v) || !Number.isFinite(r)) {
70 + issues.push({ code: "nan-value", message: `non-finite value/rate at ${new Date(t).toISOString()}` });
71 + break;
72 + }
73 + if (constraints.kind === "cumulative" && v < prev - tolerance(prev)) {
74 + issues.push({
75 + code: "negative-rate",
76 + message: `cumulative counter decreases near ${new Date(t).toISOString()} (${prev} → ${v})`,
77 + });
78 + break;
79 + }
80 + if (
81 + constraints.maxAbsRatePerSec !== undefined &&
82 + Math.abs(r) > constraints.maxAbsRatePerSec
83 + ) {
84 + issues.push({
85 + code: "rate-bound-exceeded",
86 + message: `|rate|=${Math.abs(r)} exceeds declared max ${constraints.maxAbsRatePerSec} at ${new Date(t).toISOString()}`,
87 + });
88 + break;
89 + }
90 + prev = v;
91 + }
92 + return issues;
93 +}
94 +
95 +/** Absolute value jump between two models at the swap instant. */
96 +export function diffModels(prev: CounterModel, next: CounterModel, atMs: number): number {
97 + return Math.abs(counterValue(next, atMs) - counterValue(prev, atMs));
98 +}
99 +
100 +/**
101 + * Full deployability gate: guardrail validation of the new model over the horizon
102 + * plus the anti-teleportation diff against the outgoing model at swap time.
103 + */
104 +export function assertDeployable(
105 + previous: CounterModel | null,
106 + next: CounterModel,
107 + constraints: MetricConstraints,
108 + swapAtMs: number,
109 + horizon: ValidationHorizon,
110 +): ValidationIssue[] {
111 + const issues = validateModel(next, constraints, horizon);
112 + if (previous && constraints.maxJumpOnRefit !== undefined) {
113 + const jump = diffModels(previous, next, swapAtMs);
114 + if (jump > constraints.maxJumpOnRefit) {
115 + issues.push({
116 + code: "jump-exceeded",
117 + message: `refit jump ${jump} exceeds declared max ${constraints.maxJumpOnRefit} — deployment blocked`,
118 + });
119 + }
120 + }
121 + return issues;
122 +}
123 +
124 +/** Numeric tolerance scaled to magnitude (float error on huge counters). */
125 +function tolerance(v: number): number {
126 + return Math.max(1e-6, Math.abs(v) * 1e-9);
127 +}
added packages/models/test/builders-validate.test.ts +183 −0
@@ -0,0 +1,183 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/test/builders-validate.test.ts
6 + * Purpose: Builders + guardrails — annual totals, monotonicity, rate bounds, anti-teleportation diff gate
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { counterValue, rateAt } from "@earth-now/counter";
11 +import {
12 + assertDeployable,
13 + buildKeelingModel,
14 + buildLinearYtdModel,
15 + buildSeasonalYtdModel,
16 + buildStaticRtModel,
17 + buildStockSplineModel,
18 + diffModels,
19 + secondsInUtcYear,
20 + validateModel,
21 +} from "../src/index";
22 +
23 +const meta = {
24 + metricId: "test_metric",
25 + sourceId: "test_source",
26 + observedAt: "2026-01-01T00:00:00.000Z",
27 + displayHints: { decimals: 0, unit: "units" },
28 +};
29 +
30 +describe("buildLinearYtdModel (L0)", () => {
31 + const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1_000_000 });
32 +
33 + it("starts at 0 on Jan 1 UTC and ends the year at the annual total", () => {
34 + expect(counterValue(m, Date.parse("2026-01-01T00:00:00Z"))).toBe(0);
35 + expect(counterValue(m, Date.parse("2027-01-01T00:00:00Z"))).toBeCloseTo(1_000_000, 3);
36 + });
37 +
38 + it("is leap-aware", () => {
39 + expect(secondsInUtcYear(2024)).toBe(366 * 86_400);
40 + expect(secondsInUtcYear(2026)).toBe(365 * 86_400);
41 + });
42 +});
43 +
44 +describe("buildSeasonalYtdModel (L1)", () => {
45 + const m = buildSeasonalYtdModel(meta, {
46 + year: 2026,
47 + annualTotal: 1_000_000,
48 + shape: [{ period: "year", order: 1, relativeAmplitude: 0.3, phase: 0.5 }],
49 + });
50 +
51 + it("keeps the annual total within 0.5 % despite seasonality", () => {
52 + const end = counterValue(m, Date.parse("2027-01-01T00:00:00Z"));
53 + expect(Math.abs(end - 1_000_000) / 1_000_000).toBeLessThan(0.005);
54 + });
55 +
56 + it("rate stays strictly positive (cumulative honesty)", () => {
57 + for (let d = 0; d < 365; d += 7) {
58 + const t = Date.parse("2026-01-01T00:00:00Z") + d * 86_400_000;
59 + expect(rateAt(m, t)).toBeGreaterThan(0);
60 + }
61 + });
62 +
63 + it("rejects shapes that would allow a negative rate", () => {
64 + expect(() =>
65 + buildSeasonalYtdModel(meta, {
66 + year: 2026,
67 + annualTotal: 1000,
68 + shape: [{ period: "year", order: 1, relativeAmplitude: 1.2, phase: 0 }],
69 + }),
70 + ).toThrow(/negative rate/);
71 + });
72 +});
73 +
74 +describe("buildStockSplineModel (L2)", () => {
75 + const m = buildStockSplineModel(meta, {
76 + observations: [
77 + { time: "2023-07-01T00:00:00.000Z", value: 8_045_000_000 },
78 + { time: "2024-07-01T00:00:00.000Z", value: 8_119_000_000 },
79 + { time: "2025-07-01T00:00:00.000Z", value: 8_192_000_000 },
80 + ],
81 + forecasts: [
82 + { time: "2026-07-01T00:00:00.000Z", value: 8_262_000_000 },
83 + { time: "2027-07-01T00:00:00.000Z", value: 8_330_000_000 },
84 + ],
85 + });
86 +
87 + it("anchors on the last real observation", () => {
88 + expect(m.anchorTime).toBe("2025-07-01T00:00:00.000Z");
89 + expect(counterValue(m, Date.parse(m.anchorTime))).toBeCloseTo(8_192_000_000, 3);
90 + });
91 +
92 + it("interpolates monotonically between obs and forecast", () => {
93 + const issues = validateModel(
94 + m,
95 + { kind: "stock", maxAbsRatePerSec: 10 },
96 + { fromMs: Date.parse("2023-07-01T00:00:00Z"), toMs: Date.parse("2027-07-01T00:00:00Z") },
97 + );
98 + expect(issues).toEqual([]);
99 + const mid = counterValue(m, Date.parse("2026-01-01T00:00:00Z"));
100 + expect(mid).toBeGreaterThan(8_192_000_000);
101 + expect(mid).toBeLessThan(8_262_000_000);
102 + });
103 +});
104 +
105 +describe("buildKeelingModel (L2 vedette)", () => {
106 + // Synthetic monthly CO₂ with known trend and cycle.
107 + const obs: Array<{ time: string; value: number }> = [];
108 + for (let y = 2021; y <= 2025; y++) {
109 + for (let mth = 0; mth < 12; mth++) {
110 + const t = Date.UTC(y, mth, 15);
111 + const tau = (t - Date.UTC(2000, 0, 1)) / 1000;
112 + const yearS = 365.2425 * 86_400;
113 + obs.push({
114 + time: new Date(t).toISOString(),
115 + value: 400 + (2.5 / yearS) * tau + 3 * Math.cos(((2 * Math.PI) / yearS) * tau - 0.7),
116 + });
117 + }
118 + }
119 + const m = buildKeelingModel(meta, { observations: obs });
120 +
121 + it("anchor equals the fitted value at the last observation", () => {
122 + const anchorMs = Date.parse(m.anchorTime);
123 + expect(counterValue(m, anchorMs)).toBeCloseTo(m.anchorValue, 6);
124 + });
125 +
126 + it("projects the seasonal cycle forward (≈ ±3 around the trend)", () => {
127 + // The synthetic cycle 3·cos(ωτ − 0.7) peaks at year-fraction 0.111 (≈ Feb 10)
128 + // and bottoms at 0.611 (≈ Aug 11): peak-to-trough ≈ 6 minus half a year of trend.
129 + const peak = counterValue(m, Date.parse("2026-02-10T00:00:00Z"));
130 + const trough = counterValue(m, Date.parse("2026-08-11T00:00:00Z"));
131 + expect(peak - trough).toBeGreaterThan(3);
132 + });
133 +});
134 +
135 +describe("guardrails", () => {
136 + it("validateModel flags a decreasing cumulative", () => {
137 + const bad = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });
138 + const withNegativeRate = {
139 + ...bad,
140 + rateFn: { kind: "linear" as const, perSecond: -1 },
141 + };
142 + const issues = validateModel(
143 + withNegativeRate,
144 + { kind: "cumulative" },
145 + { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-01-02T00:00:00Z") },
146 + );
147 + expect(issues.some((i) => i.code === "negative-rate")).toBe(true);
148 + });
149 +
150 + it("validateModel flags a rate-bound violation", () => {
151 + const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1e12 });
152 + const issues = validateModel(
153 + m,
154 + { kind: "cumulative", maxAbsRatePerSec: 10 },
155 + { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-02-01T00:00:00Z") },
156 + );
157 + expect(issues.some((i) => i.code === "rate-bound-exceeded")).toBe(true);
158 + });
159 +
160 + it("assertDeployable blocks teleportation between refits", () => {
161 + const prev = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });
162 + const next = buildStaticRtModel(meta, { value: 250, at: "2026-01-02T00:00:00.000Z" });
163 + const swapAt = Date.parse("2026-01-02T00:00:00Z");
164 + expect(diffModels(prev, next, swapAt)).toBe(150);
165 + const issues = assertDeployable(
166 + prev,
167 + next,
168 + { kind: "stock", maxJumpOnRefit: 100 },
169 + swapAt,
170 + { fromMs: swapAt, toMs: swapAt + 86_400_000 },
171 + );
172 + expect(issues.some((i) => i.code === "jump-exceeded")).toBe(true);
173 + // Within threshold: deployable.
174 + const ok = assertDeployable(
175 + prev,
176 + next,
177 + { kind: "stock", maxJumpOnRefit: 200 },
178 + swapAt,
179 + { fromMs: swapAt, toMs: swapAt + 86_400_000 },
180 + );
181 + expect(ok).toEqual([]);
182 + });
183 +});
added packages/models/test/filters.test.ts +62 −0
@@ -0,0 +1,62 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/test/filters.test.ts
6 + * Purpose: Holt-Winters and Kalman tests — nowcasting recovery and observation/forecast blending behavior
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { blendWithForecast, fitHoltWinters, kalmanFilter } from "../src/index";
11 +
12 +describe("fitHoltWinters", () => {
13 + it("recovers a clean level+trend+season series and forecasts it", () => {
14 + // 4 seasons of monthly data: level 100, trend +1/step, season = 10·sin.
15 + const m = 12;
16 + const series: number[] = [];
17 + for (let i = 0; i < 4 * m; i++) {
18 + series.push(100 + i + 10 * Math.sin((2 * Math.PI * (i % m)) / m));
19 + }
20 + const fit = fitHoltWinters(series, { alpha: 0.5, beta: 0.1, gamma: 0.3, seasonLength: m });
21 + const truthNext = (h: number) =>
22 + 100 + (series.length + h - 1) + 10 * Math.sin((2 * Math.PI * ((series.length + h - 1) % m)) / m);
23 + // Exponential smoothing carries a small lag on trending series — accept < 1.5
24 + // on a signal of amplitude 10 with level ~150.
25 + for (const h of [1, 3, 6]) {
26 + expect(Math.abs(fit.forecast(h) - truthNext(h))).toBeLessThan(1.5);
27 + }
28 + });
29 +
30 + it("rejects too-short series and bad params", () => {
31 + expect(() => fitHoltWinters([1, 2, 3], { alpha: 0.5, beta: 0.1, gamma: 0.1, seasonLength: 12 })).toThrow();
32 + expect(() =>
33 + fitHoltWinters(new Array(30).fill(1), { alpha: 1.5, beta: 0.1, gamma: 0.1, seasonLength: 12 }),
34 + ).toThrow();
35 + });
36 +});
37 +
38 +describe("kalmanFilter + blendWithForecast", () => {
39 + it("converges to a constant signal", () => {
40 + const noisy = [10.4, 9.7, 10.1, 10.3, 9.9, 10.0, 10.2, 9.8, 10.0, 10.1];
41 + const steps = kalmanFilter(noisy, {
42 + processVariance: 0.001,
43 + measurementVariance: 0.25,
44 + initialValue: 0,
45 + initialVariance: 100,
46 + });
47 + const last = steps[steps.length - 1]!;
48 + expect(last.value).toBeCloseTo(10, 0);
49 + expect(last.variance).toBeLessThan(0.1);
50 + });
51 +
52 + it("blend tracks the observation near it and the forecast far out", () => {
53 + const lastState = { value: 100, variance: 1 };
54 + const forecast = { value: 110, variance: 4 };
55 + const near = blendWithForecast(lastState, 1, 0.01, forecast);
56 + const far = blendWithForecast(lastState, 10_000, 0.01, forecast);
57 + // Near: mostly the observation; far: mostly the forecast.
58 + expect(near.value).toBeLessThan(103);
59 + expect(far.value).toBeGreaterThan(106);
60 + expect(near.variance).toBeLessThan(far.variance);
61 + });
62 +});
added packages/models/test/fourier.test.ts +81 −0
@@ -0,0 +1,81 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/models/test/fourier.test.ts
6 + * Purpose: Golden recovery tests — the Fourier fitter must recover a known synthetic Keeling-style signal
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { SEASONAL_EPOCH_MS, YEAR_SECONDS } from "@earth-now/counter";
11 +import { fitFourier } from "../src/index";
12 +
13 +/** Synthetic Keeling curve: 400 ppm at epoch + 2.5 ppm/yr + 3 ppm annual cycle. */
14 +function syntheticCo2(tMs: number): number {
15 + const tau = (tMs - SEASONAL_EPOCH_MS) / 1000;
16 + const omega = (2 * Math.PI) / YEAR_SECONDS;
17 + return 400 + (2.5 / YEAR_SECONDS) * tau + 3 * Math.cos(omega * tau - 0.7);
18 +}
19 +
20 +function monthlySeries(fromYear: number, years: number): Array<{ time: string; value: number }> {
21 + const out: Array<{ time: string; value: number }> = [];
22 + for (let y = 0; y < years; y++) {
23 + for (let m = 0; m < 12; m++) {
24 + const t = Date.UTC(fromYear + y, m, 15);
25 + out.push({ time: new Date(t).toISOString(), value: syntheticCo2(t) });
26 + }
27 + }
28 + return out;
29 +}
30 +
31 +describe("fitFourier — synthetic Keeling recovery", () => {
32 + const observations = monthlySeries(2020, 5);
33 + const fit = fitFourier({
34 + observations,
35 + harmonics: [
36 + { period: "year", order: 1 },
37 + { period: "year", order: 2 },
38 + ],
39 + });
40 +
41 + it("recovers the linear trend (~2.5 ppm/yr)", () => {
42 + expect(fit.trendPerSecond * YEAR_SECONDS).toBeCloseTo(2.5, 2);
43 + });
44 +
45 + it("reproduces the signal within 0.05 ppm at arbitrary instants", () => {
46 + for (const iso of [
47 + "2021-03-07T12:00:00Z",
48 + "2022-08-19T00:00:00Z",
49 + "2024-11-30T06:30:00Z",
50 + "2025-05-15T00:00:00Z",
51 + ]) {
52 + const t = Date.parse(iso);
53 + expect(fit.valueAt(t)).toBeCloseTo(syntheticCo2(t), 1);
54 + expect(Math.abs(fit.valueAt(t) - syntheticCo2(t))).toBeLessThan(0.05);
55 + }
56 + });
57 +
58 + it("fits with near-zero residual on noiseless input", () => {
59 + expect(fit.rmse).toBeLessThan(0.02);
60 + });
61 +
62 + it("converts the annual cycle to rate harmonics with the right amplitude", () => {
63 + // Value amplitude 3 ppm → rate amplitude 3·ω.
64 + const omega = (2 * Math.PI) / YEAR_SECONDS;
65 + const fundamental = fit.rateHarmonics.find((h) => h.order === 1);
66 + expect(fundamental).toBeDefined();
67 + expect(fundamental!.amplitude).toBeCloseTo(3 * omega, 6);
68 + });
69 +
70 + it("rejects underdetermined fits", () => {
71 + expect(() =>
72 + fitFourier({
73 + observations: observations.slice(0, 3),
74 + harmonics: [
75 + { period: "year", order: 1 },
76 + { period: "year", order: 2 },
77 + ],
78 + }),
79 + ).toThrow(/need at least/);
80 + });
81 +});
added packages/models/tsconfig.json +8 −0
@@ -0,0 +1,8 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "outDir": "dist",
5 + "rootDir": "src"
6 + },
7 + "include": ["src"]
8 +}
added packages/registry/fixtures/climate.json +66 −0
@@ -0,0 +1,66 @@
1 +{
2 + "co2_ppm": {
3 + "family": "keeling-fusion-v1",
4 + "observations": [
5 + { "time": "2023-01-15T00:00:00.000Z", "value": 420.9 },
6 + { "time": "2023-02-15T00:00:00.000Z", "value": 421.5 },
7 + { "time": "2023-03-15T00:00:00.000Z", "value": 422.2 },
8 + { "time": "2023-04-15T00:00:00.000Z", "value": 423.5 },
9 + { "time": "2023-05-15T00:00:00.000Z", "value": 424.1 },
10 + { "time": "2023-06-15T00:00:00.000Z", "value": 423.4 },
11 + { "time": "2023-07-15T00:00:00.000Z", "value": 421.7 },
12 + { "time": "2023-08-15T00:00:00.000Z", "value": 419.7 },
13 + { "time": "2023-09-15T00:00:00.000Z", "value": 418.1 },
14 + { "time": "2023-10-15T00:00:00.000Z", "value": 417.9 },
15 + { "time": "2023-11-15T00:00:00.000Z", "value": 419.1 },
16 + { "time": "2023-12-15T00:00:00.000Z", "value": 420.2 },
17 + { "time": "2024-01-15T00:00:00.000Z", "value": 424.4 },
18 + { "time": "2024-02-15T00:00:00.000Z", "value": 425.0 },
19 + { "time": "2024-03-15T00:00:00.000Z", "value": 425.7 },
20 + { "time": "2024-04-15T00:00:00.000Z", "value": 427.0 },
21 + { "time": "2024-05-15T00:00:00.000Z", "value": 427.6 },
22 + { "time": "2024-06-15T00:00:00.000Z", "value": 426.9 },
23 + { "time": "2024-07-15T00:00:00.000Z", "value": 425.2 },
24 + { "time": "2024-08-15T00:00:00.000Z", "value": 423.2 },
25 + { "time": "2024-09-15T00:00:00.000Z", "value": 421.6 },
26 + { "time": "2024-10-15T00:00:00.000Z", "value": 421.4 },
27 + { "time": "2024-11-15T00:00:00.000Z", "value": 422.6 },
28 + { "time": "2024-12-15T00:00:00.000Z", "value": 423.7 },
29 + { "time": "2025-01-15T00:00:00.000Z", "value": 427.2 },
30 + { "time": "2025-02-15T00:00:00.000Z", "value": 427.8 },
31 + { "time": "2025-03-15T00:00:00.000Z", "value": 428.5 },
32 + { "time": "2025-04-15T00:00:00.000Z", "value": 429.8 },
33 + { "time": "2025-05-15T00:00:00.000Z", "value": 430.4 },
34 + { "time": "2025-06-15T00:00:00.000Z", "value": 429.7 },
35 + { "time": "2025-07-15T00:00:00.000Z", "value": 428.0 },
36 + { "time": "2025-08-15T00:00:00.000Z", "value": 426.0 },
37 + { "time": "2025-09-15T00:00:00.000Z", "value": 424.4 },
38 + { "time": "2025-10-15T00:00:00.000Z", "value": 424.2 },
39 + { "time": "2025-11-15T00:00:00.000Z", "value": 425.4 },
40 + { "time": "2025-12-15T00:00:00.000Z", "value": 426.5 },
41 + { "time": "2026-01-15T00:00:00.000Z", "value": 429.8 },
42 + { "time": "2026-02-15T00:00:00.000Z", "value": 430.4 },
43 + { "time": "2026-03-15T00:00:00.000Z", "value": 431.1 },
44 + { "time": "2026-04-15T00:00:00.000Z", "value": 432.4 },
45 + { "time": "2026-05-15T00:00:00.000Z", "value": 433.0 },
46 + { "time": "2026-06-15T00:00:00.000Z", "value": 432.3 },
47 + { "time": "2026-07-15T00:00:00.000Z", "value": 430.6 }
48 + ],
49 + "observedAt": "2026-07-15T00:00:00.000Z"
50 + },
51 + "temp_anomaly": {
52 + "family": "seasonal-spline-v2",
53 + "observations": [
54 + { "time": "2022-07-01T00:00:00.000Z", "value": 1.17 },
55 + { "time": "2023-07-01T00:00:00.000Z", "value": 1.48 },
56 + { "time": "2024-07-01T00:00:00.000Z", "value": 1.6 },
57 + { "time": "2025-07-01T00:00:00.000Z", "value": 1.49 }
58 + ],
59 + "forecasts": [
60 + { "time": "2026-07-01T00:00:00.000Z", "value": 1.44 },
61 + { "time": "2027-07-01T00:00:00.000Z", "value": 1.47 }
62 + ],
63 + "uncertainty": { "low": 1.37, "high": 1.61 },
64 + "observedAt": "2025-07-01T00:00:00.000Z"
65 + }
66 +}
added packages/registry/fixtures/continents.json +92 −0
@@ -0,0 +1,92 @@
1 +{
2 + "continent_population_asia": {
3 + "family": "seasonal-spline-v2",
4 + "observations": [
5 + { "time": "2022-07-01T00:00:00.000Z", "value": 4722000000 },
6 + { "time": "2023-07-01T00:00:00.000Z", "value": 4751000000 },
7 + { "time": "2024-07-01T00:00:00.000Z", "value": 4779000000 },
8 + { "time": "2025-07-01T00:00:00.000Z", "value": 4806000000 }
9 + ],
10 + "forecasts": [
11 + { "time": "2026-07-01T00:00:00.000Z", "value": 4832000000 },
12 + { "time": "2027-07-01T00:00:00.000Z", "value": 4857000000 },
13 + { "time": "2028-07-01T00:00:00.000Z", "value": 4881000000 }
14 + ],
15 + "observedAt": "2025-07-01T00:00:00.000Z"
16 + },
17 + "continent_population_africa": {
18 + "family": "seasonal-spline-v2",
19 + "observations": [
20 + { "time": "2022-07-01T00:00:00.000Z", "value": 1427000000 },
21 + { "time": "2023-07-01T00:00:00.000Z", "value": 1461000000 },
22 + { "time": "2024-07-01T00:00:00.000Z", "value": 1495000000 },
23 + { "time": "2025-07-01T00:00:00.000Z", "value": 1530000000 }
24 + ],
25 + "forecasts": [
26 + { "time": "2026-07-01T00:00:00.000Z", "value": 1566000000 },
27 + { "time": "2027-07-01T00:00:00.000Z", "value": 1602000000 },
28 + { "time": "2028-07-01T00:00:00.000Z", "value": 1639000000 }
29 + ],
30 + "observedAt": "2025-07-01T00:00:00.000Z"
31 + },
32 + "continent_population_europe": {
33 + "family": "seasonal-spline-v2",
34 + "observations": [
35 + { "time": "2022-07-01T00:00:00.000Z", "value": 744500000 },
36 + { "time": "2023-07-01T00:00:00.000Z", "value": 744000000 },
37 + { "time": "2024-07-01T00:00:00.000Z", "value": 743500000 },
38 + { "time": "2025-07-01T00:00:00.000Z", "value": 742800000 }
39 + ],
40 + "forecasts": [
41 + { "time": "2026-07-01T00:00:00.000Z", "value": 742000000 },
42 + { "time": "2027-07-01T00:00:00.000Z", "value": 741100000 },
43 + { "time": "2028-07-01T00:00:00.000Z", "value": 740200000 }
44 + ],
45 + "observedAt": "2025-07-01T00:00:00.000Z"
46 + },
47 + "continent_population_latam": {
48 + "family": "seasonal-spline-v2",
49 + "observations": [
50 + { "time": "2022-07-01T00:00:00.000Z", "value": 655000000 },
51 + { "time": "2023-07-01T00:00:00.000Z", "value": 660000000 },
52 + { "time": "2024-07-01T00:00:00.000Z", "value": 665000000 },
53 + { "time": "2025-07-01T00:00:00.000Z", "value": 669000000 }
54 + ],
55 + "forecasts": [
56 + { "time": "2026-07-01T00:00:00.000Z", "value": 673000000 },
57 + { "time": "2027-07-01T00:00:00.000Z", "value": 677000000 },
58 + { "time": "2028-07-01T00:00:00.000Z", "value": 681000000 }
59 + ],
60 + "observedAt": "2025-07-01T00:00:00.000Z"
61 + },
62 + "continent_population_north_america": {
63 + "family": "seasonal-spline-v2",
64 + "observations": [
65 + { "time": "2022-07-01T00:00:00.000Z", "value": 375000000 },
66 + { "time": "2023-07-01T00:00:00.000Z", "value": 379000000 },
67 + { "time": "2024-07-01T00:00:00.000Z", "value": 383000000 },
68 + { "time": "2025-07-01T00:00:00.000Z", "value": 387000000 }
69 + ],
70 + "forecasts": [
71 + { "time": "2026-07-01T00:00:00.000Z", "value": 390000000 },
72 + { "time": "2027-07-01T00:00:00.000Z", "value": 393000000 },
73 + { "time": "2028-07-01T00:00:00.000Z", "value": 396000000 }
74 + ],
75 + "observedAt": "2025-07-01T00:00:00.000Z"
76 + },
77 + "continent_population_oceania": {
78 + "family": "seasonal-spline-v2",
79 + "observations": [
80 + { "time": "2022-07-01T00:00:00.000Z", "value": 44500000 },
81 + { "time": "2023-07-01T00:00:00.000Z", "value": 45100000 },
82 + { "time": "2024-07-01T00:00:00.000Z", "value": 45600000 },
83 + { "time": "2025-07-01T00:00:00.000Z", "value": 46200000 }
84 + ],
85 + "forecasts": [
86 + { "time": "2026-07-01T00:00:00.000Z", "value": 46700000 },
87 + { "time": "2027-07-01T00:00:00.000Z", "value": 47200000 },
88 + { "time": "2028-07-01T00:00:00.000Z", "value": 47700000 }
89 + ],
90 + "observedAt": "2025-07-01T00:00:00.000Z"
91 + }
92 +}
added packages/registry/fixtures/economy.json +55 −0
@@ -0,0 +1,55 @@
1 +{
2 + "gdp_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 118000000000000,
6 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.04, "phase": 0.796 }],
7 + "observedAt": "2026-04-15T00:00:00.000Z"
8 + },
9 + "military_spend_ytd": {
10 + "family": "linear-ytd-v1",
11 + "year": 2026,
12 + "annualTotal": 2900000000000,
13 + "observedAt": "2026-04-28T00:00:00.000Z"
14 + },
15 + "cars_produced_ytd": {
16 + "family": "seasonal-ytd-v1",
17 + "year": 2026,
18 + "annualTotal": 95000000,
19 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.08, "phase": -0.792 }],
20 + "observedAt": "2026-03-01T00:00:00.000Z"
21 + },
22 + "smartphones_sold_ytd": {
23 + "family": "seasonal-ytd-v1",
24 + "year": 2026,
25 + "annualTotal": 1240000000,
26 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.15, "phase": 0.796 }],
27 + "observedAt": "2026-07-15T00:00:00.000Z"
28 + },
29 + "cement_ytd": {
30 + "family": "seasonal-ytd-v1",
31 + "year": 2026,
32 + "annualTotal": 4100000000,
33 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.1, "phase": -2.856 }],
34 + "observedAt": "2026-01-31T00:00:00.000Z"
35 + },
36 + "steel_ytd": {
37 + "family": "seasonal-ytd-v1",
38 + "year": 2026,
39 + "annualTotal": 1850000000,
40 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.05, "phase": -2.856 }],
41 + "observedAt": "2026-06-25T00:00:00.000Z"
42 + },
43 + "ewaste_ytd": {
44 + "family": "linear-ytd-v1",
45 + "year": 2026,
46 + "annualTotal": 65000000,
47 + "observedAt": "2024-03-20T00:00:00.000Z"
48 + },
49 + "clothes_produced_ytd": {
50 + "family": "linear-ytd-v1",
51 + "year": 2026,
52 + "annualTotal": 120000000000,
53 + "observedAt": "2024-11-20T00:00:00.000Z"
54 + }
55 +}
added packages/registry/fixtures/emissions.json +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "co2_emissions_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 38200000000,
6 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.05, "phase": -0.258 }],
7 + "observedAt": "2025-11-13T00:00:00.000Z"
8 + }
9 +}
added packages/registry/fixtures/energy.json +100 −0
@@ -0,0 +1,100 @@
1 +{
2 + "electricity_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 32400000000,
6 + "shape": [
7 + {
8 + "period": "year",
9 + "order": 2,
10 + "relativeAmplitude": 0.06,
11 + "phase": -0.516
12 + },
13 + {
14 + "period": "day",
15 + "order": 1,
16 + "relativeAmplitude": 0.08,
17 + "phase": 2.8798
18 + }
19 + ],
20 + "observedAt": "2026-05-31T00:00:00.000Z"
21 + },
22 + "renewable_share": {
23 + "family": "seasonal-spline-v2",
24 + "observations": [
25 + {
26 + "time": "2022-07-01T00:00:00.000Z",
27 + "value": 29.5
28 + },
29 + {
30 + "time": "2023-07-01T00:00:00.000Z",
31 + "value": 30.3
32 + },
33 + {
34 + "time": "2024-07-01T00:00:00.000Z",
35 + "value": 32.1
36 + },
37 + {
38 + "time": "2025-07-01T00:00:00.000Z",
39 + "value": 34.4
40 + }
41 + ],
42 + "forecasts": [
43 + {
44 + "time": "2026-07-01T00:00:00.000Z",
45 + "value": 36.3
46 + },
47 + {
48 + "time": "2027-07-01T00:00:00.000Z",
49 + "value": 38.2
50 + }
51 + ],
52 + "uncertainty": {
53 + "low": 33.4,
54 + "high": 35.4
55 + },
56 + "observedAt": "2025-07-01T00:00:00.000Z"
57 + },
58 + "coal_burned_ytd": {
59 + "family": "seasonal-ytd-v1",
60 + "year": 2026,
61 + "annualTotal": 8750000000,
62 + "shape": [
63 + {
64 + "period": "year",
65 + "order": 1,
66 + "relativeAmplitude": 0.1,
67 + "phase": -0.258
68 + }
69 + ],
70 + "observedAt": "2025-12-18T00:00:00.000Z"
71 + },
72 + "oil_pumped_ytd": {
73 + "family": "seasonal-ytd-v1",
74 + "year": 2026,
75 + "annualTotal": 37900000000,
76 + "shape": [
77 + {
78 + "period": "year",
79 + "order": 1,
80 + "relativeAmplitude": 0.02,
81 + "phase": -0.258
82 + }
83 + ],
84 + "observedAt": "2026-06-30T00:00:00.000Z"
85 + },
86 + "solar_installed_ytd": {
87 + "family": "seasonal-ytd-v1",
88 + "year": 2026,
89 + "annualTotal": 600000000000,
90 + "shape": [
91 + {
92 + "period": "year",
93 + "order": 1,
94 + "relativeAmplitude": 0.2,
95 + "phase": 0.279
96 + }
97 + ],
98 + "observedAt": "2026-03-27T00:00:00.000Z"
99 + }
100 +}
\ No newline at end of file
added packages/registry/fixtures/forest.json +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "forest_loss_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 28500000,
6 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.3, "phase": 2.0857 }],
7 + "observedAt": "2026-06-30T00:00:00.000Z"
8 + }
9 +}
added packages/registry/fixtures/health.json +50 −0
@@ -0,0 +1,50 @@
1 +{
2 + "cvd_deaths_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 20500000,
6 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.06, "phase": -0.258 }],
7 + "observedAt": "2025-05-15T00:00:00.000Z"
8 + },
9 + "cancer_deaths_ytd": {
10 + "family": "seasonal-ytd-v1",
11 + "year": 2026,
12 + "annualTotal": 10000000,
13 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.02, "phase": -0.258 }],
14 + "observedAt": "2025-05-15T00:00:00.000Z"
15 + },
16 + "tobacco_deaths_ytd": {
17 + "family": "linear-ytd-v1",
18 + "year": 2026,
19 + "annualTotal": 8700000,
20 + "observedAt": "2025-07-31T00:00:00.000Z"
21 + },
22 + "malaria_deaths_ytd": {
23 + "family": "seasonal-ytd-v1",
24 + "year": 2026,
25 + "annualTotal": 600000,
26 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.25, "phase": 1.724 }],
27 + "observedAt": "2025-12-10T00:00:00.000Z"
28 + },
29 + "child_deaths_u5_ytd": {
30 + "family": "seasonal-ytd-v1",
31 + "year": 2026,
32 + "annualTotal": 4800000,
33 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.05, "phase": -0.258 }],
34 + "observedAt": "2025-03-20T00:00:00.000Z"
35 + },
36 + "road_deaths_ytd": {
37 + "family": "seasonal-ytd-v1",
38 + "year": 2026,
39 + "annualTotal": 1190000,
40 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.05, "phase": -2.856 }],
41 + "observedAt": "2023-12-13T00:00:00.000Z"
42 + },
43 + "cigarettes_ytd": {
44 + "family": "seasonal-ytd-v1",
45 + "year": 2026,
46 + "annualTotal": 5200000000000,
47 + "shape": [{ "period": "day", "order": 1, "relativeAmplitude": 0.1, "phase": 2.618 }],
48 + "observedAt": "2025-07-31T00:00:00.000Z"
49 + }
50 +}
added packages/registry/fixtures/ocean.json +67 −0
@@ -0,0 +1,67 @@
1 +{
2 + "sea_level_rise": {
3 + "family": "seasonal-spline-v2",
4 + "observations": [
5 + { "time": "2022-01-01T00:00:00.000Z", "value": 97.5 },
6 + { "time": "2023-01-01T00:00:00.000Z", "value": 101.0 },
7 + { "time": "2024-01-01T00:00:00.000Z", "value": 107.5 },
8 + { "time": "2025-01-01T00:00:00.000Z", "value": 111.5 },
9 + { "time": "2026-01-01T00:00:00.000Z", "value": 115.5 }
10 + ],
11 + "forecasts": [
12 + { "time": "2027-01-01T00:00:00.000Z", "value": 120.0 },
13 + { "time": "2028-01-01T00:00:00.000Z", "value": 124.5 }
14 + ],
15 + "uncertainty": { "low": 111.5, "high": 119.5 },
16 + "observedAt": "2026-01-01T00:00:00.000Z"
17 + },
18 + "arctic_sea_ice": {
19 + "family": "keeling-fusion-v1",
20 + "observations": [
21 + { "time": "2023-01-15T00:00:00.000Z", "value": 13500000 },
22 + { "time": "2023-02-15T00:00:00.000Z", "value": 14200000 },
23 + { "time": "2023-03-15T00:00:00.000Z", "value": 14400000 },
24 + { "time": "2023-04-15T00:00:00.000Z", "value": 13700000 },
25 + { "time": "2023-05-15T00:00:00.000Z", "value": 12700000 },
26 + { "time": "2023-06-15T00:00:00.000Z", "value": 11000000 },
27 + { "time": "2023-07-15T00:00:00.000Z", "value": 8500000 },
28 + { "time": "2023-08-15T00:00:00.000Z", "value": 6200000 },
29 + { "time": "2023-09-15T00:00:00.000Z", "value": 4600000 },
30 + { "time": "2023-10-15T00:00:00.000Z", "value": 6500000 },
31 + { "time": "2023-11-15T00:00:00.000Z", "value": 9500000 },
32 + { "time": "2023-12-15T00:00:00.000Z", "value": 11900000 },
33 + { "time": "2024-01-15T00:00:00.000Z", "value": 13450000 },
34 + { "time": "2024-02-15T00:00:00.000Z", "value": 14150000 },
35 + { "time": "2024-03-15T00:00:00.000Z", "value": 14350000 },
36 + { "time": "2024-04-15T00:00:00.000Z", "value": 13650000 },
37 + { "time": "2024-05-15T00:00:00.000Z", "value": 12650000 },
38 + { "time": "2024-06-15T00:00:00.000Z", "value": 10950000 },
39 + { "time": "2024-07-15T00:00:00.000Z", "value": 8450000 },
40 + { "time": "2024-08-15T00:00:00.000Z", "value": 6150000 },
41 + { "time": "2024-09-15T00:00:00.000Z", "value": 4550000 },
42 + { "time": "2024-10-15T00:00:00.000Z", "value": 6450000 },
43 + { "time": "2024-11-15T00:00:00.000Z", "value": 9450000 },
44 + { "time": "2024-12-15T00:00:00.000Z", "value": 11850000 },
45 + { "time": "2025-01-15T00:00:00.000Z", "value": 13400000 },
46 + { "time": "2025-02-15T00:00:00.000Z", "value": 14100000 },
47 + { "time": "2025-03-15T00:00:00.000Z", "value": 14300000 },
48 + { "time": "2025-04-15T00:00:00.000Z", "value": 13600000 },
49 + { "time": "2025-05-15T00:00:00.000Z", "value": 12600000 },
50 + { "time": "2025-06-15T00:00:00.000Z", "value": 10900000 },
51 + { "time": "2025-07-15T00:00:00.000Z", "value": 8400000 },
52 + { "time": "2025-08-15T00:00:00.000Z", "value": 6100000 },
53 + { "time": "2025-09-15T00:00:00.000Z", "value": 4500000 },
54 + { "time": "2025-10-15T00:00:00.000Z", "value": 6400000 },
55 + { "time": "2025-11-15T00:00:00.000Z", "value": 9400000 },
56 + { "time": "2025-12-15T00:00:00.000Z", "value": 11800000 },
57 + { "time": "2026-01-15T00:00:00.000Z", "value": 13350000 },
58 + { "time": "2026-02-15T00:00:00.000Z", "value": 14050000 },
59 + { "time": "2026-03-15T00:00:00.000Z", "value": 14250000 },
60 + { "time": "2026-04-15T00:00:00.000Z", "value": 13550000 },
61 + { "time": "2026-05-15T00:00:00.000Z", "value": 12550000 },
62 + { "time": "2026-06-15T00:00:00.000Z", "value": 10850000 },
63 + { "time": "2026-07-15T00:00:00.000Z", "value": 8350000 }
64 + ],
65 + "observedAt": "2026-07-15T00:00:00.000Z"
66 + }
67 +}
added packages/registry/fixtures/population-countries.json +152 −0
@@ -0,0 +1,152 @@
1 +{
2 + "country_population_india": {
3 + "family": "seasonal-spline-v2",
4 + "observations": [
5 + { "time": "2022-07-01T00:00:00.000Z", "value": 1425400000 },
6 + { "time": "2023-07-01T00:00:00.000Z", "value": 1438100000 },
7 + { "time": "2024-07-01T00:00:00.000Z", "value": 1450900000 },
8 + { "time": "2025-07-01T00:00:00.000Z", "value": 1463000000 }
9 + ],
10 + "forecasts": [
11 + { "time": "2026-07-01T00:00:00.000Z", "value": 1474600000 },
12 + { "time": "2027-07-01T00:00:00.000Z", "value": 1485700000 },
13 + { "time": "2028-07-01T00:00:00.000Z", "value": 1496300000 }
14 + ],
15 + "observedAt": "2025-07-01T00:00:00.000Z"
16 + },
17 + "country_population_china": {
18 + "family": "seasonal-spline-v2",
19 + "observations": [
20 + { "time": "2022-07-01T00:00:00.000Z", "value": 1425900000 },
21 + { "time": "2023-07-01T00:00:00.000Z", "value": 1422600000 },
22 + { "time": "2024-07-01T00:00:00.000Z", "value": 1419300000 },
23 + { "time": "2025-07-01T00:00:00.000Z", "value": 1416100000 }
24 + ],
25 + "forecasts": [
26 + { "time": "2026-07-01T00:00:00.000Z", "value": 1412400000 },
27 + { "time": "2027-07-01T00:00:00.000Z", "value": 1408300000 },
28 + { "time": "2028-07-01T00:00:00.000Z", "value": 1403800000 }
29 + ],
30 + "observedAt": "2025-07-01T00:00:00.000Z"
31 + },
32 + "country_population_usa": {
33 + "family": "seasonal-spline-v2",
34 + "observations": [
35 + { "time": "2022-07-01T00:00:00.000Z", "value": 339000000 },
36 + { "time": "2023-07-01T00:00:00.000Z", "value": 341900000 },
37 + { "time": "2024-07-01T00:00:00.000Z", "value": 345400000 },
38 + { "time": "2025-07-01T00:00:00.000Z", "value": 347300000 }
39 + ],
40 + "forecasts": [
41 + { "time": "2026-07-01T00:00:00.000Z", "value": 349200000 },
42 + { "time": "2027-07-01T00:00:00.000Z", "value": 351000000 },
43 + { "time": "2028-07-01T00:00:00.000Z", "value": 352800000 }
44 + ],
45 + "observedAt": "2025-07-01T00:00:00.000Z"
46 + },
47 + "country_population_indonesia": {
48 + "family": "seasonal-spline-v2",
49 + "observations": [
50 + { "time": "2022-07-01T00:00:00.000Z", "value": 278800000 },
51 + { "time": "2023-07-01T00:00:00.000Z", "value": 281200000 },
52 + { "time": "2024-07-01T00:00:00.000Z", "value": 283500000 },
53 + { "time": "2025-07-01T00:00:00.000Z", "value": 285700000 }
54 + ],
55 + "forecasts": [
56 + { "time": "2026-07-01T00:00:00.000Z", "value": 287900000 },
57 + { "time": "2027-07-01T00:00:00.000Z", "value": 290000000 },
58 + { "time": "2028-07-01T00:00:00.000Z", "value": 292000000 }
59 + ],
60 + "observedAt": "2025-07-01T00:00:00.000Z"
61 + },
62 + "country_population_pakistan": {
63 + "family": "seasonal-spline-v2",
64 + "observations": [
65 + { "time": "2022-07-01T00:00:00.000Z", "value": 243700000 },
66 + { "time": "2023-07-01T00:00:00.000Z", "value": 247500000 },
67 + { "time": "2024-07-01T00:00:00.000Z", "value": 251300000 },
68 + { "time": "2025-07-01T00:00:00.000Z", "value": 255200000 }
69 + ],
70 + "forecasts": [
71 + { "time": "2026-07-01T00:00:00.000Z", "value": 259200000 },
72 + { "time": "2027-07-01T00:00:00.000Z", "value": 263200000 },
73 + { "time": "2028-07-01T00:00:00.000Z", "value": 267300000 }
74 + ],
75 + "observedAt": "2025-07-01T00:00:00.000Z"
76 + },
77 + "country_population_nigeria": {
78 + "family": "seasonal-spline-v2",
79 + "observations": [
80 + { "time": "2022-07-01T00:00:00.000Z", "value": 221800000 },
81 + { "time": "2023-07-01T00:00:00.000Z", "value": 227200000 },
82 + { "time": "2024-07-01T00:00:00.000Z", "value": 232700000 },
83 + { "time": "2025-07-01T00:00:00.000Z", "value": 238300000 }
84 + ],
85 + "forecasts": [
86 + { "time": "2026-07-01T00:00:00.000Z", "value": 244000000 },
87 + { "time": "2027-07-01T00:00:00.000Z", "value": 249800000 },
88 + { "time": "2028-07-01T00:00:00.000Z", "value": 255700000 }
89 + ],
90 + "observedAt": "2025-07-01T00:00:00.000Z"
91 + },
92 + "country_population_brazil": {
93 + "family": "seasonal-spline-v2",
94 + "observations": [
95 + { "time": "2022-07-01T00:00:00.000Z", "value": 209400000 },
96 + { "time": "2023-07-01T00:00:00.000Z", "value": 210700000 },
97 + { "time": "2024-07-01T00:00:00.000Z", "value": 212000000 },
98 + { "time": "2025-07-01T00:00:00.000Z", "value": 213200000 }
99 + ],
100 + "forecasts": [
101 + { "time": "2026-07-01T00:00:00.000Z", "value": 214300000 },
102 + { "time": "2027-07-01T00:00:00.000Z", "value": 215300000 },
103 + { "time": "2028-07-01T00:00:00.000Z", "value": 216300000 }
104 + ],
105 + "observedAt": "2025-07-01T00:00:00.000Z"
106 + },
107 + "country_population_bangladesh": {
108 + "family": "seasonal-spline-v2",
109 + "observations": [
110 + { "time": "2022-07-01T00:00:00.000Z", "value": 171200000 },
111 + { "time": "2023-07-01T00:00:00.000Z", "value": 172400000 },
112 + { "time": "2024-07-01T00:00:00.000Z", "value": 173600000 },
113 + { "time": "2025-07-01T00:00:00.000Z", "value": 174700000 }
114 + ],
115 + "forecasts": [
116 + { "time": "2026-07-01T00:00:00.000Z", "value": 175800000 },
117 + { "time": "2027-07-01T00:00:00.000Z", "value": 176800000 },
118 + { "time": "2028-07-01T00:00:00.000Z", "value": 177800000 }
119 + ],
120 + "observedAt": "2025-07-01T00:00:00.000Z"
121 + },
122 + "country_population_russia": {
123 + "family": "seasonal-spline-v2",
124 + "observations": [
125 + { "time": "2022-07-01T00:00:00.000Z", "value": 145600000 },
126 + { "time": "2023-07-01T00:00:00.000Z", "value": 145200000 },
127 + { "time": "2024-07-01T00:00:00.000Z", "value": 144800000 },
128 + { "time": "2025-07-01T00:00:00.000Z", "value": 144400000 }
129 + ],
130 + "forecasts": [
131 + { "time": "2026-07-01T00:00:00.000Z", "value": 143900000 },
132 + { "time": "2027-07-01T00:00:00.000Z", "value": 143400000 },
133 + { "time": "2028-07-01T00:00:00.000Z", "value": 142900000 }
134 + ],
135 + "observedAt": "2025-07-01T00:00:00.000Z"
136 + },
137 + "country_population_mexico": {
138 + "family": "seasonal-spline-v2",
139 + "observations": [
140 + { "time": "2022-07-01T00:00:00.000Z", "value": 128600000 },
141 + { "time": "2023-07-01T00:00:00.000Z", "value": 129800000 },
142 + { "time": "2024-07-01T00:00:00.000Z", "value": 130900000 },
143 + { "time": "2025-07-01T00:00:00.000Z", "value": 132000000 }
144 + ],
145 + "forecasts": [
146 + { "time": "2026-07-01T00:00:00.000Z", "value": 133000000 },
147 + { "time": "2027-07-01T00:00:00.000Z", "value": 134000000 },
148 + { "time": "2028-07-01T00:00:00.000Z", "value": 134900000 }
149 + ],
150 + "observedAt": "2025-07-01T00:00:00.000Z"
151 + }
152 +}
added packages/registry/fixtures/population.json +38 −0
@@ -0,0 +1,38 @@
1 +{
2 + "world_population": {
3 + "family": "seasonal-spline-v2",
4 + "observations": [
5 + { "time": "2022-07-01T00:00:00.000Z", "value": 8021000000 },
6 + { "time": "2023-07-01T00:00:00.000Z", "value": 8091000000 },
7 + { "time": "2024-07-01T00:00:00.000Z", "value": 8161000000 },
8 + { "time": "2025-07-01T00:00:00.000Z", "value": 8231000000 }
9 + ],
10 + "forecasts": [
11 + { "time": "2026-07-01T00:00:00.000Z", "value": 8299000000 },
12 + { "time": "2027-07-01T00:00:00.000Z", "value": 8365000000 },
13 + { "time": "2028-07-01T00:00:00.000Z", "value": 8429000000 }
14 + ],
15 + "uncertainty": { "low": 8211000000, "high": 8251000000 },
16 + "observedAt": "2025-07-01T00:00:00.000Z"
17 + },
18 + "births_ytd": {
19 + "family": "seasonal-ytd-v1",
20 + "year": 2026,
21 + "annualTotal": 131500000,
22 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.035, "phase": 1.8449 }],
23 + "observedAt": "2025-07-01T00:00:00.000Z"
24 + },
25 + "heartbeats_ytd": {
26 + "family": "linear-ytd-v1",
27 + "year": 2026,
28 + "annualTotal": 306100000000000000,
29 + "observedAt": "2025-07-01T00:00:00.000Z"
30 + },
31 + "deaths_ytd": {
32 + "family": "seasonal-ytd-v1",
33 + "year": 2026,
34 + "annualTotal": 63000000,
35 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.06, "phase": -0.258 }],
36 + "observedAt": "2025-07-01T00:00:00.000Z"
37 + }
38 +}
added packages/registry/fixtures/realtime.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "earthquakes_24h": {
3 + "family": "static-rt-v1",
4 + "at": "2026-08-09T00:00:00.000Z",
5 + "value": 38,
6 + "observedAt": "2026-08-09T00:00:00.000Z"
7 + },
8 + "humans_in_space": {
9 + "family": "static-rt-v1",
10 + "at": "2026-08-01T00:00:00.000Z",
11 + "value": 11,
12 + "observedAt": "2026-08-01T00:00:00.000Z"
13 + }
14 +}
added packages/registry/fixtures/society.json +188 −0
@@ -0,0 +1,188 @@
1 +{
2 + "extreme_poverty": {
3 + "family": "seasonal-spline-v2",
4 + "observations": [
5 + {
6 + "time": "2022-07-01T00:00:00.000Z",
7 + "value": 715000000
8 + },
9 + {
10 + "time": "2023-07-01T00:00:00.000Z",
11 + "value": 700000000
12 + },
13 + {
14 + "time": "2024-07-01T00:00:00.000Z",
15 + "value": 692000000
16 + },
17 + {
18 + "time": "2025-07-01T00:00:00.000Z",
19 + "value": 683000000
20 + }
21 + ],
22 + "forecasts": [
23 + {
24 + "time": "2026-07-01T00:00:00.000Z",
25 + "value": 674000000
26 + },
27 + {
28 + "time": "2027-07-01T00:00:00.000Z",
29 + "value": 665000000
30 + }
31 + ],
32 + "uncertainty": {
33 + "low": 653000000,
34 + "high": 713000000
35 + },
36 + "observedAt": "2025-07-01T00:00:00.000Z"
37 + },
38 + "people_without_clean_water": {
39 + "family": "seasonal-spline-v2",
40 + "observations": [
41 + {
42 + "time": "2020-07-01T00:00:00.000Z",
43 + "value": 2260000000
44 + },
45 + {
46 + "time": "2022-07-01T00:00:00.000Z",
47 + "value": 2200000000
48 + },
49 + {
50 + "time": "2024-07-01T00:00:00.000Z",
51 + "value": 2130000000
52 + }
53 + ],
54 + "forecasts": [
55 + {
56 + "time": "2026-07-01T00:00:00.000Z",
57 + "value": 2060000000
58 + },
59 + {
60 + "time": "2028-07-01T00:00:00.000Z",
61 + "value": 1990000000
62 + }
63 + ],
64 + "uncertainty": {
65 + "low": 2050000000,
66 + "high": 2210000000
67 + },
68 + "observedAt": "2024-07-01T00:00:00.000Z"
69 + },
70 + "undernourished": {
71 + "family": "seasonal-spline-v2",
72 + "observations": [
73 + {
74 + "time": "2021-07-01T00:00:00.000Z",
75 + "value": 728000000
76 + },
77 + {
78 + "time": "2022-07-01T00:00:00.000Z",
79 + "value": 735000000
80 + },
81 + {
82 + "time": "2023-07-01T00:00:00.000Z",
83 + "value": 710000000
84 + },
85 + {
86 + "time": "2024-07-01T00:00:00.000Z",
87 + "value": 685000000
88 + }
89 + ],
90 + "forecasts": [
91 + {
92 + "time": "2026-07-01T00:00:00.000Z",
93 + "value": 650000000
94 + },
95 + {
96 + "time": "2027-07-01T00:00:00.000Z",
97 + "value": 638000000
98 + }
99 + ],
100 + "uncertainty": {
101 + "low": 630000000,
102 + "high": 740000000
103 + },
104 + "observedAt": "2024-07-01T00:00:00.000Z"
105 + },
106 + "food_waste_ytd": {
107 + "family": "linear-ytd-v1",
108 + "year": 2026,
109 + "annualTotal": 1080000000,
110 + "observedAt": "2024-03-27T00:00:00.000Z"
111 + },
112 + "water_use_ytd": {
113 + "family": "seasonal-ytd-v1",
114 + "year": 2026,
115 + "annualTotal": 4300000000000,
116 + "shape": [
117 + {
118 + "period": "year",
119 + "order": 1,
120 + "relativeAmplitude": 0.15,
121 + "phase": -2.856
122 + }
123 + ],
124 + "observedAt": "2025-11-30T00:00:00.000Z"
125 + },
126 + "animals_slaughtered_ytd": {
127 + "family": "seasonal-ytd-v1",
128 + "year": 2026,
129 + "annualTotal": 83000000000,
130 + "shape": [
131 + {
132 + "period": "year",
133 + "order": 1,
134 + "relativeAmplitude": 0.05,
135 + "phase": 0.796
136 + }
137 + ],
138 + "observedAt": "2025-12-20T00:00:00.000Z"
139 + },
140 + "fish_caught_ytd": {
141 + "family": "seasonal-ytd-v1",
142 + "year": 2026,
143 + "annualTotal": 92000000,
144 + "shape": [
145 + {
146 + "period": "year",
147 + "order": 1,
148 + "relativeAmplitude": 0.1,
149 + "phase": -2.856
150 + }
151 + ],
152 + "observedAt": "2025-06-07T00:00:00.000Z"
153 + },
154 + "coffee_cups_ytd": {
155 + "family": "seasonal-ytd-v1",
156 + "year": 2026,
157 + "annualTotal": 820000000000,
158 + "shape": [
159 + {
160 + "period": "day",
161 + "order": 1,
162 + "relativeAmplitude": 0.35,
163 + "phase": -2.618
164 + },
165 + {
166 + "period": "week",
167 + "order": 1,
168 + "relativeAmplitude": 0.05,
169 + "phase": 3.029
170 + }
171 + ],
172 + "observedAt": "2025-12-05T00:00:00.000Z"
173 + },
174 + "food_produced_ytd": {
175 + "family": "seasonal-ytd-v1",
176 + "year": 2026,
177 + "annualTotal": 9800000000,
178 + "shape": [
179 + {
180 + "period": "year",
181 + "order": 1,
182 + "relativeAmplitude": 0.2,
183 + "phase": 1.724
184 + }
185 + ],
186 + "observedAt": "2025-12-20T00:00:00.000Z"
187 + }
188 +}
\ No newline at end of file
added packages/registry/fixtures/space.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "earth_orbit_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 939290000000,
6 + "shape": [{ "period": "year", "order": 1, "relativeAmplitude": 0.0168, "phase": -0.0516 }],
7 + "observedAt": "2026-01-01T00:00:00.000Z"
8 + },
9 + "overshoot_days": {
10 + "family": "linear-stock-v1",
11 + "at": "2026-07-25T00:00:00.000Z",
12 + "value": 365,
13 + "perSecond": -0.000011574074074074073,
14 + "observedAt": "2026-06-05T00:00:00.000Z"
15 + }
16 +}
added packages/registry/fixtures/tech.json +54 −0
@@ -0,0 +1,54 @@
1 +{
2 + "emails_ytd": {
3 + "family": "seasonal-ytd-v1",
4 + "year": 2026,
5 + "annualTotal": 143100000000000,
6 + "shape": [
7 + {
8 + "period": "day",
9 + "order": 1,
10 + "relativeAmplitude": 0.15,
11 + "phase": 2.618
12 + },
13 + {
14 + "period": "week",
15 + "order": 1,
16 + "relativeAmplitude": 0.25,
17 + "phase": 3.029
18 + }
19 + ],
20 + "observedAt": "2026-02-01T00:00:00.000Z"
21 + },
22 + "google_searches_ytd": {
23 + "family": "seasonal-ytd-v1",
24 + "year": 2026,
25 + "annualTotal": 5110000000000,
26 + "shape": [
27 + {
28 + "period": "day",
29 + "order": 1,
30 + "relativeAmplitude": 0.2,
31 + "phase": 2.356
32 + },
33 + {
34 + "period": "week",
35 + "order": 1,
36 + "relativeAmplitude": 0.08,
37 + "phase": 3.029
38 + }
39 + ],
40 + "observedAt": "2025-06-30T00:00:00.000Z"
41 + },
42 + "data_created_ytd": {
43 + "family": "linear-ytd-v1",
44 + "year": 2026,
45 + "annualTotal": 200000000000,
46 + "observedAt": "2025-11-10T00:00:00.000Z"
47 + },
48 + "datacenter_electricity_ytd": {
49 + "family": "linear-ytd-v1",
50 + "year": 2026,
51 + "annualTotal": 536000000,
52 + "observedAt": "2026-04-10T00:00:00.000Z"
53 + }
54 +}
\ No newline at end of file
added packages/registry/metrics/climate.yaml +120 −0
@@ -0,0 +1,120 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/climate.yaml
5 +# Purpose: MVP climate & atmosphere metrics (catalog §3, 🥇) — CO₂ ppm (Keeling vedette), temperature anomaly, carbon budget
6 +
7 +metrics:
8 + - id: co2_ppm
9 + name: { fr: "Concentration CO₂ (Mauna Loa)", en: "CO₂ concentration (Mauna Loa)" }
10 + domain: climate
11 + priority: mvp
12 + kind: stock
13 + level: 2
14 + model: keeling-fusion-v1
15 + unit: ppm
16 + sources:
17 + - id: noaa_gml_mlo
18 + name: "NOAA Global Monitoring Laboratory — Mauna Loa"
19 + url: https://gml.noaa.gov/ccgg/trends/
20 + license: "US Government public domain (cite NOAA GML)"
21 + cadence: "daily/weekly"
22 + refresh: on_source_update
23 + display:
24 + decimals: 2
25 + sigFigs: 5
26 + unit: { fr: "ppm", en: "ppm" }
27 + constraints: { maxAbsRatePerSec: 0.00001, maxJumpOnRefit: 1 }
28 + windows: [total]
29 +
30 + - id: temp_anomaly
31 + name:
32 + fr: "Anomalie de température globale (vs 1850-1900)"
33 + en: "Global temperature anomaly (vs 1850-1900)"
34 + domain: climate
35 + priority: mvp
36 + kind: stock
37 + level: 2
38 + model: seasonal-spline-v2
39 + unit: degC
40 + sources:
41 + - id: copernicus_era5
42 + name: "Copernicus C3S / ERA5"
43 + url: https://climate.copernicus.eu/
44 + license: "Copernicus licence — free use with attribution"
45 + cadence: monthly
46 + - id: berkeley_earth
47 + name: "Berkeley Earth"
48 + url: https://berkeleyearth.org/data/
49 + license: "CC BY 4.0"
50 + cadence: monthly
51 + refresh: on_source_update
52 + display:
53 + decimals: 2
54 + sigFigs: 3
55 + unit: { fr: "°C", en: "°C" }
56 + constraints: { maxAbsRatePerSec: 0.0000001, maxJumpOnRefit: 0.05 }
57 + windows: [total]
58 + uncertaintyFraction: 0.08
59 +
60 + - id: carbon_budget_remaining
61 + name:
62 + fr: "Budget carbone 1,5 °C restant"
63 + en: "Remaining 1.5 °C carbon budget"
64 + domain: climate
65 + priority: mvp
66 + kind: derived
67 + level: derived
68 + model: derived
69 + unit: t_co2
70 + sources:
71 + - id: ipcc_ar6_gcb
72 + name: "IPCC AR6 + Global Carbon Budget (updated)"
73 + url: https://www.globalcarbonproject.org/carbonbudget/
74 + license: "CC BY 4.0"
75 + cadence: yearly
76 + refresh: on_source_update
77 + display:
78 + decimals: 1
79 + sigFigs: 3
80 + scale: 0.000000001
81 + unit: { fr: "Gt CO₂", en: "Gt CO₂" }
82 + windows: [total]
83 + derived:
84 + op: linear-combination
85 + constant: 197000000000
86 + inputs: [{ id: co2_emissions_ytd, weight: -1 }]
87 + uncertaintyFraction: 0.5
88 + editorialNote:
89 + fr: "Budget 50 % de chances de rester sous +1,5 °C, au 1er janvier 2026 — intervalle très large, affiché obligatoirement."
90 + en: "Budget for a 50 % chance of staying under +1.5 °C, as of Jan 1 2026 — very wide interval, always displayed."
91 +
92 + - id: carbon_budget_years_remaining
93 + name:
94 + fr: "Temps restant avant épuisement du budget 1,5 °C"
95 + en: "Time left before the 1.5 °C budget runs out"
96 + domain: climate
97 + priority: mvp
98 + kind: derived
99 + level: derived
100 + model: derived
101 + unit: years
102 + sources:
103 + - id: ipcc_ar6_gcb
104 + name: "IPCC AR6 + Global Carbon Budget (derived)"
105 + url: https://www.globalcarbonproject.org/carbonbudget/
106 + license: "CC BY 4.0"
107 + cadence: yearly
108 + refresh: on_source_update
109 + display:
110 + decimals: 1
111 + sigFigs: 2
112 + unit: { fr: "années", en: "years" }
113 + windows: [total]
114 + derived:
115 + op: depletion-countdown
116 + inputs: [{ id: carbon_budget_remaining, weight: 1 }]
117 + uncertaintyFraction: 0.5
118 + editorialNote:
119 + fr: "Dérivé : budget restant ÷ taux d'émission courant. Estimation, intervalle affiché."
120 + en: "Derived: remaining budget ÷ current emission rate. Estimate, interval displayed."
added packages/registry/metrics/continents.yaml +50 −0
@@ -0,0 +1,50 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/continents.yaml
5 +# Purpose: Population by continent (catalog §1 🥈) — UN WPP L2 splines, ranked live in the UI
6 +
7 +metrics:
8 + - &continent_base
9 + id: continent_population_asia
10 + name: { fr: "Asie", en: "Asia" }
11 + domain: population
12 + priority: v1
13 + kind: stock
14 + level: 2
15 + model: seasonal-spline-v2
16 + unit: people
17 + sources:
18 + - id: un_wpp_2024
19 + name: "UN World Population Prospects 2024"
20 + url: https://population.un.org/wpp/
21 + license: "CC BY 3.0 IGO"
22 + cadence: yearly
23 + variants: [low, median, high]
24 + refresh: on_source_update
25 + display:
26 + decimals: 0
27 + sigFigs: 6
28 + unit: { fr: "personnes", en: "people" }
29 + constraints: { maxAbsRatePerSec: 5, maxJumpOnRefit: 5000000 }
30 + windows: [total]
31 +
32 + - <<: *continent_base
33 + id: continent_population_africa
34 + name: { fr: "Afrique", en: "Africa" }
35 +
36 + - <<: *continent_base
37 + id: continent_population_europe
38 + name: { fr: "Europe", en: "Europe" }
39 +
40 + - <<: *continent_base
41 + id: continent_population_latam
42 + name: { fr: "Amérique latine et Caraïbes", en: "Latin America & Caribbean" }
43 +
44 + - <<: *continent_base
45 + id: continent_population_north_america
46 + name: { fr: "Amérique du Nord", en: "Northern America" }
47 +
48 + - <<: *continent_base
49 + id: continent_population_oceania
50 + name: { fr: "Océanie", en: "Oceania" }
added packages/registry/metrics/economy.yaml +231 −0
@@ -0,0 +1,231 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/economy.yaml
5 +# Purpose: Economy & consumption metrics (catalog §9 🥈/🥉) — GDP, military spend, cars, smartphones, cement, steel, e-waste, clothing
6 +
7 +metrics:
8 + - id: gdp_ytd
9 + name: { fr: "PIB mondial produit cette année", en: "World GDP produced this year" }
10 + domain: economy
11 + priority: v1
12 + kind: cumulative
13 + level: 2
14 + model: seasonal-ytd-v1
15 + unit: usd
16 + sources:
17 + - id: imf_weo
18 + name: "FMI — World Economic Outlook"
19 + url: https://www.imf.org/en/Publications/WEO
20 + license: "IMF terms — citation required"
21 + licenseNote: "verify reuse terms before launch"
22 + cadence: "2×/year"
23 + refresh: on_source_update
24 + display:
25 + decimals: 0
26 + sigFigs: 5
27 + unit: { fr: "$ US", en: "US$" }
28 + constraints: { maxAbsRatePerSec: 10000000, maxJumpOnRefit: 500000000000 }
29 + windows: [today, ytd, session]
30 + editorialNote:
31 + fr: "PIB nominal (prévision FMI ~118 000 Md$ en 2026) réparti avec une légère saisonnalité T4."
32 + en: "Nominal GDP (IMF forecast ~$118 T in 2026) spread with a mild Q4 seasonality."
33 +
34 + - id: military_spend_ytd
35 + name: { fr: "Dépenses militaires cette année", en: "Military spending this year" }
36 + domain: economy
37 + priority: v1
38 + kind: cumulative
39 + level: 0
40 + model: linear-ytd-v1
41 + unit: usd
42 + sources:
43 + - id: sipri
44 + name: "SIPRI — Military Expenditure Database"
45 + url: https://www.sipri.org/databases/milex
46 + license: "SIPRI terms — citation required"
47 + licenseNote: "verify reuse terms before launch"
48 + cadence: yearly
49 + refresh: on_source_update
50 + display:
51 + decimals: 0
52 + sigFigs: 4
53 + unit: { fr: "$ US", en: "US$" }
54 + constraints: { maxAbsRatePerSec: 200000, maxJumpOnRefit: 20000000000 }
55 + windows: [today, ytd, session]
56 +
57 + - id: cars_produced_ytd
58 + name: { fr: "Véhicules produits cette année", en: "Vehicles produced this year" }
59 + domain: economy
60 + priority: v1
61 + kind: cumulative
62 + level: 1
63 + model: seasonal-ytd-v1
64 + unit: vehicles
65 + sources:
66 + - id: oica
67 + name: "OICA — Production Statistics"
68 + url: https://www.oica.net/production-statistics/
69 + license: "OICA — citation required"
70 + licenseNote: "verify reuse terms before launch"
71 + cadence: yearly
72 + refresh: on_source_update
73 + display:
74 + decimals: 0
75 + sigFigs: 4
76 + unit: { fr: "véhicules", en: "vehicles" }
77 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 1000000 }
78 + windows: [ytd, today, session]
79 +
80 + - id: smartphones_sold_ytd
81 + name: { fr: "Smartphones vendus cette année", en: "Smartphones sold this year" }
82 + domain: economy
83 + priority: v1
84 + kind: cumulative
85 + level: 1
86 + model: seasonal-ytd-v1
87 + unit: units
88 + sources:
89 + - id: idc_counterpoint
90 + name: "IDC / Counterpoint Research"
91 + url: https://www.idc.com/
92 + license: "Market estimate — citation required"
93 + licenseNote: "verify reuse terms before launch"
94 + cadence: quarterly
95 + refresh: on_source_update
96 + display:
97 + decimals: 0
98 + sigFigs: 4
99 + unit: { fr: "unités", en: "units" }
100 + constraints: { maxAbsRatePerSec: 150, maxJumpOnRefit: 20000000 }
101 + windows: [ytd, today, session]
102 +
103 + - id: cement_ytd
104 + name: { fr: "Ciment produit cette année", en: "Cement produced this year" }
105 + domain: economy
106 + priority: v1
107 + kind: cumulative
108 + level: 1
109 + model: seasonal-ytd-v1
110 + unit: t
111 + sources:
112 + - id: usgs_cement
113 + name: "USGS — Mineral Commodity Summaries (Cement)"
114 + url: https://www.usgs.gov/centers/national-minerals-information-center/cement-statistics-and-information
115 + license: "US Government public domain"
116 + cadence: yearly
117 + refresh: on_source_update
118 + display:
119 + decimals: 0
120 + sigFigs: 4
121 + unit: { fr: "tonnes", en: "tonnes" }
122 + constraints: { maxAbsRatePerSec: 400, maxJumpOnRefit: 20000000 }
123 + windows: [ytd, today, session]
124 +
125 + - id: steel_ytd
126 + name: { fr: "Acier produit cette année", en: "Steel produced this year" }
127 + domain: economy
128 + priority: v1
129 + kind: cumulative
130 + level: 1
131 + model: seasonal-ytd-v1
132 + unit: t
133 + sources:
134 + - id: worldsteel
135 + name: "worldsteel — Monthly crude steel production"
136 + url: https://worldsteel.org/data/
137 + license: "worldsteel — citation required"
138 + licenseNote: "verify reuse terms before launch"
139 + cadence: monthly
140 + refresh: on_source_update
141 + display:
142 + decimals: 0
143 + sigFigs: 4
144 + unit: { fr: "tonnes", en: "tonnes" }
145 + constraints: { maxAbsRatePerSec: 200, maxJumpOnRefit: 10000000 }
146 + windows: [ytd, today, session]
147 +
148 + - id: ewaste_ytd
149 + name: { fr: "E-déchets générés cette année", en: "E-waste generated this year" }
150 + domain: economy
151 + priority: v1
152 + kind: cumulative
153 + level: 0
154 + model: linear-ytd-v1
155 + unit: t
156 + sources:
157 + - id: gem_ewaste
158 + name: "Global E-waste Monitor (UNITAR/ITU)"
159 + url: https://ewastemonitor.info/
160 + license: "Report — citation required"
161 + licenseNote: "verify reuse terms before launch"
162 + cadence: "~2 years"
163 + refresh: on_source_update
164 + display:
165 + decimals: 0
166 + sigFigs: 4
167 + unit: { fr: "tonnes", en: "tonnes" }
168 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 2000000 }
169 + windows: [ytd, today, session]
170 + uncertaintyFraction: 0.15
171 + editorialNote:
172 + fr: "Estimation Global E-waste Monitor (~65 Mt/an) — intervalle affiché."
173 + en: "Global E-waste Monitor estimate (~65 Mt/yr) — interval displayed."
174 +
175 + - id: clothes_produced_ytd
176 + name: { fr: "Vêtements produits cette année", en: "Garments produced this year" }
177 + domain: economy
178 + priority: v1
179 + kind: cumulative
180 + level: 0
181 + model: linear-ytd-v1
182 + unit: units
183 + sources:
184 + - id: ellen_macarthur
185 + name: "Ellen MacArthur Foundation / McKinsey (textile)"
186 + url: https://www.ellenmacarthurfoundation.org/topics/fashion/overview
187 + license: "Report estimate — citation required"
188 + licenseNote: "verify reuse terms before launch"
189 + cadence: occasional
190 + refresh: on_source_update
191 + display:
192 + decimals: 0
193 + sigFigs: 3
194 + unit: { fr: "vêtements", en: "garments" }
195 + constraints: { maxAbsRatePerSec: 10000, maxJumpOnRefit: 5000000000 }
196 + windows: [ytd, today, session]
197 + uncertaintyFraction: 0.3
198 + editorialNote:
199 + fr: "Estimation à très large intervalle (~120 Md de pièces/an) — mention « estimation » obligatoire."
200 + en: "Very wide-interval estimate (~120 B garments/yr) — 'estimate' label mandatory."
201 +
202 + - id: military_school_meals
203 + name:
204 + fr: "Repas scolaires équivalents aux dépenses militaires"
205 + en: "School meals equivalent of military spending"
206 + domain: economy
207 + priority: v1
208 + kind: derived
209 + level: derived
210 + model: derived
211 + unit: meals
212 + sources:
213 + - id: sipri_wfp
214 + name: "Juxtaposition SIPRI ÷ PAM (~0,25 $ par repas scolaire)"
215 + url: https://www.wfp.org/school-meals
216 + license: "Derived juxtaposition — cite SIPRI and WFP"
217 + licenseNote: "verify WFP cost-per-meal figure before launch"
218 + cadence: yearly
219 + refresh: on_source_update
220 + display:
221 + decimals: 0
222 + sigFigs: 4
223 + unit: { fr: "repas", en: "meals" }
224 + windows: [today, session, ytd]
225 + derived:
226 + op: linear-combination
227 + inputs: [{ id: military_spend_ytd, weight: 4.0 }]
228 + uncertaintyFraction: 0.3
229 + editorialNote:
230 + fr: "Juxtaposition assumée (signature éditoriale) : chaque dollar militaire = ~4 repas scolaires du PAM. Ordre de grandeur, intervalle affiché."
231 + en: "Deliberate juxtaposition (editorial signature): every military dollar = ~4 WFP school meals. Order of magnitude, interval displayed."
added packages/registry/metrics/emissions.yaml +79 −0
@@ -0,0 +1,79 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/emissions.yaml
5 +# Purpose: MVP emissions metrics (catalog §7, 🥇) — CO₂ emitted YTD (L2 vedette), today, and per-second rate
6 +
7 +metrics:
8 + - id: co2_emissions_ytd
9 + name:
10 + fr: "CO₂ émis cette année (fossile + ciment)"
11 + en: "CO₂ emitted this year (fossil + cement)"
12 + domain: emissions
13 + priority: mvp
14 + kind: cumulative
15 + level: 2
16 + model: seasonal-ytd-v1
17 + unit: t_co2
18 + sources:
19 + - id: gcp_2025
20 + name: "Global Carbon Project — Global Carbon Budget (annual + nowcast)"
21 + url: https://www.globalcarbonproject.org/carbonbudget/
22 + license: "CC BY 4.0"
23 + cadence: "yearly + nowcast"
24 + refresh: on_source_update
25 + display:
26 + decimals: 0
27 + sigFigs: 4
28 + unit: { fr: "t CO₂", en: "t CO₂" }
29 + constraints: { maxAbsRatePerSec: 2000, maxJumpOnRefit: 50000000 }
30 + windows: [ytd, today, session]
31 +
32 + - id: co2_emissions_today
33 + name: { fr: "CO₂ émis aujourd'hui", en: "CO₂ emitted today" }
34 + domain: emissions
35 + priority: mvp
36 + kind: derived
37 + level: derived
38 + model: derived
39 + unit: t_co2
40 + sources:
41 + - id: gcp_2025
42 + name: "Global Carbon Project (derived)"
43 + url: https://www.globalcarbonproject.org/carbonbudget/
44 + license: "CC BY 4.0"
45 + cadence: "yearly + nowcast"
46 + refresh: on_source_update
47 + display:
48 + decimals: 0
49 + sigFigs: 4
50 + unit: { fr: "t CO₂", en: "t CO₂" }
51 + windows: [today, session]
52 + derived:
53 + op: window
54 + window: today
55 + inputs: [{ id: co2_emissions_ytd, weight: 1 }]
56 +
57 + - id: co2_rate
58 + name: { fr: "CO₂ par seconde", en: "CO₂ per second" }
59 + domain: emissions
60 + priority: mvp
61 + kind: derived
62 + level: derived
63 + model: derived
64 + unit: t_co2_per_s
65 + sources:
66 + - id: gcp_2025
67 + name: "Global Carbon Project (derived rate)"
68 + url: https://www.globalcarbonproject.org/carbonbudget/
69 + license: "CC BY 4.0"
70 + cadence: "yearly + nowcast"
71 + refresh: on_source_update
72 + display:
73 + decimals: 0
74 + sigFigs: 4
75 + unit: { fr: "t CO₂/s", en: "t CO₂/s" }
76 + windows: [total]
77 + derived:
78 + op: rate-of
79 + inputs: [{ id: co2_emissions_ytd, weight: 1 }]
added packages/registry/metrics/energy.yaml +132 −0
@@ -0,0 +1,132 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/energy.yaml
5 +# Purpose: MVP energy metrics (catalog §6, 🥇) — electricity produced, renewable share, coal burned, oil pumped
6 +
7 +metrics:
8 + - id: electricity_ytd
9 + name: { fr: "Électricité produite cette année", en: "Electricity generated this year" }
10 + domain: energy
11 + priority: mvp
12 + kind: cumulative
13 + level: 1
14 + model: seasonal-ytd-v1
15 + unit: MWh
16 + sources:
17 + - id: ember_2025
18 + name: "Ember — Global Electricity Review / monthly data"
19 + url: https://ember-energy.org/
20 + license: "CC BY 4.0"
21 + cadence: monthly
22 + - id: iea_electricity
23 + name: "IEA Electricity"
24 + url: https://www.iea.org/data-and-statistics
25 + license: "CC BY 4.0"
26 + cadence: yearly
27 + refresh: on_source_update
28 + display:
29 + decimals: 2
30 + sigFigs: 5
31 + scale: 0.000001
32 + unit: { fr: "TWh", en: "TWh" }
33 + constraints: { maxAbsRatePerSec: 2000, maxJumpOnRefit: 20000000 }
34 + windows: [ytd, today, session]
35 +
36 + - id: renewable_share
37 + name:
38 + fr: "Part renouvelable de l'électricité"
39 + en: "Renewable share of electricity"
40 + domain: energy
41 + priority: mvp
42 + kind: stock
43 + level: 2
44 + model: seasonal-spline-v2
45 + unit: percent
46 + sources:
47 + - id: ember_2025
48 + name: "Ember — Global Electricity Review"
49 + url: https://ember-energy.org/
50 + license: "CC BY 4.0"
51 + cadence: monthly
52 + refresh: on_source_update
53 + display:
54 + decimals: 1
55 + sigFigs: 3
56 + unit: { fr: "%", en: "%" }
57 + constraints: { maxAbsRatePerSec: 0.000001, maxJumpOnRefit: 0.5 }
58 + windows: [total]
59 + editorialNote:
60 + fr: "v0.1 : tendance annuelle lissée — la saisonnalité intra-annuelle (hydro, solaire) sera ajoutée avec les données mensuelles Ember."
61 + en: "v0.1: smoothed annual trend — intra-year seasonality (hydro, solar) lands with Ember monthly data."
62 +
63 + - id: coal_burned_ytd
64 + name: { fr: "Charbon brûlé cette année", en: "Coal burned this year" }
65 + domain: energy
66 + priority: mvp
67 + kind: cumulative
68 + level: 1
69 + model: seasonal-ytd-v1
70 + unit: t
71 + sources:
72 + - id: iea_coal
73 + name: "IEA Coal / Energy Institute Statistical Review"
74 + url: https://www.iea.org/reports/coal-2024
75 + license: "CC BY 4.0"
76 + cadence: yearly
77 + refresh: on_source_update
78 + display:
79 + decimals: 0
80 + sigFigs: 4
81 + unit: { fr: "tonnes", en: "tonnes" }
82 + constraints: { maxAbsRatePerSec: 600, maxJumpOnRefit: 10000000 }
83 + windows: [ytd, today, session]
84 +
85 + - id: oil_pumped_ytd
86 + name: { fr: "Pétrole pompé cette année", en: "Oil pumped this year" }
87 + domain: energy
88 + priority: mvp
89 + kind: cumulative
90 + level: 1
91 + model: seasonal-ytd-v1
92 + unit: barrels
93 + sources:
94 + - id: eia_opec
95 + name: "US EIA / OPEC monthly reports"
96 + url: https://www.eia.gov/
97 + license: "US Government public domain"
98 + cadence: monthly
99 + refresh: on_source_update
100 + display:
101 + decimals: 0
102 + sigFigs: 5
103 + unit: { fr: "barils", en: "barrels" }
104 + constraints: { maxAbsRatePerSec: 2500, maxJumpOnRefit: 50000000 }
105 + windows: [ytd, today, session]
106 +
107 + - id: solar_installed_ytd
108 + name: { fr: "Capacité solaire installée cette année", en: "Solar capacity installed this year" }
109 + domain: energy
110 + priority: v1
111 + kind: cumulative
112 + level: 1
113 + model: seasonal-ytd-v1
114 + unit: W
115 + sources:
116 + - id: irena_bnef
117 + name: "IRENA / BloombergNEF — annual solar additions"
118 + url: https://www.irena.org/Data
119 + license: "IRENA open data — citation required"
120 + licenseNote: "verify reuse terms before launch"
121 + cadence: yearly
122 + refresh: on_source_update
123 + display:
124 + decimals: 0
125 + sigFigs: 4
126 + scale: 0.001
127 + unit: { fr: "kW", en: "kW" }
128 + constraints: { maxAbsRatePerSec: 30000, maxJumpOnRefit: 5000000000 }
129 + windows: [session, today, ytd]
130 + editorialNote:
131 + fr: "~600 GW ajoutés en 2026 (IRENA/BNEF), pic d'installations en fin d'année modélisé — regardez la fenêtre « depuis votre arrivée »."
132 + en: "~600 GW added in 2026 (IRENA/BNEF), year-end installation rush modelled — watch the 'since you arrived' window."
added packages/registry/metrics/forest.yaml +82 −0
@@ -0,0 +1,82 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/forest.yaml
5 +# Purpose: MVP forest metrics (catalog §5, 🥇) — tree cover loss YTD/today and football-pitch equivalent
6 +
7 +metrics:
8 + - id: forest_loss_ytd
9 + name: { fr: "Forêt perdue cette année", en: "Forest lost this year" }
10 + domain: forest
11 + priority: mvp
12 + kind: cumulative
13 + level: 1
14 + model: seasonal-ytd-v1
15 + unit: ha
16 + sources:
17 + - id: gfw_glad
18 + name: "Global Forest Watch / GLAD alerts"
19 + url: https://www.globalforestwatch.org/
20 + license: "CC BY 4.0"
21 + cadence: "weekly alerts + annual"
22 + refresh: on_source_update
23 + display:
24 + decimals: 0
25 + sigFigs: 4
26 + unit: { fr: "hectares", en: "hectares" }
27 + constraints: { maxAbsRatePerSec: 5, maxJumpOnRefit: 100000 }
28 + windows: [ytd, today, session]
29 + editorialNote:
30 + fr: "Perte brute de couvert arboré (GFW) — pas la déforestation nette ; méthode affichée au clic."
31 + en: "Gross tree-cover loss (GFW) — not net deforestation; method shown on click."
32 +
33 + - id: forest_loss_today
34 + name: { fr: "Forêt perdue aujourd'hui", en: "Forest lost today" }
35 + domain: forest
36 + priority: mvp
37 + kind: derived
38 + level: derived
39 + model: derived
40 + unit: ha
41 + sources:
42 + - id: gfw_glad
43 + name: "Global Forest Watch (derived)"
44 + url: https://www.globalforestwatch.org/
45 + license: "CC BY 4.0"
46 + cadence: "weekly alerts + annual"
47 + refresh: on_source_update
48 + display:
49 + decimals: 0
50 + sigFigs: 4
51 + unit: { fr: "hectares", en: "hectares" }
52 + windows: [today, session]
53 + derived:
54 + op: window
55 + window: today
56 + inputs: [{ id: forest_loss_ytd, weight: 1 }]
57 +
58 + - id: forest_loss_football_fields
59 + name:
60 + fr: "Équivalent terrains de football perdus"
61 + en: "Football pitches of forest lost"
62 + domain: forest
63 + priority: mvp
64 + kind: derived
65 + level: derived
66 + model: derived
67 + unit: pitches
68 + sources:
69 + - id: gfw_glad
70 + name: "Global Forest Watch (derived, 1 pitch = 0.714 ha)"
71 + url: https://www.globalforestwatch.org/
72 + license: "CC BY 4.0"
73 + cadence: "weekly alerts + annual"
74 + refresh: on_source_update
75 + display:
76 + decimals: 1
77 + sigFigs: 3
78 + unit: { fr: "terrains de football", en: "football pitches" }
79 + windows: [today, session]
80 + derived:
81 + op: linear-combination
82 + inputs: [{ id: forest_loss_ytd, weight: 1.4005602 }]
added packages/registry/metrics/health.yaml +112 −0
@@ -0,0 +1,112 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/health.yaml
5 +# Purpose: Health metrics (catalog §2 🥈) — major mortality counters (sober editorial rule) and cigarettes smoked
6 +
7 +metrics:
8 + - &health_death_base
9 + id: cvd_deaths_ytd
10 + name:
11 + fr: "Décès par maladies cardiovasculaires cette année"
12 + en: "Cardiovascular disease deaths this year"
13 + domain: health
14 + priority: v1
15 + kind: cumulative
16 + level: 1
17 + model: seasonal-ytd-v1
18 + unit: people
19 + sources:
20 + - id: ihme_gbd
21 + name: "IHME Global Burden of Disease / OMS"
22 + url: https://www.healthdata.org/research-analysis/gbd
23 + license: "IHME free-of-charge non-commercial (verify)"
24 + licenseNote: "non-commercial terms — legal review before monetization"
25 + cadence: yearly
26 + refresh: on_source_update
27 + display:
28 + decimals: 0
29 + sigFigs: 4
30 + unit: { fr: "personnes", en: "people" }
31 + constraints: { maxAbsRatePerSec: 2, maxJumpOnRefit: 200000 }
32 + windows: [ytd, today, session]
33 + editorialNote:
34 + fr: "Compteur de mortalité : ton sobre, source et méthode au clic, pas de gamification."
35 + en: "Mortality counter: sober tone, source and method on click, no gamification."
36 +
37 + - <<: *health_death_base
38 + id: cancer_deaths_ytd
39 + name: { fr: "Décès par cancer cette année", en: "Cancer deaths this year" }
40 +
41 + - <<: *health_death_base
42 + id: tobacco_deaths_ytd
43 + name: { fr: "Décès liés au tabac cette année", en: "Tobacco-related deaths this year" }
44 + level: 0
45 + model: linear-ytd-v1
46 + sources:
47 + - id: who_tobacco
48 + name: "OMS — Tobacco fact sheets"
49 + url: https://www.who.int/news-room/fact-sheets/detail/tobacco
50 + license: "CC BY-NC-SA 3.0 IGO"
51 + licenseNote: "non-commercial clause — legal review before monetization"
52 + cadence: yearly
53 +
54 + - <<: *health_death_base
55 + id: malaria_deaths_ytd
56 + name: { fr: "Décès par paludisme cette année", en: "Malaria deaths this year" }
57 + sources:
58 + - id: who_malaria
59 + name: "OMS — World Malaria Report"
60 + url: https://www.who.int/teams/global-malaria-programme/reports/world-malaria-report-2025
61 + license: "CC BY-NC-SA 3.0 IGO"
62 + licenseNote: "non-commercial clause — legal review before monetization"
63 + cadence: yearly
64 +
65 + - <<: *health_death_base
66 + id: child_deaths_u5_ytd
67 + name: { fr: "Décès d'enfants de moins de 5 ans cette année", en: "Deaths of children under 5 this year" }
68 + sources:
69 + - id: un_igme
70 + name: "UN IGME — Child Mortality Estimates"
71 + url: https://childmortality.org/
72 + license: "CC BY 4.0 (verify)"
73 + licenseNote: "verify reuse terms before launch"
74 + cadence: yearly
75 +
76 + - <<: *health_death_base
77 + id: road_deaths_ytd
78 + name: { fr: "Décès par accidents de la route cette année", en: "Road traffic deaths this year" }
79 + sources:
80 + - id: who_road
81 + name: "OMS — Global Status Report on Road Safety"
82 + url: https://www.who.int/teams/social-determinants-of-health/safety-and-mobility/global-status-report-on-road-safety-2023
83 + license: "CC BY-NC-SA 3.0 IGO"
84 + licenseNote: "non-commercial clause — legal review before monetization"
85 + cadence: "~3 years"
86 +
87 + - id: cigarettes_ytd
88 + name: { fr: "Cigarettes fumées", en: "Cigarettes smoked" }
89 + domain: health
90 + priority: v1
91 + kind: cumulative
92 + level: 1
93 + model: seasonal-ytd-v1
94 + unit: cigarettes
95 + sources:
96 + - id: who_euromonitor_tobacco
97 + name: "Dérivé OMS / Euromonitor (consommation mondiale de cigarettes)"
98 + url: https://www.who.int/news-room/fact-sheets/detail/tobacco
99 + license: "Estimate — citation required"
100 + licenseNote: "verify estimate basis before launch"
101 + cadence: yearly
102 + refresh: on_source_update
103 + display:
104 + decimals: 0
105 + sigFigs: 4
106 + unit: { fr: "cigarettes", en: "cigarettes" }
107 + constraints: { maxAbsRatePerSec: 400000, maxJumpOnRefit: 20000000000 }
108 + windows: [today, ytd, session]
109 + uncertaintyFraction: 0.15
110 + editorialNote:
111 + fr: "Estimation dérivée (~5 200 milliards/an, en baisse) — intervalle affiché."
112 + en: "Derived estimate (~5.2 trillion/yr, declining) — interval displayed."
added packages/registry/metrics/ocean.yaml +57 −0
@@ -0,0 +1,57 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/ocean.yaml
5 +# Purpose: MVP ocean & cryosphere metrics (catalog §4, 🥇) — sea level rise and Arctic sea ice extent
6 +
7 +metrics:
8 + - id: sea_level_rise
9 + name:
10 + fr: "Élévation du niveau de la mer (depuis 1993)"
11 + en: "Sea level rise (since 1993)"
12 + domain: ocean
13 + priority: mvp
14 + kind: stock
15 + level: 2
16 + model: seasonal-spline-v2
17 + unit: mm
18 + sources:
19 + - id: nasa_aviso
20 + name: "NASA Sea Level / AVISO altimetry"
21 + url: https://sealevel.nasa.gov/
22 + license: "US Government public domain (cite NASA)"
23 + cadence: monthly
24 + refresh: on_source_update
25 + display:
26 + decimals: 1
27 + sigFigs: 4
28 + unit: { fr: "mm", en: "mm" }
29 + constraints: { maxAbsRatePerSec: 0.000001, maxJumpOnRefit: 2 }
30 + windows: [total]
31 +
32 + - id: arctic_sea_ice
33 + name: { fr: "Étendue de la banquise arctique", en: "Arctic sea ice extent" }
34 + domain: ocean
35 + priority: mvp
36 + kind: stock
37 + level: 1
38 + model: keeling-fusion-v1
39 + unit: km2
40 + sources:
41 + - id: nsidc_sii
42 + name: "NSIDC Sea Ice Index"
43 + url: https://nsidc.org/data/seaice_index
44 + license: "NSIDC open data — cite NSIDC"
45 + licenseNote: "verify exact reuse terms before launch"
46 + cadence: daily
47 + refresh: on_source_update
48 + display:
49 + decimals: 2
50 + sigFigs: 3
51 + scale: 0.000001
52 + unit: { fr: "M km²", en: "M km²" }
53 + constraints: { maxAbsRatePerSec: 5, maxJumpOnRefit: 500000 }
54 + windows: [total]
55 + editorialNote:
56 + fr: "Cycle saisonnier très marqué (max ~mars, min ~septembre) ; tendance long terme à la baisse."
57 + en: "Strong seasonal cycle (max ~March, min ~September); long-term declining trend."
added packages/registry/metrics/population-countries.yaml +66 −0
@@ -0,0 +1,66 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/population-countries.yaml
5 +# Purpose: Top 10 countries by population, live (catalog §1 🥈) — UN WPP L2 splines, re-ranked live in the UI
6 +
7 +metrics:
8 + - &country_base
9 + id: country_population_india
10 + name: { fr: "Inde", en: "India" }
11 + domain: population
12 + priority: v1
13 + kind: stock
14 + level: 2
15 + model: seasonal-spline-v2
16 + unit: people
17 + sources:
18 + - id: un_wpp_2024
19 + name: "UN World Population Prospects 2024"
20 + url: https://population.un.org/wpp/
21 + license: "CC BY 3.0 IGO"
22 + cadence: yearly
23 + variants: [low, median, high]
24 + refresh: on_source_update
25 + display:
26 + decimals: 0
27 + sigFigs: 6
28 + unit: { fr: "personnes", en: "people" }
29 + constraints: { maxAbsRatePerSec: 2, maxJumpOnRefit: 2000000 }
30 + windows: [total]
31 +
32 + - <<: *country_base
33 + id: country_population_china
34 + name: { fr: "Chine", en: "China" }
35 +
36 + - <<: *country_base
37 + id: country_population_usa
38 + name: { fr: "États-Unis", en: "United States" }
39 +
40 + - <<: *country_base
41 + id: country_population_indonesia
42 + name: { fr: "Indonésie", en: "Indonesia" }
43 +
44 + - <<: *country_base
45 + id: country_population_pakistan
46 + name: { fr: "Pakistan", en: "Pakistan" }
47 +
48 + - <<: *country_base
49 + id: country_population_nigeria
50 + name: { fr: "Nigéria", en: "Nigeria" }
51 +
52 + - <<: *country_base
53 + id: country_population_brazil
54 + name: { fr: "Brésil", en: "Brazil" }
55 +
56 + - <<: *country_base
57 + id: country_population_bangladesh
58 + name: { fr: "Bangladesh", en: "Bangladesh" }
59 +
60 + - <<: *country_base
61 + id: country_population_russia
62 + name: { fr: "Russie", en: "Russia" }
63 +
64 + - <<: *country_base
65 + id: country_population_mexico
66 + name: { fr: "Mexique", en: "Mexico" }
added packages/registry/metrics/population.yaml +207 −0
@@ -0,0 +1,207 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/population.yaml
5 +# Purpose: MVP population & demography metrics (catalog §1, all 🥇) — UN WPP driven
6 +
7 +metrics:
8 + - id: world_population
9 + name: { fr: "Population mondiale", en: "World population" }
10 + domain: population
11 + priority: mvp
12 + kind: stock
13 + level: 2
14 + model: seasonal-spline-v2
15 + unit: people
16 + sources:
17 + - id: un_wpp_2024
18 + name: "UN World Population Prospects 2024"
19 + url: https://population.un.org/wpp/
20 + license: "CC BY 3.0 IGO"
21 + cadence: yearly
22 + variants: [low, median, high]
23 + refresh: on_source_update
24 + display:
25 + decimals: 0
26 + sigFigs: 7
27 + unit: { fr: "personnes", en: "people" }
28 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 5000000 }
29 + windows: [total]
30 +
31 + - id: births_ytd
32 + name: { fr: "Naissances cette année", en: "Births this year" }
33 + domain: population
34 + priority: mvp
35 + kind: cumulative
36 + level: 1
37 + model: seasonal-ytd-v1
38 + unit: people
39 + sources:
40 + - id: un_wpp_2024
41 + name: "UN World Population Prospects 2024 (derived crude birth rate)"
42 + url: https://population.un.org/wpp/
43 + license: "CC BY 3.0 IGO"
44 + cadence: yearly
45 + refresh: on_source_update
46 + display:
47 + decimals: 0
48 + sigFigs: 6
49 + unit: { fr: "personnes", en: "people" }
50 + constraints: { maxAbsRatePerSec: 8, maxJumpOnRefit: 500000 }
51 + windows: [ytd, today, session]
52 +
53 + - id: births_today
54 + name: { fr: "Naissances aujourd'hui", en: "Births today" }
55 + domain: population
56 + priority: mvp
57 + kind: derived
58 + level: derived
59 + model: derived
60 + unit: people
61 + sources:
62 + - id: un_wpp_2024
63 + name: "UN World Population Prospects 2024 (derived)"
64 + url: https://population.un.org/wpp/
65 + license: "CC BY 3.0 IGO"
66 + cadence: yearly
67 + refresh: on_source_update
68 + display:
69 + decimals: 0
70 + sigFigs: 5
71 + unit: { fr: "personnes", en: "people" }
72 + windows: [today, session]
73 + derived:
74 + op: window
75 + window: today
76 + inputs: [{ id: births_ytd, weight: 1 }]
77 +
78 + - id: deaths_ytd
79 + name: { fr: "Décès cette année", en: "Deaths this year" }
80 + domain: population
81 + priority: mvp
82 + kind: cumulative
83 + level: 1
84 + model: seasonal-ytd-v1
85 + unit: people
86 + sources:
87 + - id: un_wpp_2024
88 + name: "UN World Population Prospects 2024 (derived crude death rate)"
89 + url: https://population.un.org/wpp/
90 + license: "CC BY 3.0 IGO"
91 + cadence: yearly
92 + refresh: on_source_update
93 + display:
94 + decimals: 0
95 + sigFigs: 6
96 + unit: { fr: "personnes", en: "people" }
97 + constraints: { maxAbsRatePerSec: 4, maxJumpOnRefit: 300000 }
98 + windows: [ytd, today, session]
99 + editorialNote:
100 + fr: "Compteur de mortalité : ton sobre, source et méthode affichées au clic, pas de gamification."
101 + en: "Mortality counter: sober tone, source and method shown on click, no gamification."
102 +
103 + - id: deaths_today
104 + name: { fr: "Décès aujourd'hui", en: "Deaths today" }
105 + domain: population
106 + priority: mvp
107 + kind: derived
108 + level: derived
109 + model: derived
110 + unit: people
111 + sources:
112 + - id: un_wpp_2024
113 + name: "UN World Population Prospects 2024 (derived)"
114 + url: https://population.un.org/wpp/
115 + license: "CC BY 3.0 IGO"
116 + cadence: yearly
117 + refresh: on_source_update
118 + display:
119 + decimals: 0
120 + sigFigs: 5
121 + unit: { fr: "personnes", en: "people" }
122 + windows: [today, session]
123 + derived:
124 + op: window
125 + window: today
126 + inputs: [{ id: deaths_ytd, weight: 1 }]
127 + editorialNote:
128 + fr: "Compteur de mortalité : ton sobre, source et méthode affichées au clic, pas de gamification."
129 + en: "Mortality counter: sober tone, source and method shown on click, no gamification."
130 +
131 + - id: heartbeats_ytd
132 + name: { fr: "Battements de cœur humains cette année", en: "Human heartbeats this year" }
133 + domain: population
134 + priority: v1
135 + kind: cumulative
136 + level: 0
137 + model: linear-ytd-v1
138 + unit: beats
139 + sources:
140 + - id: derived_heartbeats
141 + name: "Dérivé pédagogique — population ONU WPP × ~70 battements/min"
142 + url: https://population.un.org/wpp/
143 + license: "CC BY 3.0 IGO (population); heart-rate factor: physiology literature"
144 + cadence: yearly
145 + refresh: on_source_update
146 + display:
147 + decimals: 0
148 + sigFigs: 4
149 + unit: { fr: "battements", en: "beats" }
150 + constraints: { maxAbsRatePerSec: 20000000000, maxJumpOnRefit: 1000000000000000 }
151 + windows: [today, ytd, session]
152 + uncertaintyFraction: 0.1
153 + editorialNote:
154 + fr: "Compteur pédagogique : population mondiale × ~70 battements/min en moyenne — ordre de grandeur, intervalle affiché."
155 + en: "Pedagogical counter: world population × ~70 beats/min on average — order of magnitude, interval displayed."
156 +
157 + - id: heartbeats_today
158 + name: { fr: "Battements de cœur aujourd'hui", en: "Heartbeats today" }
159 + domain: population
160 + priority: v1
161 + kind: derived
162 + level: derived
163 + model: derived
164 + unit: beats
165 + sources:
166 + - id: derived_heartbeats
167 + name: "Dérivé pédagogique (derived)"
168 + url: https://population.un.org/wpp/
169 + license: "CC BY 3.0 IGO (population); heart-rate factor: physiology literature"
170 + cadence: yearly
171 + refresh: on_source_update
172 + display:
173 + decimals: 0
174 + sigFigs: 4
175 + unit: { fr: "battements", en: "beats" }
176 + windows: [today, session]
177 + derived:
178 + op: window
179 + window: today
180 + inputs: [{ id: heartbeats_ytd, weight: 1 }]
181 + uncertaintyFraction: 0.1
182 +
183 + - id: net_growth_today
184 + name: { fr: "Croissance nette aujourd'hui", en: "Net population growth today" }
185 + domain: population
186 + priority: mvp
187 + kind: derived
188 + level: derived
189 + model: derived
190 + unit: people
191 + sources:
192 + - id: un_wpp_2024
193 + name: "UN World Population Prospects 2024 (births − deaths)"
194 + url: https://population.un.org/wpp/
195 + license: "CC BY 3.0 IGO"
196 + cadence: yearly
197 + refresh: on_source_update
198 + display:
199 + decimals: 0
200 + sigFigs: 5
201 + unit: { fr: "personnes", en: "people" }
202 + windows: [today, ytd, session]
203 + derived:
204 + op: linear-combination
205 + inputs:
206 + - { id: births_ytd, weight: 1 }
207 + - { id: deaths_ytd, weight: -1 }
added packages/registry/metrics/realtime.yaml +52 −0
@@ -0,0 +1,52 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/realtime.yaml
5 +# Purpose: MVP true real-time metrics (catalog §12, 🥇) — USGS earthquakes (event-driven, NO interpolation)
6 +
7 +metrics:
8 + - id: earthquakes_24h
9 + name:
10 + fr: "Séismes des dernières 24 h (M ≥ 2,5)"
11 + en: "Earthquakes in the last 24 h (M ≥ 2.5)"
12 + domain: realtime
13 + priority: mvp
14 + kind: event
15 + level: rt
16 + model: static-rt-v1
17 + unit: events
18 + sources:
19 + - id: usgs_fdsn
20 + name: "USGS FDSN Event Web Service"
21 + url: https://earthquake.usgs.gov/fdsnws/event/1/
22 + license: "US Government public domain"
23 + cadence: minutes
24 + refresh: poll_5min
25 + display:
26 + decimals: 0
27 + unit: { fr: "séismes", en: "quakes" }
28 + windows: [total]
29 + editorialNote:
30 + fr: "Vrai temps réel événementiel : aucun tick interpolé, le compteur ne bouge que sur événement USGS."
31 + en: "True event-driven real time: no interpolated ticks, the counter moves only on USGS events."
32 +
33 + - id: humans_in_space
34 + name: { fr: "Humains dans l'espace en ce moment", en: "Humans in space right now" }
35 + domain: realtime
36 + priority: mvp
37 + kind: event
38 + level: rt
39 + model: static-rt-v1
40 + unit: people
41 + sources:
42 + - id: open_notify
43 + name: "Open Notify — People in Space"
44 + url: http://api.open-notify.org/astros.json
45 + license: "Open API — cite open-notify.org"
46 + licenseNote: "verify availability; manual fallback list"
47 + cadence: event-driven
48 + refresh: poll_1h
49 + display:
50 + decimals: 0
51 + unit: { fr: "personnes", en: "people" }
52 + windows: [total]
added packages/registry/metrics/society.yaml +237 −0
@@ -0,0 +1,237 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/society.yaml
5 +# Purpose: MVP society metrics (catalog §8-9, 🥇) — extreme poverty, water access, hunger, food waste
6 +
7 +metrics:
8 + - id: extreme_poverty
9 + name:
10 + fr: "Personnes en extrême pauvreté (< 2,15 $/j)"
11 + en: "People in extreme poverty (< $2.15/day)"
12 + domain: society
13 + priority: mvp
14 + kind: stock
15 + level: 2
16 + model: seasonal-spline-v2
17 + unit: people
18 + sources:
19 + - id: wb_pip
20 + name: "World Bank — Poverty and Inequality Platform"
21 + url: https://pip.worldbank.org/
22 + license: "CC BY 4.0"
23 + cadence: yearly
24 + refresh: on_source_update
25 + display:
26 + decimals: 0
27 + sigFigs: 3
28 + unit: { fr: "personnes", en: "people" }
29 + constraints: { maxAbsRatePerSec: 5, maxJumpOnRefit: 20000000 }
30 + windows: [total]
31 +
32 + - id: people_without_clean_water
33 + name:
34 + fr: "Personnes sans eau potable gérée en sécurité"
35 + en: "People without safely managed drinking water"
36 + domain: society
37 + priority: mvp
38 + kind: stock
39 + level: 2
40 + model: seasonal-spline-v2
41 + unit: people
42 + sources:
43 + - id: jmp_wash
44 + name: "WHO/UNICEF Joint Monitoring Programme (JMP)"
45 + url: https://washdata.org/
46 + license: "CC BY 4.0"
47 + licenseNote: "verify JMP reuse terms before launch"
48 + cadence: yearly
49 + refresh: on_source_update
50 + display:
51 + decimals: 0
52 + sigFigs: 3
53 + unit: { fr: "personnes", en: "people" }
54 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 50000000 }
55 + windows: [total]
56 +
57 + - id: undernourished
58 + name: { fr: "Personnes sous-alimentées", en: "Undernourished people" }
59 + domain: society
60 + priority: mvp
61 + kind: stock
62 + level: 2
63 + model: seasonal-spline-v2
64 + unit: people
65 + sources:
66 + - id: fao_sofi
67 + name: "FAO — State of Food Security and Nutrition (SOFI)"
68 + url: https://www.fao.org/publications/sofi/
69 + license: "CC BY-NC-SA 3.0 IGO"
70 + licenseNote: "non-commercial clause — legal review required before any monetization"
71 + cadence: yearly
72 + refresh: on_source_update
73 + display:
74 + decimals: 0
75 + sigFigs: 3
76 + unit: { fr: "personnes", en: "people" }
77 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 50000000 }
78 + windows: [total]
79 + uncertaintyFraction: 0.08
80 + editorialNote:
81 + fr: "Estimation FAO avec intervalle large — l'intervalle et la mention « estimation » sont affichés."
82 + en: "FAO estimate with a wide interval — the interval and the 'estimate' label are displayed."
83 +
84 + - id: food_waste_ytd
85 + name: { fr: "Nourriture gaspillée cette année", en: "Food wasted this year" }
86 + domain: society
87 + priority: mvp
88 + kind: cumulative
89 + level: 0
90 + model: linear-ytd-v1
91 + unit: t
92 + sources:
93 + - id: unep_fwi
94 + name: "UNEP Food Waste Index"
95 + url: https://www.unep.org/resources/publication/unep-food-waste-index-report-2024
96 + license: "© UNEP — citation required"
97 + licenseNote: "verify reuse terms before launch"
98 + cadence: "~2 years"
99 + refresh: on_source_update
100 + display:
101 + decimals: 0
102 + sigFigs: 3
103 + unit: { fr: "tonnes", en: "tonnes" }
104 + constraints: { maxAbsRatePerSec: 100, maxJumpOnRefit: 5000000 }
105 + windows: [ytd, today, session]
106 + uncertaintyFraction: 0.15
107 + editorialNote:
108 + fr: "Extrapolation linéaire (L0) d'une estimation à large intervalle — mention « estimation » obligatoire."
109 + en: "Linear extrapolation (L0) of a wide-interval estimate — 'estimate' label mandatory."
110 +
111 + - id: water_use_ytd
112 + name: { fr: "Eau douce consommée cette année", en: "Freshwater used this year" }
113 + domain: society
114 + priority: v1
115 + kind: cumulative
116 + level: 1
117 + model: seasonal-ytd-v1
118 + unit: m3
119 + sources:
120 + - id: fao_aquastat
121 + name: "FAO AQUASTAT"
122 + url: https://www.fao.org/aquastat/
123 + license: "CC BY 4.0 (verify)"
124 + licenseNote: "verify reuse terms before launch"
125 + cadence: yearly
126 + refresh: on_source_update
127 + display:
128 + decimals: 0
129 + sigFigs: 5
130 + unit: { fr: "m³", en: "m³" }
131 + constraints: { maxAbsRatePerSec: 400000, maxJumpOnRefit: 50000000000 }
132 + windows: [today, ytd, session]
133 + editorialNote:
134 + fr: "Prélèvements mondiaux (~4 300 km³/an), saisonnalité d'irrigation modélisée."
135 + en: "Global withdrawals (~4,300 km³/yr), irrigation seasonality modelled."
136 +
137 + - id: animals_slaughtered_ytd
138 + name: { fr: "Animaux terrestres abattus cette année", en: "Land animals slaughtered this year" }
139 + domain: society
140 + priority: v1
141 + kind: cumulative
142 + level: 1
143 + model: seasonal-ytd-v1
144 + unit: animals
145 + sources:
146 + - id: faostat_livestock
147 + name: "FAOSTAT — Livestock Primary"
148 + url: https://www.fao.org/faostat/en/#data/QCL
149 + license: "CC BY-NC-SA 3.0 IGO"
150 + licenseNote: "non-commercial clause — legal review before monetization"
151 + cadence: yearly
152 + refresh: on_source_update
153 + display:
154 + decimals: 0
155 + sigFigs: 4
156 + unit: { fr: "animaux", en: "animals" }
157 + constraints: { maxAbsRatePerSec: 8000, maxJumpOnRefit: 1000000000 }
158 + windows: [today, ytd, session]
159 + editorialNote:
160 + fr: "Ton sobre : ~83 milliards d'animaux terrestres par an (FAOSTAT), volailles en très grande majorité."
161 + en: "Sober tone: ~83 billion land animals per year (FAOSTAT), overwhelmingly poultry."
162 +
163 + - id: fish_caught_ytd
164 + name: { fr: "Poissons pêchés cette année", en: "Fish caught this year" }
165 + domain: society
166 + priority: v1
167 + kind: cumulative
168 + level: 1
169 + model: seasonal-ytd-v1
170 + unit: t
171 + sources:
172 + - id: fao_sofia
173 + name: "FAO SOFIA — State of World Fisheries"
174 + url: https://www.fao.org/publications/sofia/
175 + license: "CC BY-NC-SA 3.0 IGO"
176 + licenseNote: "non-commercial clause — legal review before monetization"
177 + cadence: yearly
178 + refresh: on_source_update
179 + display:
180 + decimals: 0
181 + sigFigs: 4
182 + unit: { fr: "tonnes", en: "tonnes" }
183 + constraints: { maxAbsRatePerSec: 10, maxJumpOnRefit: 2000000 }
184 + windows: [ytd, today, session]
185 +
186 + - id: coffee_cups_ytd
187 + name: { fr: "Tasses de café bues", en: "Cups of coffee drunk" }
188 + domain: society
189 + priority: v1
190 + kind: cumulative
191 + level: 1
192 + model: seasonal-ytd-v1
193 + unit: cups
194 + sources:
195 + - id: ico_coffee
196 + name: "Dérivé ICO — International Coffee Organization"
197 + url: https://www.ico.org/
198 + license: "Derived estimate — citation required"
199 + licenseNote: "verify estimate basis before launch"
200 + cadence: yearly
201 + refresh: on_source_update
202 + display:
203 + decimals: 0
204 + sigFigs: 4
205 + unit: { fr: "tasses", en: "cups" }
206 + constraints: { maxAbsRatePerSec: 80000, maxJumpOnRefit: 10000000000 }
207 + windows: [today, ytd, session]
208 + uncertaintyFraction: 0.2
209 + editorialNote:
210 + fr: "Dérivé de la consommation ICO (~2,25 milliards de tasses/jour) — cycle matinal mondial modélisé, estimation."
211 + en: "Derived from ICO consumption (~2.25 billion cups/day) — global morning cycle modelled, estimate."
212 +
213 + - id: food_produced_ytd
214 + name: { fr: "Nourriture produite cette année", en: "Food produced this year" }
215 + domain: society
216 + priority: v1
217 + kind: cumulative
218 + level: 1
219 + model: seasonal-ytd-v1
220 + unit: t
221 + sources:
222 + - id: faostat_crops
223 + name: "FAOSTAT — Crops and livestock products"
224 + url: https://www.fao.org/faostat/en/#data/QCL
225 + license: "CC BY-NC-SA 3.0 IGO"
226 + licenseNote: "non-commercial clause — legal review before monetization"
227 + cadence: yearly
228 + refresh: on_source_update
229 + display:
230 + decimals: 0
231 + sigFigs: 4
232 + unit: { fr: "tonnes", en: "tonnes" }
233 + constraints: { maxAbsRatePerSec: 1500, maxJumpOnRefit: 100000000 }
234 + windows: [ytd, today, session]
235 + editorialNote:
236 + fr: "Production agricole primaire (~9,8 Gt/an), pic de récoltes hémisphère nord modélisé."
237 + en: "Primary agricultural output (~9.8 Gt/yr), northern-hemisphere harvest peak modelled."
added packages/registry/metrics/space.yaml +62 −0
@@ -0,0 +1,62 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/space.yaml
5 +# Purpose: MVP space & physical-Earth metrics (catalog §13, 🥇) — Earth's orbital distance (deterministic) and Overshoot Day countdown
6 +
7 +metrics:
8 + - id: earth_orbit_ytd
9 + name:
10 + fr: "Distance parcourue par la Terre autour du Soleil"
11 + en: "Distance Earth has travelled around the Sun"
12 + domain: space
13 + priority: mvp
14 + kind: cumulative
15 + level: 1
16 + model: seasonal-ytd-v1
17 + unit: m
18 + sources:
19 + - id: astro_calc
20 + name: "Éphémérides — calcul déterministe (JPL)"
21 + url: https://ssd.jpl.nasa.gov/
22 + license: "US Government public domain"
23 + cadence: deterministic
24 + refresh: never
25 + display:
26 + decimals: 0
27 + sigFigs: 7
28 + scale: 0.001
29 + unit: { fr: "km", en: "km" }
30 + constraints: { maxAbsRatePerSec: 31000, maxJumpOnRefit: 100000 }
31 + windows: [today, ytd, session]
32 + editorialNote:
33 + fr: "Astronomie pure : vitesse orbitale moyenne 29,78 km/s, plus rapide au périhélie (janvier) qu'à l'aphélie (juillet)."
34 + en: "Pure astronomy: mean orbital speed 29.78 km/s, faster at perihelion (January) than aphelion (July)."
35 +
36 + - id: overshoot_days
37 + name:
38 + fr: "Jours avant le prochain Earth Overshoot Day"
39 + en: "Days until the next Earth Overshoot Day"
40 + domain: space
41 + priority: mvp
42 + kind: stock
43 + level: 0
44 + model: linear-stock-v1
45 + unit: days
46 + sources:
47 + - id: gfn_overshoot
48 + name: "Global Footprint Network — Earth Overshoot Day"
49 + url: https://overshoot.footprintnetwork.org/
50 + license: "© Global Footprint Network — citation required"
51 + licenseNote: "verify reuse terms before launch"
52 + cadence: yearly
53 + refresh: on_source_update
54 + display:
55 + decimals: 0
56 + sigFigs: 3
57 + unit: { fr: "jours", en: "days" }
58 + constraints: { maxAbsRatePerSec: 0.00002, maxJumpOnRefit: 10 }
59 + windows: [total]
60 + editorialNote:
61 + fr: "Date 2027 estimée à partir de la tendance GFN — mise à jour à l'annonce officielle."
62 + en: "2027 date estimated from the GFN trend — updated on official announcement."
added packages/registry/metrics/tech.yaml +163 −0
@@ -0,0 +1,163 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: packages/registry/metrics/tech.yaml
5 +# Purpose: Technology & internet metrics (catalog §10 🥈) — emails sent and Google searches, L1 day+week cycles, wide-interval estimates
6 +
7 +metrics:
8 + - id: emails_ytd
9 + name: { fr: "Emails envoyés cette année", en: "Emails sent this year" }
10 + domain: tech
11 + priority: v1
12 + kind: cumulative
13 + level: 1
14 + model: seasonal-ytd-v1
15 + unit: emails
16 + sources:
17 + - id: radicati
18 + name: "The Radicati Group — Email Statistics Report"
19 + url: https://www.radicati.com/
20 + license: "© Radicati — market estimate, citation required"
21 + licenseNote: "verify reuse terms before launch"
22 + cadence: yearly
23 + refresh: on_source_update
24 + display:
25 + decimals: 0
26 + sigFigs: 4
27 + unit: { fr: "emails", en: "emails" }
28 + constraints: { maxAbsRatePerSec: 10000000, maxJumpOnRefit: 500000000000 }
29 + windows: [today, ytd, session]
30 + uncertaintyFraction: 0.2
31 + editorialNote:
32 + fr: "Estimation sectorielle (Radicati, ~392 Md/jour en 2026, spam inclus) — cycle jour + semaine modélisé, intervalle large affiché."
33 + en: "Industry estimate (Radicati, ~392 B/day in 2026, spam included) — day + week cycles modelled, wide interval displayed."
34 +
35 + - id: emails_today
36 + name: { fr: "Emails envoyés aujourd'hui", en: "Emails sent today" }
37 + domain: tech
38 + priority: v1
39 + kind: derived
40 + level: derived
41 + model: derived
42 + unit: emails
43 + sources:
44 + - id: radicati
45 + name: "The Radicati Group (derived)"
46 + url: https://www.radicati.com/
47 + license: "© Radicati — market estimate, citation required"
48 + licenseNote: "verify reuse terms before launch"
49 + cadence: yearly
50 + refresh: on_source_update
51 + display:
52 + decimals: 0
53 + sigFigs: 4
54 + unit: { fr: "emails", en: "emails" }
55 + windows: [today, session]
56 + derived:
57 + op: window
58 + window: today
59 + inputs: [{ id: emails_ytd, weight: 1 }]
60 + uncertaintyFraction: 0.2
61 +
62 + - id: google_searches_ytd
63 + name: { fr: "Recherches Google cette année", en: "Google searches this year" }
64 + domain: tech
65 + priority: v1
66 + kind: cumulative
67 + level: 1
68 + model: seasonal-ytd-v1
69 + unit: searches
70 + sources:
71 + - id: public_search_estimates
72 + name: "Estimations publiques (Internet Live Stats / analyses sectorielles)"
73 + url: https://www.internetlivestats.com/
74 + license: "Public estimate — citation required"
75 + licenseNote: "Google does not publish this figure; verify estimate basis before launch"
76 + cadence: occasional
77 + refresh: on_source_update
78 + display:
79 + decimals: 0
80 + sigFigs: 4
81 + unit: { fr: "recherches", en: "searches" }
82 + constraints: { maxAbsRatePerSec: 500000, maxJumpOnRefit: 50000000000 }
83 + windows: [today, ytd, session]
84 + uncertaintyFraction: 0.25
85 + editorialNote:
86 + fr: "Google ne publie pas ce chiffre — estimation publique (~14 Md/jour), intervalle large affiché obligatoirement."
87 + en: "Google does not publish this figure — public estimate (~14 B/day), wide interval always displayed."
88 +
89 + - id: google_searches_today
90 + name: { fr: "Recherches Google aujourd'hui", en: "Google searches today" }
91 + domain: tech
92 + priority: v1
93 + kind: derived
94 + level: derived
95 + model: derived
96 + unit: searches
97 + sources:
98 + - id: public_search_estimates
99 + name: "Estimations publiques (derived)"
100 + url: https://www.internetlivestats.com/
101 + license: "Public estimate — citation required"
102 + licenseNote: "verify estimate basis before launch"
103 + cadence: occasional
104 + refresh: on_source_update
105 + display:
106 + decimals: 0
107 + sigFigs: 4
108 + unit: { fr: "recherches", en: "searches" }
109 + windows: [today, session]
110 + derived:
111 + op: window
112 + window: today
113 + inputs: [{ id: google_searches_ytd, weight: 1 }]
114 + uncertaintyFraction: 0.25
115 +
116 + - id: data_created_ytd
117 + name: { fr: "Données créées cette année", en: "Data created this year" }
118 + domain: tech
119 + priority: v1
120 + kind: cumulative
121 + level: 0
122 + model: linear-ytd-v1
123 + unit: tb
124 + sources:
125 + - id: idc_datasphere
126 + name: "IDC — Global DataSphere"
127 + url: https://www.idc.com/getdoc.jsp?containerId=IDC_P38353
128 + license: "Market estimate — citation required"
129 + licenseNote: "verify reuse terms before launch"
130 + cadence: yearly
131 + refresh: on_source_update
132 + display:
133 + decimals: 0
134 + sigFigs: 4
135 + unit: { fr: "To", en: "TB" }
136 + constraints: { maxAbsRatePerSec: 20000, maxJumpOnRefit: 2000000000 }
137 + windows: [today, ytd, session]
138 + uncertaintyFraction: 0.2
139 + editorialNote:
140 + fr: "Estimation IDC (~200 Zo en 2026, croissance exponentielle lissée sur l'année) — estimation."
141 + en: "IDC estimate (~200 ZB in 2026, exponential growth flattened within the year) — estimate."
142 +
143 + - id: datacenter_electricity_ytd
144 + name: { fr: "Électricité des data centers cette année", en: "Data-center electricity this year" }
145 + domain: tech
146 + priority: v1
147 + kind: cumulative
148 + level: 0
149 + model: linear-ytd-v1
150 + unit: MWh
151 + sources:
152 + - id: iea_datacentres
153 + name: "IEA — Energy and AI / Data centres"
154 + url: https://www.iea.org/energy-system/buildings/data-centres-and-data-transmission-networks
155 + license: "CC BY 4.0"
156 + cadence: yearly
157 + refresh: on_source_update
158 + display:
159 + decimals: 0
160 + sigFigs: 5
161 + unit: { fr: "MWh", en: "MWh" }
162 + constraints: { maxAbsRatePerSec: 60, maxJumpOnRefit: 5000000 }
163 + windows: [today, ytd, session]
added packages/registry/package.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "name": "@earth-now/registry",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Metric & source registry (YAML, product source of truth) + fixtures + model fitting orchestration",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "exports": {
9 + ".": {
10 + "types": "./dist/index.d.ts",
11 + "import": "./dist/index.js"
12 + }
13 + },
14 + "main": "./dist/index.js",
15 + "types": "./dist/index.d.ts",
16 + "scripts": {
17 + "build": "tsc -p tsconfig.json",
18 + "dev": "tsc -p tsconfig.json --watch",
19 + "test": "vitest run",
20 + "typecheck": "tsc -p tsconfig.json --noEmit",
21 + "lint": "echo 'lint: covered by root header check'"
22 + },
23 + "dependencies": {
24 + "@earth-now/counter": "workspace:*",
25 + "@earth-now/models": "workspace:*",
26 + "yaml": "^2.5.0",
27 + "zod": "^3.23.8"
28 + },
29 + "devDependencies": {
30 + "@types/node": "^20",
31 + "typescript": "^5.5.4",
32 + "vitest": "^2.0.5"
33 + }
34 +}
added packages/registry/src/fit.ts +164 −0
@@ -0,0 +1,164 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/registry/src/fit.ts
6 + * Purpose: Fit a CounterModel for every registry metric from its fixture, run the guardrail validation, compose derived metrics
7 + */
8 +
9 +import type { CounterModel, DisplayHints } from "@earth-now/counter";
10 +import {
11 + type MetricConstraints,
12 + type ValidationIssue,
13 + buildKeelingModel,
14 + buildLinearStockModel,
15 + buildLinearYtdModel,
16 + buildSeasonalYtdModel,
17 + buildStaticRtModel,
18 + buildStockSplineModel,
19 + composeLinearCombination,
20 + validateModel,
21 +} from "@earth-now/models";
22 +import type { MetricEntry, MetricFixture } from "./schema.js";
23 +
24 +export interface FitOptions {
25 + /** Validation horizon (pure — the caller supplies "now"). */
26 + validateFromMs: number;
27 + validateToMs: number;
28 +}
29 +
30 +export interface FitResult {
31 + models: Map<string, CounterModel>;
32 + /** Metrics whose model FAILED validation — they must NOT be served (stale badge instead). */
33 + blocked: Array<{ metricId: string; issues: ValidationIssue[] }>;
34 +}
35 +
36 +function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints {
37 + const hints: DisplayHints = {
38 + decimals: entry.display.decimals,
39 + unit: entry.display.unit[locale],
40 + };
41 + if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs;
42 + if (entry.display.scale !== undefined) hints.scale = entry.display.scale;
43 + return hints;
44 +}
45 +
46 +function constraintsOf(entry: MetricEntry): MetricConstraints {
47 + const c: MetricConstraints = {
48 + kind: entry.kind === "cumulative" ? "cumulative" : "stock",
49 + };
50 + if (entry.constraints.maxAbsRatePerSec !== undefined)
51 + c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec;
52 + if (entry.constraints.maxJumpOnRefit !== undefined)
53 + c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit;
54 + return c;
55 +}
56 +
57 +function fitOne(entry: MetricEntry, fixture: MetricFixture): CounterModel {
58 + const primarySource = entry.sources[0];
59 + if (!primarySource) throw new Error(`Metric ${entry.id} has no source`);
60 + const meta = {
61 + metricId: entry.id,
62 + sourceId: primarySource.id,
63 + observedAt: fixture.observedAt,
64 + displayHints: displayHintsOf(entry),
65 + };
66 + switch (fixture.family) {
67 + case "seasonal-spline-v2": {
68 + const model = buildStockSplineModel(meta, {
69 + observations: fixture.observations,
70 + forecasts: fixture.forecasts,
71 + });
72 + return fixture.uncertainty ? { ...model, uncertainty: fixture.uncertainty } : model;
73 + }
74 + case "keeling-fusion-v1":
75 + return buildKeelingModel(meta, { observations: fixture.observations });
76 + case "seasonal-ytd-v1":
77 + return buildSeasonalYtdModel(meta, {
78 + year: fixture.year,
79 + annualTotal: fixture.annualTotal,
80 + shape: fixture.shape,
81 + });
82 + case "linear-ytd-v1":
83 + return buildLinearYtdModel(meta, { year: fixture.year, annualTotal: fixture.annualTotal });
84 + case "linear-stock-v1":
85 + return buildLinearStockModel(meta, {
86 + at: fixture.at,
87 + value: fixture.value,
88 + perSecond: fixture.perSecond,
89 + });
90 + case "static-rt-v1":
91 + return buildStaticRtModel(meta, { value: fixture.value, at: fixture.at });
92 + }
93 +}
94 +
95 +/**
96 + * Fit models for every metric. Non-derived metrics need a fixture; derived
97 + * metrics are composed from their inputs (linear-combination server-side; the
98 + * window / rate-of / depletion-countdown ops reuse the input's model — clients
99 + * apply the transform with the SAME evaluated function).
100 + */
101 +export function fitAllModels(
102 + metrics: MetricEntry[],
103 + fixtures: Map<string, MetricFixture>,
104 + opts: FitOptions,
105 +): FitResult {
106 + const models = new Map<string, CounterModel>();
107 + const blocked: FitResult["blocked"] = [];
108 + const horizon = { fromMs: opts.validateFromMs, toMs: opts.validateToMs };
109 +
110 + const validateAndStore = (entry: MetricEntry, model: CounterModel): void => {
111 + const issues = validateModel(model, constraintsOf(entry), horizon);
112 + if (issues.length > 0) blocked.push({ metricId: entry.id, issues });
113 + else models.set(entry.id, model);
114 + };
115 +
116 + for (const entry of metrics) {
117 + if (entry.derived) continue;
118 + const fixture = fixtures.get(entry.id);
119 + if (!fixture) {
120 + blocked.push({
121 + metricId: entry.id,
122 + issues: [{ code: "nan-value", message: "no fixture / no ingested data for this metric" }],
123 + });
124 + continue;
125 + }
126 + validateAndStore(entry, fitOne(entry, fixture));
127 + }
128 +
129 + for (const entry of metrics) {
130 + if (!entry.derived) continue;
131 + const spec = entry.derived;
132 + const inputModels = spec.inputs.map((i) => models.get(i.id));
133 + if (inputModels.some((m) => m === undefined)) {
134 + blocked.push({
135 + metricId: entry.id,
136 + issues: [{ code: "nan-value", message: "a derived input model is missing or blocked" }],
137 + });
138 + continue;
139 + }
140 + const primarySource = entry.sources[0]!;
141 + const meta = {
142 + metricId: entry.id,
143 + sourceId: primarySource.id,
144 + observedAt: inputModels[0]!.observedAt,
145 + displayHints: displayHintsOf(entry),
146 + };
147 +
148 + if (spec.op === "linear-combination") {
149 + const composed = composeLinearCombination(
150 + meta,
151 + spec.inputs.map((i, idx) => ({ model: inputModels[idx]!, weight: i.weight })),
152 + spec.constant ?? 0,
153 + );
154 + validateAndStore(entry, composed);
155 + } else {
156 + // window / rate-of / depletion-countdown: same function, re-labelled;
157 + // the client applies the transform via packages/counter helpers.
158 + const base = inputModels[0]!;
159 + validateAndStore(entry, { ...base, metricId: entry.id, displayHints: meta.displayHints });
160 + }
161 + }
162 +
163 + return { models, blocked };
164 +}
added packages/registry/src/index.ts +22 −0
@@ -0,0 +1,22 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/registry/src/index.ts
6 + * Purpose: Public entrypoint of the registry package (schemas, loader, fitting orchestration)
7 + */
8 +
9 +export {
10 + type MetricEntry,
11 + type MetricFixture,
12 + type SourceEntry,
13 + derivedSchema,
14 + displaySchema,
15 + fixtureSchema,
16 + fixturesFileSchema,
17 + metricSchema,
18 + metricsFileSchema,
19 + sourceSchema,
20 +} from "./schema.js";
21 +export { type Registry, loadFixtures, loadRegistry } from "./load.js";
22 +export { type FitOptions, type FitResult, fitAllModels } from "./fit.js";
added packages/registry/src/load.ts +79 −0
@@ -0,0 +1,79 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/registry/src/load.ts
6 + * Purpose: Load and validate the metric YAML files and JSON fixtures from disk (server-side only)
7 + */
8 +
9 +import { readFileSync, readdirSync } from "node:fs";
10 +import { dirname, join } from "node:path";
11 +import { fileURLToPath } from "node:url";
12 +import { parse as parseYaml } from "yaml";
13 +import {
14 + type MetricEntry,
15 + type MetricFixture,
16 + fixturesFileSchema,
17 + metricsFileSchema,
18 +} from "./schema.js";
19 +
20 +// load.js lives in <pkg>/dist (built) or <pkg>/src (tests) — the package root is one level up.
21 +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22 +
23 +export interface Registry {
24 + metrics: MetricEntry[];
25 + byId: Map<string, MetricEntry>;
26 +}
27 +
28 +/** Load every metrics/*.yaml file, validate, and check cross-references. */
29 +export function loadRegistry(rootDir: string = PACKAGE_ROOT): Registry {
30 + const metricsDir = join(rootDir, "metrics");
31 + const files = readdirSync(metricsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
32 + const metrics: MetricEntry[] = [];
33 + for (const file of files.sort()) {
34 + // merge: true enables YAML 1.1 merge keys (<<: *anchor) used by grouped
35 + // metric families like the top-10 country populations.
36 + const doc: unknown = parseYaml(readFileSync(join(metricsDir, file), "utf8"), { merge: true });
37 + const parsed = metricsFileSchema.safeParse(doc);
38 + if (!parsed.success) {
39 + throw new Error(`Registry file ${file} is invalid:\n${parsed.error.message}`);
40 + }
41 + metrics.push(...parsed.data.metrics);
42 + }
43 +
44 + const byId = new Map<string, MetricEntry>();
45 + for (const m of metrics) {
46 + if (byId.has(m.id)) throw new Error(`Duplicate metric id in registry: ${m.id}`);
47 + byId.set(m.id, m);
48 + }
49 + // Derived metrics must reference declared metrics.
50 + for (const m of metrics) {
51 + if (m.derived) {
52 + for (const input of m.derived.inputs) {
53 + if (!byId.has(input.id)) {
54 + throw new Error(`Metric ${m.id} derives from undeclared metric ${input.id}`);
55 + }
56 + }
57 + }
58 + }
59 + return { metrics, byId };
60 +}
61 +
62 +/** Load every fixtures/*.json file (keyed by metric id) and validate. */
63 +export function loadFixtures(rootDir: string = PACKAGE_ROOT): Map<string, MetricFixture> {
64 + const fixturesDir = join(rootDir, "fixtures");
65 + const files = readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
66 + const out = new Map<string, MetricFixture>();
67 + for (const file of files.sort()) {
68 + const doc: unknown = JSON.parse(readFileSync(join(fixturesDir, file), "utf8"));
69 + const parsed = fixturesFileSchema.safeParse(doc);
70 + if (!parsed.success) {
71 + throw new Error(`Fixture file ${file} is invalid:\n${parsed.error.message}`);
72 + }
73 + for (const [metricId, fixture] of Object.entries(parsed.data)) {
74 + if (out.has(metricId)) throw new Error(`Duplicate fixture for metric ${metricId} in ${file}`);
75 + out.set(metricId, fixture);
76 + }
77 + }
78 + return out;
79 +}
added packages/registry/src/schema.ts +136 −0
@@ -0,0 +1,136 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/registry/src/schema.ts
6 + * Purpose: Zod schemas for the metric registry YAML and the data fixtures — a metric does not exist until it validates here
7 + */
8 +
9 +import { z } from "zod";
10 +
11 +export const sourceSchema = z.object({
12 + id: z.string().min(1),
13 + name: z.string().min(1),
14 + url: z.string().url(),
15 + license: z.string().min(1),
16 + /** Set when the license needs a human re-check before public launch. */
17 + licenseNote: z.string().optional(),
18 + cadence: z.string().min(1),
19 + variants: z.array(z.string()).optional(),
20 +});
21 +
22 +export const displaySchema = z.object({
23 + decimals: z.number().int().min(0).max(6),
24 + sigFigs: z.number().int().min(1).max(15).optional(),
25 + /** Display conversion factor (t → Gt = 1e-9). NEVER applied in the pipeline. */
26 + scale: z.number().positive().optional(),
27 + unit: z.object({ fr: z.string(), en: z.string() }),
28 +});
29 +
30 +export const derivedSchema = z.object({
31 + op: z.enum(["linear-combination", "window", "rate-of", "depletion-countdown"]),
32 + inputs: z.array(z.object({ id: z.string(), weight: z.number().default(1) })).min(1),
33 + constant: z.number().optional(),
34 + /** For op=window: which window is this metric's primary reading. */
35 + window: z.enum(["today", "ytd", "session"]).optional(),
36 +});
37 +
38 +export const metricSchema = z.object({
39 + id: z.string().regex(/^[a-z0-9_]+$/),
40 + name: z.object({ fr: z.string().min(1), en: z.string().min(1) }),
41 + domain: z.enum([
42 + "population",
43 + "climate",
44 + "emissions",
45 + "forest",
46 + "ocean",
47 + "energy",
48 + "society",
49 + "health",
50 + "economy",
51 + "tech",
52 + "realtime",
53 + "space",
54 + ]),
55 + priority: z.enum(["mvp", "v1", "v2"]),
56 + kind: z.enum(["stock", "cumulative", "event", "derived"]),
57 + /** Model level from the catalog: 0/1/2 statistical, "rt" true event-driven, "derived". */
58 + level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal("rt"), z.literal("derived")]),
59 + /** Model family (must match a builder in packages/models), or "derived"/"rt". */
60 + model: z.string().min(1),
61 + /** Data-layer unit (SI or domain standard); display conversions live in display. */
62 + unit: z.string().min(1),
63 + sources: z.array(sourceSchema).min(1),
64 + refresh: z.string().min(1),
65 + display: displaySchema,
66 + constraints: z
67 + .object({
68 + maxAbsRatePerSec: z.number().positive().optional(),
69 + maxJumpOnRefit: z.number().positive().optional(),
70 + })
71 + .default({}),
72 + windows: z.array(z.enum(["total", "today", "ytd", "session"])).default(["total"]),
73 + derived: derivedSchema.optional(),
74 + /** Relative 90 % CI to surface in the UI ("estimation" label mandatory when set). */
75 + uncertaintyFraction: z.number().positive().max(1).optional(),
76 + editorialNote: z.object({ fr: z.string(), en: z.string() }).optional(),
77 +});
78 +
79 +export const metricsFileSchema = z.object({ metrics: z.array(metricSchema).min(1) });
80 +
81 +export type MetricEntry = z.infer<typeof metricSchema>;
82 +export type SourceEntry = z.infer<typeof sourceSchema>;
83 +
84 +const timeValueSchema = z.object({ time: z.string(), value: z.number() });
85 +
86 +export const fixtureSchema = z.discriminatedUnion("family", [
87 + z.object({
88 + family: z.literal("seasonal-spline-v2"),
89 + observations: z.array(timeValueSchema).min(1),
90 + forecasts: z.array(timeValueSchema).min(1),
91 + uncertainty: z.object({ low: z.number(), high: z.number() }).optional(),
92 + observedAt: z.string(),
93 + }),
94 + z.object({
95 + family: z.literal("keeling-fusion-v1"),
96 + observations: z.array(timeValueSchema).min(6),
97 + observedAt: z.string(),
98 + }),
99 + z.object({
100 + family: z.literal("seasonal-ytd-v1"),
101 + year: z.number().int(),
102 + annualTotal: z.number().nonnegative(),
103 + shape: z.array(
104 + z.object({
105 + period: z.enum(["year", "week", "day"]),
106 + order: z.number().int().min(1),
107 + relativeAmplitude: z.number(),
108 + phase: z.number(),
109 + }),
110 + ),
111 + observedAt: z.string(),
112 + }),
113 + z.object({
114 + family: z.literal("linear-ytd-v1"),
115 + year: z.number().int(),
116 + annualTotal: z.number().nonnegative(),
117 + observedAt: z.string(),
118 + }),
119 + z.object({
120 + family: z.literal("linear-stock-v1"),
121 + at: z.string(),
122 + value: z.number(),
123 + perSecond: z.number(),
124 + observedAt: z.string(),
125 + }),
126 + z.object({
127 + family: z.literal("static-rt-v1"),
128 + at: z.string(),
129 + value: z.number(),
130 + observedAt: z.string(),
131 + }),
132 +]);
133 +
134 +export type MetricFixture = z.infer<typeof fixtureSchema>;
135 +
136 +export const fixturesFileSchema = z.record(z.string(), fixtureSchema);
added packages/registry/test/registry.test.ts +131 −0
@@ -0,0 +1,131 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/registry/test/registry.test.ts
6 + * Purpose: Integration — every declared MVP metric loads, fits, passes guardrails and yields sane values
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import { counterValue, rateAt, windowValue } from "@earth-now/counter";
11 +import { fitAllModels, loadFixtures, loadRegistry } from "../src/index";
12 +
13 +const NOW = Date.parse("2026-08-09T12:00:00Z");
14 +
15 +describe("registry", () => {
16 + const registry = loadRegistry();
17 + const fixtures = loadFixtures();
18 +
19 + it("declares every 🥇 MVP metric of the catalog", () => {
20 + const ids = registry.metrics.map((m) => m.id);
21 + for (const required of [
22 + "world_population",
23 + "births_ytd",
24 + "births_today",
25 + "deaths_ytd",
26 + "deaths_today",
27 + "net_growth_today",
28 + "co2_ppm",
29 + "temp_anomaly",
30 + "carbon_budget_remaining",
31 + "carbon_budget_years_remaining",
32 + "co2_emissions_ytd",
33 + "co2_emissions_today",
34 + "co2_rate",
35 + "forest_loss_ytd",
36 + "forest_loss_today",
37 + "forest_loss_football_fields",
38 + "sea_level_rise",
39 + "arctic_sea_ice",
40 + "electricity_ytd",
41 + "renewable_share",
42 + "coal_burned_ytd",
43 + "oil_pumped_ytd",
44 + "extreme_poverty",
45 + "people_without_clean_water",
46 + "undernourished",
47 + "food_waste_ytd",
48 + "earthquakes_24h",
49 + "humans_in_space",
50 + "earth_orbit_ytd",
51 + "overshoot_days",
52 + ]) {
53 + expect(ids, `missing metric ${required}`).toContain(required);
54 + }
55 + });
56 +
57 + it("every non-derived metric has a fixture and every source has a license", () => {
58 + for (const m of registry.metrics) {
59 + if (!m.derived) {
60 + expect(fixtures.has(m.id), `no fixture for ${m.id}`).toBe(true);
61 + }
62 + for (const s of m.sources) expect(s.license.length).toBeGreaterThan(0);
63 + }
64 + });
65 +
66 + const { models, blocked } = fitAllModels(registry.metrics, fixtures, {
67 + validateFromMs: Date.parse("2026-01-01T00:00:00Z"),
68 + validateToMs: Date.parse("2027-01-01T00:00:00Z"),
69 + });
70 +
71 + it("fits ALL metrics with zero guardrail violations", () => {
72 + expect(blocked).toEqual([]);
73 + expect(models.size).toBe(registry.metrics.length);
74 + });
75 +
76 + it("produces sane values at a reference instant (2026-08-09 12:00 UTC)", () => {
77 + const v = (id: string) => counterValue(models.get(id)!, NOW);
78 +
79 + expect(v("world_population")).toBeGreaterThan(8.2e9);
80 + expect(v("world_population")).toBeLessThan(8.35e9);
81 +
82 + expect(v("co2_ppm")).toBeGreaterThan(424);
83 + expect(v("co2_ppm")).toBeLessThan(434);
84 +
85 + // ~60 % of the year elapsed: YTD counters sit between 50 % and 70 % of the annual total.
86 + expect(v("births_ytd")).toBeGreaterThan(131_500_000 * 0.5);
87 + expect(v("births_ytd")).toBeLessThan(131_500_000 * 0.7);
88 + expect(v("co2_emissions_ytd")).toBeGreaterThan(38.2e9 * 0.5);
89 + expect(v("co2_emissions_ytd")).toBeLessThan(38.2e9 * 0.7);
90 +
91 + expect(v("temp_anomaly")).toBeGreaterThan(1.3);
92 + expect(v("temp_anomaly")).toBeLessThan(1.6);
93 +
94 + // Overshoot Day 2026 was Jul 25 — ~350 days until the next one.
95 + expect(v("overshoot_days")).toBeGreaterThan(345);
96 + expect(v("overshoot_days")).toBeLessThan(355);
97 +
98 + // Net growth today (composed births − deaths) ticks at ~2.2 people/s.
99 + const net = models.get("net_growth_today")!;
100 + expect(rateAt(net, NOW)).toBeGreaterThan(1.5);
101 + expect(rateAt(net, NOW)).toBeLessThan(3);
102 + expect(windowValue(net, NOW, "today")).toBeGreaterThan(0);
103 +
104 + // Carbon budget decreasing, in a plausible band.
105 + const budget = models.get("carbon_budget_remaining")!;
106 + expect(counterValue(budget, NOW)).toBeLessThan(197e9);
107 + expect(counterValue(budget, NOW)).toBeGreaterThan(150e9);
108 + expect(rateAt(budget, NOW)).toBeLessThan(0);
109 + });
110 +
111 + it("cumulative windows behave (today ≪ ytd, both > 0)", () => {
112 + for (const id of ["births_ytd", "co2_emissions_ytd", "forest_loss_ytd", "electricity_ytd"]) {
113 + const m = models.get(id)!;
114 + const today = windowValue(m, NOW, "today");
115 + const ytd = windowValue(m, NOW, "ytd");
116 + expect(today).toBeGreaterThan(0);
117 + expect(ytd).toBeGreaterThan(today);
118 + }
119 + });
120 +
121 + it("wide-uncertainty metrics declare their interval (honesty rule)", () => {
122 + for (const id of ["food_waste_ytd", "undernourished", "carbon_budget_remaining"]) {
123 + const entry = registry.byId.get(id)!;
124 + expect(
125 + entry.uncertaintyFraction !== undefined || fixtures.get(id) !== undefined,
126 + `${id} must declare uncertainty`,
127 + ).toBe(true);
128 + }
129 + expect(registry.byId.get("food_waste_ytd")!.uncertaintyFraction).toBeDefined();
130 + });
131 +});
added packages/registry/tsconfig.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "outDir": "dist",
5 + "rootDir": "src",
6 + "module": "NodeNext",
7 + "moduleResolution": "NodeNext"
8 + },
9 + "include": ["src"]
10 +}
added packages/widget/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "@earth-now/widget",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "Embeddable widget.js — self-contained IIFE counter (< 15 KB gzip, zero runtime dependencies)",
7 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
8 + "scripts": {
9 + "build": "esbuild src/widget.ts --bundle --minify --format=iife --outfile=dist/widget.js && node scripts/check-size.mjs",
10 + "dev": "esbuild src/widget.ts --bundle --format=iife --outfile=dist/widget.js --watch",
11 + "test": "vitest run",
12 + "typecheck": "tsc -p tsconfig.json --noEmit",
13 + "lint": "echo 'lint: covered by root header check'"
14 + },
15 + "dependencies": {
16 + "@earth-now/counter": "workspace:*"
17 + },
18 + "devDependencies": {
19 + "@types/node": "^20",
20 + "esbuild": "^0.23.1",
21 + "typescript": "^5.5.4",
22 + "vitest": "^2.0.5"
23 + }
24 +}
added packages/widget/scripts/check-size.mjs +34 −0
@@ -0,0 +1,34 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/widget/scripts/check-size.mjs
6 + * Purpose: Build gate — fail the widget build if dist/widget.js exceeds the 15 KB gzip budget
7 + */
8 +
9 +import { readFileSync } from "node:fs";
10 +import { gzipSync } from "node:zlib";
11 +
12 +const LIMIT_BYTES = 15 * 1024;
13 +
14 +const bundlePath = new URL("../dist/widget.js", import.meta.url);
15 +let raw;
16 +try {
17 + raw = readFileSync(bundlePath);
18 +} catch {
19 + console.error("✗ check-size: dist/widget.js not found — run the esbuild step first");
20 + process.exit(1);
21 +}
22 +
23 +const gzipped = gzipSync(raw, { level: 9 }).length;
24 +console.log(
25 + `widget.js: ${raw.length} B raw, ${gzipped} B gzip (budget ${LIMIT_BYTES} B gzip)`,
26 +);
27 +
28 +if (gzipped > LIMIT_BYTES) {
29 + console.error(
30 + `✗ widget.js is ${gzipped - LIMIT_BYTES} B over the 15 KB gzip budget — trim it before shipping`,
31 + );
32 + process.exit(1);
33 +}
34 +console.log("✓ widget.js is within the 15 KB gzip budget");
added packages/widget/src/render.ts +103 −0
@@ -0,0 +1,103 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/widget/src/render.ts
6 + * Purpose: Pure, DOM-free widget helpers — config resolution, model payload parsing, display text building (unit-testable)
7 + */
8 +
9 +import {
10 + type CounterModel,
11 + type CounterWindow,
12 + formatRate,
13 + formatValue,
14 + rateAt,
15 + windowValue,
16 +} from "@earth-now/counter";
17 +
18 +/** Default API origin the widget talks to when data-api is not provided. */
19 +export const DEFAULT_API = "https://www.earth-now.co";
20 +
21 +export interface WidgetConfig {
22 + metricId: string;
23 + window: CounterWindow;
24 + lang: string;
25 + api: string;
26 +}
27 +
28 +/**
29 + * Resolve a widget config from a DOMStringMap-like object
30 + * (data-earth-now-metric / data-window / data-lang / data-api).
31 + * Returns null when no metric id is declared.
32 + */
33 +export function resolveConfig(dataset: Partial<Record<string, string>>): WidgetConfig | null {
34 + const metricId = dataset["earthNowMetric"]?.trim();
35 + if (!metricId) return null;
36 + const w = dataset["window"];
37 + const window: CounterWindow =
38 + w === "today" || w === "ytd" || w === "session" || w === "total" ? w : "total";
39 + return {
40 + metricId,
41 + window,
42 + lang: dataset["lang"] ?? "en",
43 + api: (dataset["api"] ?? DEFAULT_API).replace(/\/+$/, ""),
44 + };
45 +}
46 +
47 +/** URL of the CounterModel endpoint for a config. */
48 +export function modelUrl(config: WidgetConfig): string {
49 + return `${config.api}/v1/metrics/${encodeURIComponent(config.metricId)}/model`;
50 +}
51 +
52 +/**
53 + * Validate a fetched JSON payload into a CounterModel.
54 + * Accepts either the model itself or an `{ model: ... }` envelope.
55 + * Throws a descriptive error on anything malformed — the caller renders "data pending".
56 + */
57 +export function parseModelPayload(json: unknown): CounterModel {
58 + const candidate =
59 + typeof json === "object" && json !== null && "model" in json
60 + ? (json as { model: unknown }).model
61 + : json;
62 + if (typeof candidate !== "object" || candidate === null)
63 + throw new Error("earth-now widget: model payload is not an object");
64 + const m = candidate as Partial<CounterModel>;
65 + if (typeof m.metricId !== "string") throw new Error("earth-now widget: model missing metricId");
66 + if (typeof m.anchorValue !== "number" || !Number.isFinite(m.anchorValue))
67 + throw new Error("earth-now widget: model missing finite anchorValue");
68 + if (typeof m.anchorTime !== "string" || Number.isNaN(Date.parse(m.anchorTime)))
69 + throw new Error("earth-now widget: model missing valid anchorTime");
70 + if (typeof m.rateFn !== "object" || m.rateFn === null || typeof m.rateFn.kind !== "string")
71 + throw new Error("earth-now widget: model missing rateFn");
72 + if (typeof m.displayHints !== "object" || m.displayHints === null)
73 + throw new Error("earth-now widget: model missing displayHints");
74 + return m as CounterModel;
75 +}
76 +
77 +export interface DisplayText {
78 + /** Formatted counter value (applySigFigs false — the ticker animates the trailing digits). */
79 + value: string;
80 + /** Formatted instantaneous rate incl. cadence suffix, e.g. "4.3/s". */
81 + rate: string;
82 + /** Display unit label from the model's display hints. */
83 + unit: string;
84 +}
85 +
86 +/**
87 + * Compute the text a widget shows at instant tMs — pure, time is a parameter.
88 + * "session" windows use the caller-provided session start (widget load time).
89 + */
90 +export function buildDisplayText(
91 + model: CounterModel,
92 + tMs: number,
93 + config: WidgetConfig,
94 + sessionStartMs?: number,
95 +): DisplayText {
96 + const v = windowValue(model, tMs, config.window, sessionStartMs);
97 + const opts = { locale: config.lang, applySigFigs: false };
98 + return {
99 + value: formatValue(v, model.displayHints, opts),
100 + rate: formatRate(rateAt(model, tMs), model.displayHints, { locale: config.lang }),
101 + unit: model.displayHints.unit,
102 + };
103 +}
added packages/widget/src/widget.ts +168 −0
@@ -0,0 +1,168 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/widget/src/widget.ts
6 + * Purpose: Embeddable widget entrypoint — mounts live counters on [data-earth-now-metric] elements (IIFE, zero runtime deps)
7 + */
8 +
9 +import type { CounterModel } from "@earth-now/counter";
10 +import {
11 + type WidgetConfig,
12 + buildDisplayText,
13 + modelUrl,
14 + parseModelPayload,
15 + resolveConfig,
16 +} from "./render.js";
17 +
18 +/** "session" windows count from widget load time. */
19 +const SESSION_START_MS = Date.now();
20 +
21 +/** Re-fetch the model every 15 minutes (no SSE in v0.1 to keep size down). */
22 +const REFRESH_MS = 15 * 60 * 1000;
23 +
24 +const FONT_STACK =
25 + "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
26 +
27 +interface WidgetInstance {
28 + config: WidgetConfig;
29 + valueEl: HTMLElement;
30 + rateEl: HTMLElement;
31 + model: CounterModel | null;
32 +}
33 +
34 +const instances: WidgetInstance[] = [];
35 +let rafRunning = false;
36 +
37 +function styleAttribution(a: HTMLAnchorElement): void {
38 + // MANDATORY attribution — license/attribution is a product commitment. Never remove.
39 + a.href = "https://earth-now.co";
40 + a.target = "_blank";
41 + a.rel = "noopener";
42 + a.textContent = "earth-now.co";
43 + const s = a.style;
44 + s.color = "#7aa2ff";
45 + s.fontSize = "10px";
46 + s.textDecoration = "none";
47 + s.letterSpacing = "0.04em";
48 + s.marginTop = "4px";
49 +}
50 +
51 +function buildCard(host: HTMLElement, config: WidgetConfig): WidgetInstance {
52 + const card = document.createElement("div");
53 + const cs = card.style;
54 + cs.display = "inline-flex";
55 + cs.flexDirection = "column";
56 + cs.alignItems = "flex-start";
57 + cs.background = "#0b1220";
58 + cs.color = "#e6edf7";
59 + cs.border = "1px solid #1d2a44";
60 + cs.borderRadius = "14px";
61 + cs.padding = "10px 16px";
62 + cs.fontFamily = FONT_STACK;
63 + cs.lineHeight = "1.35";
64 + cs.boxSizing = "border-box";
65 + cs.minWidth = "160px";
66 +
67 + const label = document.createElement("div");
68 + label.textContent = config.metricId.replace(/_/g, " ");
69 + label.style.fontSize = "10px";
70 + label.style.textTransform = "uppercase";
71 + label.style.letterSpacing = "0.08em";
72 + label.style.color = "#8b98b3";
73 +
74 + const valueEl = document.createElement("div");
75 + valueEl.textContent = "—";
76 + valueEl.style.fontSize = "22px";
77 + valueEl.style.fontWeight = "700";
78 + valueEl.style.fontVariantNumeric = "tabular-nums";
79 + valueEl.style.whiteSpace = "nowrap";
80 +
81 + const rateEl = document.createElement("div");
82 + rateEl.textContent = "";
83 + rateEl.style.fontSize = "11px";
84 + rateEl.style.color = "#8b98b3";
85 + rateEl.style.fontVariantNumeric = "tabular-nums";
86 +
87 + const attribution = document.createElement("a");
88 + styleAttribution(attribution);
89 +
90 + card.appendChild(label);
91 + card.appendChild(valueEl);
92 + card.appendChild(rateEl);
93 + card.appendChild(attribution);
94 + host.appendChild(card);
95 +
96 + return { config, valueEl, rateEl, model: null };
97 +}
98 +
99 +function renderError(inst: WidgetInstance): void {
100 + inst.valueEl.textContent = "—";
101 + inst.rateEl.textContent = "data pending";
102 +}
103 +
104 +function tick(): void {
105 + const now = Date.now();
106 + for (const inst of instances) {
107 + if (!inst.model) continue;
108 + try {
109 + const text = buildDisplayText(inst.model, now, inst.config, SESSION_START_MS);
110 + inst.valueEl.textContent = `${text.value} ${text.unit}`;
111 + inst.rateEl.textContent = text.rate;
112 + } catch {
113 + inst.model = null;
114 + renderError(inst);
115 + }
116 + }
117 + requestAnimationFrame(tick);
118 +}
119 +
120 +function startLoop(): void {
121 + if (rafRunning) return;
122 + rafRunning = true;
123 + requestAnimationFrame(tick);
124 +}
125 +
126 +function loadModel(inst: WidgetInstance): void {
127 + fetch(modelUrl(inst.config))
128 + .then((res) => {
129 + if (!res.ok) throw new Error(`earth-now widget: HTTP ${res.status}`);
130 + return res.json();
131 + })
132 + .then((json: unknown) => {
133 + inst.model = parseModelPayload(json);
134 + })
135 + .catch(() => {
136 + inst.model = null;
137 + renderError(inst);
138 + })
139 + .finally(() => {
140 + setTimeout(() => loadModel(inst), REFRESH_MS);
141 + });
142 +}
143 +
144 +function mount(node: HTMLElement): void {
145 + if (node.dataset["earthNowMounted"] === "1") return;
146 + node.dataset["earthNowMounted"] = "1";
147 + const config = resolveConfig(node.dataset);
148 + if (!config) return;
149 + let host = node;
150 + if (node.tagName === "SCRIPT") {
151 + host = document.createElement("div");
152 + node.insertAdjacentElement("afterend", host);
153 + }
154 + const inst = buildCard(host, config);
155 + instances.push(inst);
156 + loadModel(inst);
157 + startLoop();
158 +}
159 +
160 +function init(): void {
161 + document.querySelectorAll<HTMLElement>("[data-earth-now-metric]").forEach(mount);
162 +}
163 +
164 +if (document.readyState === "loading") {
165 + document.addEventListener("DOMContentLoaded", init, { once: true });
166 +} else {
167 + init();
168 +}
added packages/widget/test/widget.test.ts +138 −0
@@ -0,0 +1,138 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: packages/widget/test/widget.test.ts
6 + * Purpose: Unit tests for the widget's pure helpers — config resolution, payload parsing, display text
7 + */
8 +
9 +import { describe, expect, it } from "vitest";
10 +import type { CounterModel } from "@earth-now/counter";
11 +import {
12 + DEFAULT_API,
13 + buildDisplayText,
14 + modelUrl,
15 + parseModelPayload,
16 + resolveConfig,
17 +} from "../src/render";
18 +
19 +const ANCHOR = "2026-01-01T00:00:00Z";
20 +const ANCHOR_MS = Date.parse(ANCHOR);
21 +
22 +const linearModel: CounterModel = {
23 + metricId: "co2_emissions_ytd",
24 + anchorValue: 1_000_000,
25 + anchorTime: ANCHOR,
26 + rateFn: { kind: "linear", perSecond: 2 },
27 + observedAt: "2025-12-15T00:00:00Z",
28 + sourceId: "gcp_2025",
29 + modelVersion: "linear-v1",
30 + displayHints: { decimals: 0, sigFigs: 4, unit: "t" },
31 +};
32 +
33 +describe("resolveConfig", () => {
34 + it("resolves a full dataset", () => {
35 + const config = resolveConfig({
36 + earthNowMetric: "co2_emissions_ytd",
37 + window: "today",
38 + lang: "fr",
39 + api: "https://www.earth-now.co/",
40 + });
41 + expect(config).toEqual({
42 + metricId: "co2_emissions_ytd",
43 + window: "today",
44 + lang: "fr",
45 + api: "https://www.earth-now.co",
46 + });
47 + });
48 +
49 + it("applies defaults (window total, lang en, official api)", () => {
50 + const config = resolveConfig({ earthNowMetric: "world_population" });
51 + expect(config).toEqual({
52 + metricId: "world_population",
53 + window: "total",
54 + lang: "en",
55 + api: DEFAULT_API,
56 + });
57 + });
58 +
59 + it("falls back to 'total' on an unknown window", () => {
60 + expect(resolveConfig({ earthNowMetric: "x", window: "weird" })?.window).toBe("total");
61 + });
62 +
63 + it("returns null when no metric id is declared", () => {
64 + expect(resolveConfig({})).toBeNull();
65 + expect(resolveConfig({ earthNowMetric: " " })).toBeNull();
66 + });
67 +});
68 +
69 +describe("modelUrl", () => {
70 + it("builds the /v1 model endpoint and escapes the metric id", () => {
71 + const config = resolveConfig({ earthNowMetric: "a/b", api: "https://example.org" });
72 + expect(config && modelUrl(config)).toBe("https://example.org/v1/metrics/a%2Fb/model");
73 + });
74 +});
75 +
76 +describe("parseModelPayload", () => {
77 + it("accepts a bare model and an { model } envelope", () => {
78 + expect(parseModelPayload(linearModel).metricId).toBe("co2_emissions_ytd");
79 + expect(parseModelPayload({ model: linearModel }).anchorValue).toBe(1_000_000);
80 + });
81 +
82 + it("throws descriptive errors on malformed payloads", () => {
83 + expect(() => parseModelPayload(null)).toThrow(/not an object/);
84 + expect(() => parseModelPayload({})).toThrow(/metricId/);
85 + expect(() => parseModelPayload({ ...linearModel, anchorValue: "nope" })).toThrow(
86 + /anchorValue/,
87 + );
88 + expect(() => parseModelPayload({ ...linearModel, anchorTime: "not-a-date" })).toThrow(
89 + /anchorTime/,
90 + );
91 + expect(() => parseModelPayload({ ...linearModel, rateFn: 42 })).toThrow(/rateFn/);
92 + });
93 +});
94 +
95 +describe("buildDisplayText", () => {
96 + const config = {
97 + metricId: "co2_emissions_ytd",
98 + window: "total" as const,
99 + lang: "en",
100 + api: DEFAULT_API,
101 + };
102 +
103 + it("renders the animated value WITHOUT the sigFigs cap (ticker mode)", () => {
104 + // 100 s after anchor: 1_000_000 + 2 * 100 = 1_000_200; sigFigs 4 would flatten to 1_000_000.
105 + const text = buildDisplayText(linearModel, ANCHOR_MS + 100_000, config);
106 + expect(text.value).toBe("1,000,200");
107 + expect(text.unit).toBe("t");
108 + });
109 +
110 + it("renders the per-second rate line", () => {
111 + const text = buildDisplayText(linearModel, ANCHOR_MS + 100_000, config);
112 + expect(text.rate).toBe("2/s");
113 + });
114 +
115 + it("resolves the 'today' window from the same model", () => {
116 + // 2026-01-02T00:00:30Z → 30 s into the UTC day at 2/s = 60.
117 + const t = Date.parse("2026-01-02T00:00:30Z");
118 + const text = buildDisplayText(linearModel, t, { ...config, window: "today" });
119 + expect(text.value).toBe("60");
120 + });
121 +
122 + it("uses the caller-provided session start for 'session' windows", () => {
123 + const sessionStart = ANCHOR_MS + 10_000;
124 + const text = buildDisplayText(
125 + linearModel,
126 + sessionStart + 45_000,
127 + { ...config, window: "session" },
128 + sessionStart,
129 + );
130 + expect(text.value).toBe("90");
131 + });
132 +
133 + it("formats according to the requested locale", () => {
134 + const text = buildDisplayText(linearModel, ANCHOR_MS + 100_000, { ...config, lang: "fr" });
135 + // fr uses narrow no-break spaces as grouping separators.
136 + expect(text.value.replace(/[  ]/g, " ")).toBe("1 000 200");
137 + });
138 +});
added packages/widget/tsconfig.json +8 −0
@@ -0,0 +1,8 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "lib": ["ES2022", "DOM", "DOM.Iterable"],
5 + "noEmit": true
6 + },
7 + "include": ["src", "test"]
8 +}
added pnpm-lock.yaml +3939 −0
@@ -0,0 +1,3939 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + devDependencies:
11 + tsx:
12 + specifier: ^4.19.0
13 + version: 4.23.11
14 + turbo:
15 + specifier: ^2.1.0
16 + version: 2.10.9
17 + typescript:
18 + specifier: ^5.5.4
19 + version: 5.9.3
20 +
21 + apps/api:
22 + dependencies:
23 + '@earth-now/counter':
24 + specifier: workspace:*
25 + version: link:../../packages/counter
26 + '@earth-now/models':
27 + specifier: workspace:*
28 + version: link:../../packages/models
29 + '@earth-now/registry':
30 + specifier: workspace:*
31 + version: link:../../packages/registry
32 + '@fastify/cors':
33 + specifier: ^9.0.1
34 + version: 9.0.1
35 + '@fastify/rate-limit':
36 + specifier: ^9.1.0
37 + version: 9.1.0
38 + fastify:
39 + specifier: ^4.28.1
40 + version: 4.29.1
41 + devDependencies:
42 + '@types/node':
43 + specifier: ^20
44 + version: 20.19.43
45 + tsx:
46 + specifier: ^4.19.0
47 + version: 4.23.11
48 + typescript:
49 + specifier: ^5.5.4
50 + version: 5.9.3
51 + vitest:
52 + specifier: ^2.0.5
53 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
54 +
55 + apps/ingest:
56 + dependencies:
57 + '@earth-now/counter':
58 + specifier: workspace:*
59 + version: link:../../packages/counter
60 + '@earth-now/models':
61 + specifier: workspace:*
62 + version: link:../../packages/models
63 + '@earth-now/registry':
64 + specifier: workspace:*
65 + version: link:../../packages/registry
66 + bullmq:
67 + specifier: ^5.12.0
68 + version: 5.81.3
69 + ioredis:
70 + specifier: ^5.4.1
71 + version: 5.11.1
72 + devDependencies:
73 + '@types/node':
74 + specifier: ^20
75 + version: 20.19.43
76 + tsx:
77 + specifier: ^4.19.0
78 + version: 4.23.11
79 + typescript:
80 + specifier: ^5.5.4
81 + version: 5.9.3
82 + vitest:
83 + specifier: ^2.0.5
84 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
85 +
86 + apps/web:
87 + dependencies:
88 + '@earth-now/counter':
89 + specifier: workspace:*
90 + version: link:../../packages/counter
91 + next:
92 + specifier: ^14.2.15
93 + version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
94 + react:
95 + specifier: ^18.3.1
96 + version: 18.3.1
97 + react-dom:
98 + specifier: ^18.3.1
99 + version: 18.3.1(react@18.3.1)
100 + devDependencies:
101 + '@types/node':
102 + specifier: ^20.16.5
103 + version: 20.19.43
104 + '@types/react':
105 + specifier: ^18.3.5
106 + version: 18.3.31
107 + '@types/react-dom':
108 + specifier: ^18.3.0
109 + version: 18.3.7(@types/react@18.3.31)
110 + autoprefixer:
111 + specifier: ^10.4.20
112 + version: 10.5.4(postcss@8.5.26)
113 + jsdom:
114 + specifier: ^24.1.3
115 + version: 24.1.3
116 + postcss:
117 + specifier: ^8.4.47
118 + version: 8.5.26
119 + tailwindcss:
120 + specifier: ^3.4.10
121 + version: 3.4.19(tsx@4.23.11)(yaml@2.9.0)
122 + typescript:
123 + specifier: ^5.5.4
124 + version: 5.9.3
125 + vitest:
126 + specifier: ^2.0.5
127 + version: 2.1.9(@types/node@20.19.43)(jsdom@24.1.3)
128 +
129 + packages/counter:
130 + devDependencies:
131 + fast-check:
132 + specifier: ^3.22.0
133 + version: 3.23.2
134 + jsdom:
135 + specifier: ^30.0.1
136 + version: 30.0.1
137 + typescript:
138 + specifier: ^5.5.4
139 + version: 5.9.3
140 + vitest:
141 + specifier: ^2.0.5
142 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
143 +
144 + packages/models:
145 + dependencies:
146 + '@earth-now/counter':
147 + specifier: workspace:*
148 + version: link:../counter
149 + devDependencies:
150 + fast-check:
151 + specifier: ^3.22.0
152 + version: 3.23.2
153 + typescript:
154 + specifier: ^5.5.4
155 + version: 5.9.3
156 + vitest:
157 + specifier: ^2.0.5
158 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
159 +
160 + packages/registry:
161 + dependencies:
162 + '@earth-now/counter':
163 + specifier: workspace:*
164 + version: link:../counter
165 + '@earth-now/models':
166 + specifier: workspace:*
167 + version: link:../models
168 + yaml:
169 + specifier: ^2.5.0
170 + version: 2.9.0
171 + zod:
172 + specifier: ^3.23.8
173 + version: 3.25.76
174 + devDependencies:
175 + '@types/node':
176 + specifier: ^20
177 + version: 20.19.43
178 + typescript:
179 + specifier: ^5.5.4
180 + version: 5.9.3
181 + vitest:
182 + specifier: ^2.0.5
183 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
184 +
185 + packages/widget:
186 + dependencies:
187 + '@earth-now/counter':
188 + specifier: workspace:*
189 + version: link:../counter
190 + devDependencies:
191 + '@types/node':
192 + specifier: ^20
193 + version: 20.19.43
194 + esbuild:
195 + specifier: ^0.23.1
196 + version: 0.23.1
197 + typescript:
198 + specifier: ^5.5.4
199 + version: 5.9.3
200 + vitest:
201 + specifier: ^2.0.5
202 + version: 2.1.9(@types/node@20.19.43)(jsdom@30.0.1)
203 +
204 +packages:
205 +
206 + '@alloc/quick-lru@5.2.0':
207 + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
208 + engines: {node: '>=10'}
209 +
210 + '@asamuzakjp/css-color@3.2.0':
211 + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
212 +
213 + '@asamuzakjp/css-color@6.0.7':
214 + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
215 + engines: {node: ^22.13.0 || >=24.0.0}
216 +
217 + '@asamuzakjp/dom-selector@8.3.2':
218 + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
219 + engines: {node: ^22.13.0 || >=24.0.0}
220 +
221 + '@bramus/specificity@2.4.2':
222 + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
223 + hasBin: true
224 +
225 + '@csstools/color-helpers@5.1.0':
226 + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
227 + engines: {node: '>=18'}
228 +
229 + '@csstools/color-helpers@6.1.0':
230 + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
231 + engines: {node: '>=20.19.0'}
232 +
233 + '@csstools/css-calc@2.1.4':
234 + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
235 + engines: {node: '>=18'}
236 + peerDependencies:
237 + '@csstools/css-parser-algorithms': ^3.0.5
238 + '@csstools/css-tokenizer': ^3.0.4
239 +
240 + '@csstools/css-calc@3.3.0':
241 + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
242 + engines: {node: '>=20.19.0'}
243 + peerDependencies:
244 + '@csstools/css-parser-algorithms': ^4.0.0
245 + '@csstools/css-tokenizer': ^4.0.0
246 +
247 + '@csstools/css-color-parser@3.1.0':
248 + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
249 + engines: {node: '>=18'}
250 + peerDependencies:
251 + '@csstools/css-parser-algorithms': ^3.0.5
252 + '@csstools/css-tokenizer': ^3.0.4
253 +
254 + '@csstools/css-color-parser@4.1.10':
255 + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
256 + engines: {node: '>=20.19.0'}
257 + peerDependencies:
258 + '@csstools/css-parser-algorithms': ^4.0.0
259 + '@csstools/css-tokenizer': ^4.0.0
260 +
261 + '@csstools/css-parser-algorithms@3.0.5':
262 + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
263 + engines: {node: '>=18'}
264 + peerDependencies:
265 + '@csstools/css-tokenizer': ^3.0.4
266 +
267 + '@csstools/css-parser-algorithms@4.0.0':
268 + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
269 + engines: {node: '>=20.19.0'}
270 + peerDependencies:
271 + '@csstools/css-tokenizer': ^4.0.0
272 +
273 + '@csstools/css-syntax-patches-for-csstree@1.1.7':
274 + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
275 + peerDependencies:
276 + css-tree: ^3.2.1
277 + peerDependenciesMeta:
278 + css-tree:
279 + optional: true
280 +
281 + '@csstools/css-tokenizer@3.0.4':
282 + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
283 + engines: {node: '>=18'}
284 +
285 + '@csstools/css-tokenizer@4.0.0':
286 + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
287 + engines: {node: '>=20.19.0'}
288 +
289 + '@esbuild/aix-ppc64@0.21.5':
290 + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
291 + engines: {node: '>=12'}
292 + cpu: [ppc64]
293 + os: [aix]
294 +
295 + '@esbuild/aix-ppc64@0.23.1':
296 + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
297 + engines: {node: '>=18'}
298 + cpu: [ppc64]
299 + os: [aix]
300 +
301 + '@esbuild/aix-ppc64@0.28.2':
302 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
303 + engines: {node: '>=18'}
304 + cpu: [ppc64]
305 + os: [aix]
306 +
307 + '@esbuild/android-arm64@0.21.5':
308 + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
309 + engines: {node: '>=12'}
310 + cpu: [arm64]
311 + os: [android]
312 +
313 + '@esbuild/android-arm64@0.23.1':
314 + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
315 + engines: {node: '>=18'}
316 + cpu: [arm64]
317 + os: [android]
318 +
319 + '@esbuild/android-arm64@0.28.2':
320 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
321 + engines: {node: '>=18'}
322 + cpu: [arm64]
323 + os: [android]
324 +
325 + '@esbuild/android-arm@0.21.5':
326 + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
327 + engines: {node: '>=12'}
328 + cpu: [arm]
329 + os: [android]
330 +
331 + '@esbuild/android-arm@0.23.1':
332 + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
333 + engines: {node: '>=18'}
334 + cpu: [arm]
335 + os: [android]
336 +
337 + '@esbuild/android-arm@0.28.2':
338 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
339 + engines: {node: '>=18'}
340 + cpu: [arm]
341 + os: [android]
342 +
343 + '@esbuild/android-x64@0.21.5':
344 + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
345 + engines: {node: '>=12'}
346 + cpu: [x64]
347 + os: [android]
348 +
349 + '@esbuild/android-x64@0.23.1':
350 + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
351 + engines: {node: '>=18'}
352 + cpu: [x64]
353 + os: [android]
354 +
355 + '@esbuild/android-x64@0.28.2':
356 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
357 + engines: {node: '>=18'}
358 + cpu: [x64]
359 + os: [android]
360 +
361 + '@esbuild/darwin-arm64@0.21.5':
362 + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
363 + engines: {node: '>=12'}
364 + cpu: [arm64]
365 + os: [darwin]
366 +
367 + '@esbuild/darwin-arm64@0.23.1':
368 + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
369 + engines: {node: '>=18'}
370 + cpu: [arm64]
371 + os: [darwin]
372 +
373 + '@esbuild/darwin-arm64@0.28.2':
374 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
375 + engines: {node: '>=18'}
376 + cpu: [arm64]
377 + os: [darwin]
378 +
379 + '@esbuild/darwin-x64@0.21.5':
380 + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
381 + engines: {node: '>=12'}
382 + cpu: [x64]
383 + os: [darwin]
384 +
385 + '@esbuild/darwin-x64@0.23.1':
386 + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
387 + engines: {node: '>=18'}
388 + cpu: [x64]
389 + os: [darwin]
390 +
391 + '@esbuild/darwin-x64@0.28.2':
392 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
393 + engines: {node: '>=18'}
394 + cpu: [x64]
395 + os: [darwin]
396 +
397 + '@esbuild/freebsd-arm64@0.21.5':
398 + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
399 + engines: {node: '>=12'}
400 + cpu: [arm64]
401 + os: [freebsd]
402 +
403 + '@esbuild/freebsd-arm64@0.23.1':
404 + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
405 + engines: {node: '>=18'}
406 + cpu: [arm64]
407 + os: [freebsd]
408 +
409 + '@esbuild/freebsd-arm64@0.28.2':
410 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
411 + engines: {node: '>=18'}
412 + cpu: [arm64]
413 + os: [freebsd]
414 +
415 + '@esbuild/freebsd-x64@0.21.5':
416 + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
417 + engines: {node: '>=12'}
418 + cpu: [x64]
419 + os: [freebsd]
420 +
421 + '@esbuild/freebsd-x64@0.23.1':
422 + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
423 + engines: {node: '>=18'}
424 + cpu: [x64]
425 + os: [freebsd]
426 +
427 + '@esbuild/freebsd-x64@0.28.2':
428 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
429 + engines: {node: '>=18'}
430 + cpu: [x64]
431 + os: [freebsd]
432 +
433 + '@esbuild/linux-arm64@0.21.5':
434 + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
435 + engines: {node: '>=12'}
436 + cpu: [arm64]
437 + os: [linux]
438 +
439 + '@esbuild/linux-arm64@0.23.1':
440 + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
441 + engines: {node: '>=18'}
442 + cpu: [arm64]
443 + os: [linux]
444 +
445 + '@esbuild/linux-arm64@0.28.2':
446 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
447 + engines: {node: '>=18'}
448 + cpu: [arm64]
449 + os: [linux]
450 +
451 + '@esbuild/linux-arm@0.21.5':
452 + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
453 + engines: {node: '>=12'}
454 + cpu: [arm]
455 + os: [linux]
456 +
457 + '@esbuild/linux-arm@0.23.1':
458 + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
459 + engines: {node: '>=18'}
460 + cpu: [arm]
461 + os: [linux]
462 +
463 + '@esbuild/linux-arm@0.28.2':
464 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
465 + engines: {node: '>=18'}
466 + cpu: [arm]
467 + os: [linux]
468 +
469 + '@esbuild/linux-ia32@0.21.5':
470 + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
471 + engines: {node: '>=12'}
472 + cpu: [ia32]
473 + os: [linux]
474 +
475 + '@esbuild/linux-ia32@0.23.1':
476 + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
477 + engines: {node: '>=18'}
478 + cpu: [ia32]
479 + os: [linux]
480 +
481 + '@esbuild/linux-ia32@0.28.2':
482 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
483 + engines: {node: '>=18'}
484 + cpu: [ia32]
485 + os: [linux]
486 +
487 + '@esbuild/linux-loong64@0.21.5':
488 + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
489 + engines: {node: '>=12'}
490 + cpu: [loong64]
491 + os: [linux]
492 +
493 + '@esbuild/linux-loong64@0.23.1':
494 + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
495 + engines: {node: '>=18'}
496 + cpu: [loong64]
497 + os: [linux]
498 +
499 + '@esbuild/linux-loong64@0.28.2':
500 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
501 + engines: {node: '>=18'}
502 + cpu: [loong64]
503 + os: [linux]
504 +
505 + '@esbuild/linux-mips64el@0.21.5':
506 + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
507 + engines: {node: '>=12'}
508 + cpu: [mips64el]
509 + os: [linux]
510 +
511 + '@esbuild/linux-mips64el@0.23.1':
512 + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
513 + engines: {node: '>=18'}
514 + cpu: [mips64el]
515 + os: [linux]
516 +
517 + '@esbuild/linux-mips64el@0.28.2':
518 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
519 + engines: {node: '>=18'}
520 + cpu: [mips64el]
521 + os: [linux]
522 +
523 + '@esbuild/linux-ppc64@0.21.5':
524 + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
525 + engines: {node: '>=12'}
526 + cpu: [ppc64]
527 + os: [linux]
528 +
529 + '@esbuild/linux-ppc64@0.23.1':
530 + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
531 + engines: {node: '>=18'}
532 + cpu: [ppc64]
533 + os: [linux]
534 +
535 + '@esbuild/linux-ppc64@0.28.2':
536 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
537 + engines: {node: '>=18'}
538 + cpu: [ppc64]
539 + os: [linux]
540 +
541 + '@esbuild/linux-riscv64@0.21.5':
542 + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
543 + engines: {node: '>=12'}
544 + cpu: [riscv64]
545 + os: [linux]
546 +
547 + '@esbuild/linux-riscv64@0.23.1':
548 + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
549 + engines: {node: '>=18'}
550 + cpu: [riscv64]
551 + os: [linux]
552 +
553 + '@esbuild/linux-riscv64@0.28.2':
554 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
555 + engines: {node: '>=18'}
556 + cpu: [riscv64]
557 + os: [linux]
558 +
559 + '@esbuild/linux-s390x@0.21.5':
560 + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
561 + engines: {node: '>=12'}
562 + cpu: [s390x]
563 + os: [linux]
564 +
565 + '@esbuild/linux-s390x@0.23.1':
566 + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
567 + engines: {node: '>=18'}
568 + cpu: [s390x]
569 + os: [linux]
570 +
571 + '@esbuild/linux-s390x@0.28.2':
572 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
573 + engines: {node: '>=18'}
574 + cpu: [s390x]
575 + os: [linux]
576 +
577 + '@esbuild/linux-x64@0.21.5':
578 + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
579 + engines: {node: '>=12'}
580 + cpu: [x64]
581 + os: [linux]
582 +
583 + '@esbuild/linux-x64@0.23.1':
584 + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
585 + engines: {node: '>=18'}
586 + cpu: [x64]
587 + os: [linux]
588 +
589 + '@esbuild/linux-x64@0.28.2':
590 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
591 + engines: {node: '>=18'}
592 + cpu: [x64]
593 + os: [linux]
594 +
595 + '@esbuild/netbsd-arm64@0.28.2':
596 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
597 + engines: {node: '>=18'}
598 + cpu: [arm64]
599 + os: [netbsd]
600 +
601 + '@esbuild/netbsd-x64@0.21.5':
602 + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
603 + engines: {node: '>=12'}
604 + cpu: [x64]
605 + os: [netbsd]
606 +
607 + '@esbuild/netbsd-x64@0.23.1':
608 + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
609 + engines: {node: '>=18'}
610 + cpu: [x64]
611 + os: [netbsd]
612 +
613 + '@esbuild/netbsd-x64@0.28.2':
614 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
615 + engines: {node: '>=18'}
616 + cpu: [x64]
617 + os: [netbsd]
618 +
619 + '@esbuild/openbsd-arm64@0.23.1':
620 + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
621 + engines: {node: '>=18'}
622 + cpu: [arm64]
623 + os: [openbsd]
624 +
625 + '@esbuild/openbsd-arm64@0.28.2':
626 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
627 + engines: {node: '>=18'}
628 + cpu: [arm64]
629 + os: [openbsd]
630 +
631 + '@esbuild/openbsd-x64@0.21.5':
632 + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
633 + engines: {node: '>=12'}
634 + cpu: [x64]
635 + os: [openbsd]
636 +
637 + '@esbuild/openbsd-x64@0.23.1':
638 + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
639 + engines: {node: '>=18'}
640 + cpu: [x64]
641 + os: [openbsd]
642 +
643 + '@esbuild/openbsd-x64@0.28.2':
644 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
645 + engines: {node: '>=18'}
646 + cpu: [x64]
647 + os: [openbsd]
648 +
649 + '@esbuild/openharmony-arm64@0.28.2':
650 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
651 + engines: {node: '>=18'}
652 + cpu: [arm64]
653 + os: [openharmony]
654 +
655 + '@esbuild/sunos-x64@0.21.5':
656 + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
657 + engines: {node: '>=12'}
658 + cpu: [x64]
659 + os: [sunos]
660 +
661 + '@esbuild/sunos-x64@0.23.1':
662 + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
663 + engines: {node: '>=18'}
664 + cpu: [x64]
665 + os: [sunos]
666 +
667 + '@esbuild/sunos-x64@0.28.2':
668 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
669 + engines: {node: '>=18'}
670 + cpu: [x64]
671 + os: [sunos]
672 +
673 + '@esbuild/win32-arm64@0.21.5':
674 + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
675 + engines: {node: '>=12'}
676 + cpu: [arm64]
677 + os: [win32]
678 +
679 + '@esbuild/win32-arm64@0.23.1':
680 + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
681 + engines: {node: '>=18'}
682 + cpu: [arm64]
683 + os: [win32]
684 +
685 + '@esbuild/win32-arm64@0.28.2':
686 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
687 + engines: {node: '>=18'}
688 + cpu: [arm64]
689 + os: [win32]
690 +
691 + '@esbuild/win32-ia32@0.21.5':
692 + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
693 + engines: {node: '>=12'}
694 + cpu: [ia32]
695 + os: [win32]
696 +
697 + '@esbuild/win32-ia32@0.23.1':
698 + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
699 + engines: {node: '>=18'}
700 + cpu: [ia32]
701 + os: [win32]
702 +
703 + '@esbuild/win32-ia32@0.28.2':
704 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
705 + engines: {node: '>=18'}
706 + cpu: [ia32]
707 + os: [win32]
708 +
709 + '@esbuild/win32-x64@0.21.5':
710 + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
711 + engines: {node: '>=12'}
712 + cpu: [x64]
713 + os: [win32]
714 +
715 + '@esbuild/win32-x64@0.23.1':
716 + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
717 + engines: {node: '>=18'}
718 + cpu: [x64]
719 + os: [win32]
720 +
721 + '@esbuild/win32-x64@0.28.2':
722 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
723 + engines: {node: '>=18'}
724 + cpu: [x64]
725 + os: [win32]
726 +
727 + '@exodus/bytes@1.15.1':
728 + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
729 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
730 + peerDependencies:
731 + '@noble/hashes': ^1.8.0 || ^2.0.0
732 + peerDependenciesMeta:
733 + '@noble/hashes':
734 + optional: true
735 +
736 + '@fastify/ajv-compiler@3.6.0':
737 + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==}
738 +
739 + '@fastify/cors@9.0.1':
740 + resolution: {integrity: sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==}
741 +
742 + '@fastify/error@3.4.1':
743 + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
744 +
745 + '@fastify/fast-json-stringify-compiler@4.3.0':
746 + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
747 +
748 + '@fastify/merge-json-schemas@0.1.1':
749 + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
750 +
751 + '@fastify/rate-limit@9.1.0':
752 + resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==}
753 +
754 + '@ioredis/commands@1.10.0':
755 + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
756 +
757 + '@jridgewell/gen-mapping@0.3.13':
758 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
759 +
760 + '@jridgewell/resolve-uri@3.1.2':
761 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
762 + engines: {node: '>=6.0.0'}
763 +
764 + '@jridgewell/sourcemap-codec@1.5.5':
765 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
766 +
767 + '@jridgewell/trace-mapping@0.3.31':
768 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
769 +
770 + '@lukeed/ms@2.0.2':
771 + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
772 + engines: {node: '>=8'}
773 +
774 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
775 + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
776 + cpu: [arm64]
777 + os: [darwin]
778 +
779 + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
780 + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
781 + cpu: [x64]
782 + os: [darwin]
783 +
784 + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
785 + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
786 + cpu: [arm64]
787 + os: [linux]
788 +
789 + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
790 + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
791 + cpu: [arm]
792 + os: [linux]
793 +
794 + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
795 + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
796 + cpu: [x64]
797 + os: [linux]
798 +
799 + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
800 + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
801 + cpu: [x64]
802 + os: [win32]
803 +
804 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
805 + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
806 + engines: {node: ^22.20 || ^24.12 || >=25}
807 + cpu: [x64]
808 + os: [linux]
809 +
810 + '@next/env@14.2.35':
811 + resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
812 +
813 + '@next/swc-darwin-arm64@14.2.33':
814 + resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==}
815 + engines: {node: '>= 10'}
816 + cpu: [arm64]
817 + os: [darwin]
818 +
819 + '@next/swc-darwin-x64@14.2.33':
820 + resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==}
821 + engines: {node: '>= 10'}
822 + cpu: [x64]
823 + os: [darwin]
824 +
825 + '@next/swc-linux-arm64-gnu@14.2.33':
826 + resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==}
827 + engines: {node: '>= 10'}
828 + cpu: [arm64]
829 + os: [linux]
830 +
831 + '@next/swc-linux-arm64-musl@14.2.33':
832 + resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
833 + engines: {node: '>= 10'}
834 + cpu: [arm64]
835 + os: [linux]
836 +
837 + '@next/swc-linux-x64-gnu@14.2.33':
838 + resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
839 + engines: {node: '>= 10'}
840 + cpu: [x64]
841 + os: [linux]
842 +
843 + '@next/swc-linux-x64-musl@14.2.33':
844 + resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
845 + engines: {node: '>= 10'}
846 + cpu: [x64]
847 + os: [linux]
848 +
849 + '@next/swc-win32-arm64-msvc@14.2.33':
850 + resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
851 + engines: {node: '>= 10'}
852 + cpu: [arm64]
853 + os: [win32]
854 +
855 + '@next/swc-win32-ia32-msvc@14.2.33':
856 + resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==}
857 + engines: {node: '>= 10'}
858 + cpu: [ia32]
859 + os: [win32]
860 +
861 + '@next/swc-win32-x64-msvc@14.2.33':
862 + resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==}
863 + engines: {node: '>= 10'}
864 + cpu: [x64]
865 + os: [win32]
866 +
867 + '@nodelib/fs.scandir@2.1.5':
868 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
869 + engines: {node: '>= 8'}
870 +
871 + '@nodelib/fs.stat@2.0.5':
872 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
873 + engines: {node: '>= 8'}
874 +
875 + '@nodelib/fs.walk@1.2.8':
876 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
877 + engines: {node: '>= 8'}
878 +
879 + '@pinojs/redact@0.4.0':
880 + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
881 +
882 + '@rollup/rollup-android-arm-eabi@4.62.4':
883 + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
884 + cpu: [arm]
885 + os: [android]
886 +
887 + '@rollup/rollup-android-arm64@4.62.4':
888 + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}
889 + cpu: [arm64]
890 + os: [android]
891 +
892 + '@rollup/rollup-darwin-arm64@4.62.4':
893 + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
894 + cpu: [arm64]
895 + os: [darwin]
896 +
897 + '@rollup/rollup-darwin-x64@4.62.4':
898 + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}
899 + cpu: [x64]
900 + os: [darwin]
901 +
902 + '@rollup/rollup-freebsd-arm64@4.62.4':
903 + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}
904 + cpu: [arm64]
905 + os: [freebsd]
906 +
907 + '@rollup/rollup-freebsd-x64@4.62.4':
908 + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}
909 + cpu: [x64]
910 + os: [freebsd]
911 +
912 + '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
913 + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
914 + cpu: [arm]
915 + os: [linux]
916 +
917 + '@rollup/rollup-linux-arm-musleabihf@4.62.4':
918 + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
919 + cpu: [arm]
920 + os: [linux]
921 +
922 + '@rollup/rollup-linux-arm64-gnu@4.62.4':
923 + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
924 + cpu: [arm64]
925 + os: [linux]
926 +
927 + '@rollup/rollup-linux-arm64-musl@4.62.4':
928 + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
929 + cpu: [arm64]
930 + os: [linux]
931 +
932 + '@rollup/rollup-linux-loong64-gnu@4.62.4':
933 + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
934 + cpu: [loong64]
935 + os: [linux]
936 +
937 + '@rollup/rollup-linux-loong64-musl@4.62.4':
938 + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
939 + cpu: [loong64]
940 + os: [linux]
941 +
942 + '@rollup/rollup-linux-ppc64-gnu@4.62.4':
943 + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
944 + cpu: [ppc64]
945 + os: [linux]
946 +
947 + '@rollup/rollup-linux-ppc64-musl@4.62.4':
948 + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
949 + cpu: [ppc64]
950 + os: [linux]
951 +
952 + '@rollup/rollup-linux-riscv64-gnu@4.62.4':
953 + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
954 + cpu: [riscv64]
955 + os: [linux]
956 +
957 + '@rollup/rollup-linux-riscv64-musl@4.62.4':
958 + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
959 + cpu: [riscv64]
960 + os: [linux]
961 +
962 + '@rollup/rollup-linux-s390x-gnu@4.62.4':
963 + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
964 + cpu: [s390x]
965 + os: [linux]
966 +
967 + '@rollup/rollup-linux-x64-gnu@4.62.4':
968 + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
969 + cpu: [x64]
970 + os: [linux]
971 +
972 + '@rollup/rollup-linux-x64-musl@4.62.4':
973 + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
974 + cpu: [x64]
975 + os: [linux]
976 +
977 + '@rollup/rollup-openbsd-x64@4.62.4':
978 + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
979 + cpu: [x64]
980 + os: [openbsd]
981 +
982 + '@rollup/rollup-openharmony-arm64@4.62.4':
983 + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}
984 + cpu: [arm64]
985 + os: [openharmony]
986 +
987 + '@rollup/rollup-win32-arm64-msvc@4.62.4':
988 + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}
989 + cpu: [arm64]
990 + os: [win32]
991 +
992 + '@rollup/rollup-win32-ia32-msvc@4.62.4':
993 + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}
994 + cpu: [ia32]
995 + os: [win32]
996 +
997 + '@rollup/rollup-win32-x64-gnu@4.62.4':
998 + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}
999 + cpu: [x64]
1000 + os: [win32]
1001 +
1002 + '@rollup/rollup-win32-x64-msvc@4.62.4':
1003 + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}
1004 + cpu: [x64]
1005 + os: [win32]
1006 +
1007 + '@swc/counter@0.1.3':
1008 + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
1009 +
1010 + '@swc/helpers@0.5.5':
1011 + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
1012 +
1013 + '@turbo/darwin-64@2.10.9':
1014 + resolution: {integrity: sha512-Jh+pTGXLNz8+1tkUU13TI/f+ZOI+OvC4YbHi1H+57iSpLt5DR3xgptd+4sA07RdjdRR/RX/01uQQu2OkbzIefA==}
1015 + cpu: [x64]
1016 + os: [darwin]
1017 +
1018 + '@turbo/darwin-arm64@2.10.9':
1019 + resolution: {integrity: sha512-aqtpPkiIC4IUas8Vv27oJ3aDfTuP1d5wofd01dZ7gfhHRrIKuFdqQb4imvNnVFahcOViF9Jh8Oi7feDoz8/ciA==}
1020 + cpu: [arm64]
1021 + os: [darwin]
1022 +
1023 + '@turbo/linux-64@2.10.9':
1024 + resolution: {integrity: sha512-XyAneUBsS5uNOUOjBSs81zyigMVwwhVUd3u7F2JFMKGQk6F7eNAiOEBASJ+aHLldjYsOEjhfW+5gWIqwziyFFw==}
1025 + cpu: [x64]
1026 + os: [android, linux]
1027 +
1028 + '@turbo/linux-arm64@2.10.9':
1029 + resolution: {integrity: sha512-5jAcldLnkuWIjujGhCn2MGIeUwW8IVNOq9Sce4EEzzgLcxmhTasbV0RiW6ZkaRdOjmOCGzeNXPLkDJEOIucOLw==}
1030 + cpu: [arm64]
1031 + os: [android, linux]
1032 +
1033 + '@turbo/windows-64@2.10.9':
1034 + resolution: {integrity: sha512-u1xGpGlefzuhBedbt/VR2nWdftfFVZwPeDg9e5uZl68sV1fL3HNYTNr5VyHkzgtPK2VTYg1x4OKZuGrh1Gvhtg==}
1035 + cpu: [x64]
1036 + os: [win32]
1037 +
1038 + '@turbo/windows-arm64@2.10.9':
1039 + resolution: {integrity: sha512-W2Ub165Qv0iMFljbYKxxbZN+2dVSngnzEjQNEFXtnz5oJVx9XSypmNmUvMtuYMeZ3kdVC1zI7yd/rtuX+SDnbg==}
1040 + cpu: [arm64]
1041 + os: [win32]
1042 +
1043 + '@types/estree@1.0.9':
1044 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
1045 +
1046 + '@types/node@20.19.43':
1047 + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
1048 +
1049 + '@types/prop-types@15.7.15':
1050 + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
1051 +
1052 + '@types/react-dom@18.3.7':
1053 + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
1054 + peerDependencies:
1055 + '@types/react': ^18.0.0
1056 +
1057 + '@types/react@18.3.31':
1058 + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
1059 +
1060 + '@vitest/expect@2.1.9':
1061 + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
1062 +
1063 + '@vitest/mocker@2.1.9':
1064 + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
1065 + peerDependencies:
1066 + msw: ^2.4.9
1067 + vite: ^5.0.0
1068 + peerDependenciesMeta:
1069 + msw:
1070 + optional: true
1071 + vite:
1072 + optional: true
1073 +
1074 + '@vitest/pretty-format@2.1.9':
1075 + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
1076 +
1077 + '@vitest/runner@2.1.9':
1078 + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
1079 +
1080 + '@vitest/snapshot@2.1.9':
1081 + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
1082 +
1083 + '@vitest/spy@2.1.9':
1084 + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
1085 +
1086 + '@vitest/utils@2.1.9':
1087 + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
1088 +
1089 + abstract-logging@2.0.1:
1090 + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
1091 +
1092 + agent-base@7.1.4:
1093 + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
1094 + engines: {node: '>= 14'}
1095 +
1096 + ajv-formats@2.1.1:
1097 + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
1098 + peerDependencies:
1099 + ajv: ^8.0.0
1100 + peerDependenciesMeta:
1101 + ajv:
1102 + optional: true
1103 +
1104 + ajv-formats@3.0.1:
1105 + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
1106 + peerDependencies:
1107 + ajv: ^8.0.0
1108 + peerDependenciesMeta:
1109 + ajv:
1110 + optional: true
1111 +
1112 + ajv@8.20.0:
1113 + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
1114 +
1115 + any-promise@1.3.0:
1116 + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
1117 +
1118 + anymatch@3.1.3:
1119 + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
1120 + engines: {node: '>= 8'}
1121 +
1122 + arg@5.0.2:
1123 + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
1124 +
1125 + assertion-error@2.0.1:
1126 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1127 + engines: {node: '>=12'}
1128 +
1129 + asynckit@0.4.0:
1130 + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
1131 +
1132 + atomic-sleep@1.0.0:
1133 + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
1134 + engines: {node: '>=8.0.0'}
1135 +
1136 + autoprefixer@10.5.4:
1137 + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==}
1138 + engines: {node: ^10 || ^12 || >=14}
1139 + hasBin: true
1140 + peerDependencies:
1141 + postcss: ^8.1.0
1142 +
1143 + avvio@8.4.0:
1144 + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==}
1145 +
1146 + baseline-browser-mapping@2.11.13:
1147 + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
1148 + engines: {node: '>=6.0.0'}
1149 + hasBin: true
1150 +
1151 + bidi-js@1.0.3:
1152 + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
1153 +
1154 + binary-extensions@2.3.0:
1155 + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
1156 + engines: {node: '>=8'}
1157 +
1158 + braces@3.0.3:
1159 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
1160 + engines: {node: '>=8'}
1161 +
1162 + browserslist@4.28.8:
1163 + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
1164 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
1165 + hasBin: true
1166 +
1167 + bullmq@5.81.3:
1168 + resolution: {integrity: sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==}
1169 + engines: {node: '>=12.22.0'}
1170 + peerDependencies:
1171 + redis: '>=5.0.0'
1172 + peerDependenciesMeta:
1173 + redis:
1174 + optional: true
1175 +
1176 + busboy@1.6.0:
1177 + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
1178 + engines: {node: '>=10.16.0'}
1179 +
1180 + cac@6.7.14:
1181 + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
1182 + engines: {node: '>=8'}
1183 +
1184 + call-bind-apply-helpers@1.0.2:
1185 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
1186 + engines: {node: '>= 0.4'}
1187 +
1188 + camelcase-css@2.0.1:
1189 + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
1190 + engines: {node: '>= 6'}
1191 +
1192 + caniuse-lite@1.0.30001809:
1193 + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
1194 +
1195 + chai@5.3.3:
1196 + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
1197 + engines: {node: '>=18'}
1198 +
1199 + check-error@2.1.3:
1200 + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
1201 + engines: {node: '>= 16'}
1202 +
1203 + chokidar@3.6.0:
1204 + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
1205 + engines: {node: '>= 8.10.0'}
1206 +
1207 + client-only@0.0.1:
1208 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1209 +
1210 + cluster-key-slot@1.1.1:
1211 + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
1212 + engines: {node: '>=0.10.0'}
1213 +
1214 + combined-stream@1.0.8:
1215 + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
1216 + engines: {node: '>= 0.8'}
1217 +
1218 + commander@4.1.1:
1219 + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
1220 + engines: {node: '>= 6'}
1221 +
1222 + cookie@0.7.2:
1223 + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
1224 + engines: {node: '>= 0.6'}
1225 +
1226 + cron-parser@4.9.0:
1227 + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
1228 + engines: {node: '>=12.0.0'}
1229 + deprecated: v4 is no longer maintained, upgrade to v5
1230 +
1231 + css-tree@3.2.1:
1232 + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
1233 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
1234 +
1235 + cssesc@3.0.0:
1236 + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
1237 + engines: {node: '>=4'}
1238 + hasBin: true
1239 +
1240 + cssstyle@4.6.0:
1241 + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
1242 + engines: {node: '>=18'}
1243 +
1244 + csstype@3.2.3:
1245 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1246 +
1247 + data-urls@5.0.0:
1248 + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
1249 + engines: {node: '>=18'}
1250 +
1251 + data-urls@7.0.0:
1252 + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
1253 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
1254 +
1255 + debug@4.4.3:
1256 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1257 + engines: {node: '>=6.0'}
1258 + peerDependencies:
1259 + supports-color: '*'
1260 + peerDependenciesMeta:
1261 + supports-color:
1262 + optional: true
1263 +
1264 + decimal.js@10.6.0:
1265 + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
1266 +
1267 + deep-eql@5.0.2:
1268 + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
1269 + engines: {node: '>=6'}
1270 +
1271 + delayed-stream@1.0.0:
1272 + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
1273 + engines: {node: '>=0.4.0'}
1274 +
1275 + denque@2.1.0:
1276 + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
1277 + engines: {node: '>=0.10'}
1278 +
1279 + detect-libc@2.1.2:
1280 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1281 + engines: {node: '>=8'}
1282 +
1283 + didyoumean@1.2.2:
1284 + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
1285 +
1286 + dlv@1.1.3:
1287 + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
1288 +
1289 + dunder-proto@1.0.1:
1290 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1291 + engines: {node: '>= 0.4'}
1292 +
1293 + electron-to-chromium@1.5.403:
1294 + resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==}
1295 +
1296 + entities@6.0.1:
1297 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
1298 + engines: {node: '>=0.12'}
1299 +
1300 + entities@8.0.0:
1301 + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
1302 + engines: {node: '>=20.19.0'}
1303 +
1304 + es-define-property@1.0.1:
1305 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
1306 + engines: {node: '>= 0.4'}
1307 +
1308 + es-errors@1.3.0:
1309 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
1310 + engines: {node: '>= 0.4'}
1311 +
1312 + es-module-lexer@1.7.0:
1313 + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
1314 +
1315 + es-object-atoms@1.1.2:
1316 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
1317 + engines: {node: '>= 0.4'}
1318 +
1319 + es-set-tostringtag@2.1.0:
1320 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
1321 + engines: {node: '>= 0.4'}
1322 +
1323 + esbuild@0.21.5:
1324 + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
1325 + engines: {node: '>=12'}
1326 + hasBin: true
1327 +
1328 + esbuild@0.23.1:
1329 + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
1330 + engines: {node: '>=18'}
1331 + hasBin: true
1332 +
1333 + esbuild@0.28.2:
1334 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
1335 + engines: {node: '>=18'}
1336 + hasBin: true
1337 +
1338 + escalade@3.2.0:
1339 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1340 + engines: {node: '>=6'}
1341 +
1342 + estree-walker@3.0.3:
1343 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1344 +
1345 + expect-type@1.4.0:
1346 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
1347 + engines: {node: '>=12.0.0'}
1348 +
1349 + fast-check@3.23.2:
1350 + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
1351 + engines: {node: '>=8.0.0'}
1352 +
1353 + fast-content-type-parse@1.1.0:
1354 + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
1355 +
1356 + fast-decode-uri-component@1.0.1:
1357 + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
1358 +
1359 + fast-deep-equal@3.1.3:
1360 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1361 +
1362 + fast-glob@3.3.3:
1363 + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
1364 + engines: {node: '>=8.6.0'}
1365 +
1366 + fast-json-stringify@5.16.1:
1367 + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
1368 +
1369 + fast-querystring@1.1.2:
1370 + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
1371 +
1372 + fast-uri@2.4.4:
1373 + resolution: {integrity: sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug==}
1374 +
1375 + fast-uri@3.1.5:
1376 + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
1377 +
1378 + fastify-plugin@4.5.1:
1379 + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
1380 +
1381 + fastify@4.29.1:
1382 + resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
1383 +
1384 + fastq@1.20.1:
1385 + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
1386 +
1387 + fdir@6.5.0:
1388 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1389 + engines: {node: '>=12.0.0'}
1390 + peerDependencies:
1391 + picomatch: ^3 || ^4
1392 + peerDependenciesMeta:
1393 + picomatch:
1394 + optional: true
1395 +
1396 + fill-range@7.1.1:
1397 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1398 + engines: {node: '>=8'}
1399 +
1400 + find-my-way@8.2.2:
1401 + resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==}
1402 + engines: {node: '>=14'}
1403 +
1404 + form-data@4.0.6:
1405 + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
1406 + engines: {node: '>= 6'}
1407 +
1408 + forwarded@0.2.0:
1409 + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
1410 + engines: {node: '>= 0.6'}
1411 +
1412 + fraction.js@5.3.4:
1413 + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
1414 +
1415 + fsevents@2.3.3:
1416 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1417 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1418 + os: [darwin]
1419 +
1420 + function-bind@1.1.2:
1421 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1422 +
1423 + get-intrinsic@1.3.0:
1424 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1425 + engines: {node: '>= 0.4'}
1426 +
1427 + get-proto@1.0.1:
1428 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1429 + engines: {node: '>= 0.4'}
1430 +
1431 + glob-parent@5.1.2:
1432 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1433 + engines: {node: '>= 6'}
1434 +
1435 + glob-parent@6.0.2:
1436 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1437 + engines: {node: '>=10.13.0'}
1438 +
1439 + gopd@1.2.0:
1440 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1441 + engines: {node: '>= 0.4'}
1442 +
1443 + graceful-fs@4.2.11:
1444 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1445 +
1446 + has-symbols@1.1.0:
1447 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1448 + engines: {node: '>= 0.4'}
1449 +
1450 + has-tostringtag@1.0.2:
1451 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1452 + engines: {node: '>= 0.4'}
1453 +
1454 + hasown@2.0.4:
1455 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1456 + engines: {node: '>= 0.4'}
1457 +
1458 + html-encoding-sniffer@4.0.0:
1459 + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
1460 + engines: {node: '>=18'}
1461 +
1462 + html-encoding-sniffer@6.0.0:
1463 + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
1464 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
1465 +
1466 + http-proxy-agent@7.0.2:
1467 + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
1468 + engines: {node: '>= 14'}
1469 +
1470 + https-proxy-agent@7.0.6:
1471 + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
1472 + engines: {node: '>= 14'}
1473 +
1474 + iconv-lite@0.6.3:
1475 + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
1476 + engines: {node: '>=0.10.0'}
1477 +
1478 + ioredis@5.11.1:
1479 + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
1480 + engines: {node: '>=12.22.0'}
1481 +
1482 + ipaddr.js@1.9.1:
1483 + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
1484 + engines: {node: '>= 0.10'}
1485 +
1486 + is-binary-path@2.1.0:
1487 + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
1488 + engines: {node: '>=8'}
1489 +
1490 + is-core-module@2.16.2:
1491 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
1492 + engines: {node: '>= 0.4'}
1493 +
1494 + is-extglob@2.1.1:
1495 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1496 + engines: {node: '>=0.10.0'}
1497 +
1498 + is-glob@4.0.3:
1499 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1500 + engines: {node: '>=0.10.0'}
1501 +
1502 + is-number@7.0.0:
1503 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1504 + engines: {node: '>=0.12.0'}
1505 +
1506 + is-potential-custom-element-name@1.0.1:
1507 + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
1508 +
1509 + jiti@1.21.7:
1510 + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
1511 + hasBin: true
1512 +
1513 + js-tokens@4.0.0:
1514 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1515 +
1516 + jsdom@24.1.3:
1517 + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==}
1518 + engines: {node: '>=18'}
1519 + peerDependencies:
1520 + canvas: ^2.11.2
1521 + peerDependenciesMeta:
1522 + canvas:
1523 + optional: true
1524 +
1525 + jsdom@30.0.1:
1526 + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
1527 + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
1528 + peerDependencies:
1529 + canvas: ^3.2.3
1530 + peerDependenciesMeta:
1531 + canvas:
1532 + optional: true
1533 +
1534 + json-schema-ref-resolver@1.0.1:
1535 + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
1536 +
1537 + json-schema-traverse@1.0.0:
1538 + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
1539 +
1540 + light-my-request@5.14.0:
1541 + resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
1542 +
1543 + lilconfig@3.1.3:
1544 + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
1545 + engines: {node: '>=14'}
1546 +
1547 + lines-and-columns@1.2.4:
1548 + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
1549 +
1550 + loose-envify@1.4.0:
1551 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1552 + hasBin: true
1553 +
1554 + loupe@3.2.1:
1555 + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
1556 +
1557 + lru-cache@10.4.3:
1558 + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
1559 +
1560 + lru-cache@11.5.2:
1561 + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
1562 + engines: {node: 20 || >=22}
1563 +
1564 + luxon@3.7.2:
1565 + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
1566 + engines: {node: '>=12'}
1567 +
1568 + magic-string@0.30.21:
1569 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1570 +
1571 + math-intrinsics@1.1.0:
1572 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
1573 + engines: {node: '>= 0.4'}
1574 +
1575 + mdn-data@2.27.1:
1576 + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
1577 +
1578 + merge2@1.4.1:
1579 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
1580 + engines: {node: '>= 8'}
1581 +
1582 + micromatch@4.0.8:
1583 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1584 + engines: {node: '>=8.6'}
1585 +
1586 + mime-db@1.52.0:
1587 + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
1588 + engines: {node: '>= 0.6'}
1589 +
1590 + mime-types@2.1.35:
1591 + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
1592 + engines: {node: '>= 0.6'}
1593 +
1594 + mnemonist@0.39.6:
1595 + resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
1596 +
1597 + ms@2.1.3:
1598 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1599 +
1600 + msgpackr-extract@3.0.4:
1601 + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
1602 + hasBin: true
1603 +
1604 + msgpackr@2.0.5:
1605 + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==}
1606 +
1607 + mz@2.7.0:
1608 + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
1609 +
1610 + nanoid@3.3.18:
1611 + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
1612 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1613 + hasBin: true
1614 +
1615 + next@14.2.35:
1616 + resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
1617 + engines: {node: '>=18.17.0'}
1618 + hasBin: true
1619 + peerDependencies:
1620 + '@opentelemetry/api': ^1.1.0
1621 + '@playwright/test': ^1.41.2
1622 + react: ^18.2.0
1623 + react-dom: ^18.2.0
1624 + sass: ^1.3.0
1625 + peerDependenciesMeta:
1626 + '@opentelemetry/api':
1627 + optional: true
1628 + '@playwright/test':
1629 + optional: true
1630 + sass:
1631 + optional: true
1632 +
1633 + node-abort-controller@3.1.1:
1634 + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
1635 +
1636 + node-gyp-build-optional-packages@5.2.2:
1637 + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
1638 + hasBin: true
1639 +
1640 + node-releases@2.0.53:
1641 + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
1642 + engines: {node: '>=18'}
1643 +
1644 + normalize-path@3.0.0:
1645 + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
1646 + engines: {node: '>=0.10.0'}
1647 +
1648 + nwsapi@2.2.24:
1649 + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
1650 +
1651 + object-assign@4.1.1:
1652 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1653 + engines: {node: '>=0.10.0'}
1654 +
1655 + object-hash@3.0.0:
1656 + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
1657 + engines: {node: '>= 6'}
1658 +
1659 + obliterator@2.0.5:
1660 + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
1661 +
1662 + on-exit-leak-free@2.1.2:
1663 + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
1664 + engines: {node: '>=14.0.0'}
1665 +
1666 + parse5@7.3.0:
1667 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
1668 +
1669 + parse5@8.0.1:
1670 + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
1671 +
1672 + path-parse@1.0.7:
1673 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
1674 +
1675 + pathe@1.1.2:
1676 + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
1677 +
1678 + pathval@2.0.1:
1679 + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
1680 + engines: {node: '>= 14.16'}
1681 +
1682 + picocolors@1.1.1:
1683 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1684 +
1685 + picomatch@2.3.2:
1686 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
1687 + engines: {node: '>=8.6'}
1688 +
1689 + picomatch@4.0.5:
1690 + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
1691 + engines: {node: '>=12'}
1692 +
1693 + pify@2.3.0:
1694 + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
1695 + engines: {node: '>=0.10.0'}
1696 +
1697 + pino-abstract-transport@2.0.0:
1698 + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
1699 +
1700 + pino-std-serializers@7.1.0:
1701 + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
1702 +
1703 + pino@9.14.0:
1704 + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
1705 + hasBin: true
1706 +
1707 + pirates@4.0.7:
1708 + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
1709 + engines: {node: '>= 6'}
1710 +
1711 + postcss-import@15.1.0:
1712 + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
1713 + engines: {node: '>=14.0.0'}
1714 + peerDependencies:
1715 + postcss: ^8.0.0
1716 +
1717 + postcss-js@4.1.0:
1718 + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
1719 + engines: {node: ^12 || ^14 || >= 16}
1720 + peerDependencies:
1721 + postcss: ^8.4.21
1722 +
1723 + postcss-load-config@6.0.1:
1724 + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
1725 + engines: {node: '>= 18'}
1726 + peerDependencies:
1727 + jiti: '>=1.21.0'
1728 + postcss: '>=8.0.9'
1729 + tsx: ^4.8.1
1730 + yaml: ^2.4.2
1731 + peerDependenciesMeta:
1732 + jiti:
1733 + optional: true
1734 + postcss:
1735 + optional: true
1736 + tsx:
1737 + optional: true
1738 + yaml:
1739 + optional: true
1740 +
1741 + postcss-nested@6.2.0:
1742 + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
1743 + engines: {node: '>=12.0'}
1744 + peerDependencies:
1745 + postcss: ^8.2.14
1746 +
1747 + postcss-selector-parser@6.1.4:
1748 + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
1749 + engines: {node: '>=4'}
1750 +
1751 + postcss-value-parser@4.2.0:
1752 + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
1753 +
1754 + postcss@8.4.31:
1755 + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
1756 + engines: {node: ^10 || ^12 || >=14}
1757 +
1758 + postcss@8.5.26:
1759 + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
1760 + engines: {node: ^10 || ^12 || >=14}
1761 +
1762 + process-warning@3.0.0:
1763 + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
1764 +
1765 + process-warning@5.1.0:
1766 + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
1767 +
1768 + proxy-addr@2.0.7:
1769 + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
1770 + engines: {node: '>= 0.10'}
1771 +
1772 + psl@1.15.0:
1773 + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
1774 +
1775 + punycode@2.3.1:
1776 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1777 + engines: {node: '>=6'}
1778 +
1779 + pure-rand@6.1.0:
1780 + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
1781 +
1782 + querystringify@2.2.0:
1783 + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
1784 +
1785 + queue-microtask@1.2.3:
1786 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1787 +
1788 + quick-format-unescaped@4.0.4:
1789 + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
1790 +
1791 + react-dom@18.3.1:
1792 + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
1793 + peerDependencies:
1794 + react: ^18.3.1
1795 +
1796 + react@18.3.1:
1797 + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
1798 + engines: {node: '>=0.10.0'}
1799 +
1800 + read-cache@1.0.0:
1801 + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
1802 +
1803 + readdirp@3.6.0:
1804 + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
1805 + engines: {node: '>=8.10.0'}
1806 +
1807 + real-require@0.2.0:
1808 + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
1809 + engines: {node: '>= 12.13.0'}
1810 +
1811 + redis-errors@1.2.0:
1812 + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
1813 + engines: {node: '>=4'}
1814 +
1815 + redis-parser@3.0.0:
1816 + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
1817 + engines: {node: '>=4'}
1818 +
1819 + require-from-string@2.0.2:
1820 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
1821 + engines: {node: '>=0.10.0'}
1822 +
1823 + requires-port@1.0.0:
1824 + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
1825 +
1826 + resolve@1.22.12:
1827 + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
1828 + engines: {node: '>= 0.4'}
1829 + hasBin: true
1830 +
1831 + ret@0.4.3:
1832 + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
1833 + engines: {node: '>=10'}
1834 +
1835 + reusify@1.1.0:
1836 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1837 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1838 +
1839 + rfdc@1.4.1:
1840 + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
1841 +
1842 + rollup@4.62.4:
1843 + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
1844 + engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1845 + hasBin: true
1846 +
1847 + rrweb-cssom@0.7.1:
1848 + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
1849 +
1850 + rrweb-cssom@0.8.0:
1851 + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
1852 +
1853 + run-parallel@1.2.0:
1854 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1855 +
1856 + safe-regex2@3.1.0:
1857 + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
1858 +
1859 + safe-stable-stringify@2.5.0:
1860 + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
1861 + engines: {node: '>=10'}
1862 +
1863 + safer-buffer@2.1.2:
1864 + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
1865 +
1866 + saxes@6.0.0:
1867 + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
1868 + engines: {node: '>=v12.22.7'}
1869 +
1870 + scheduler@0.23.2:
1871 + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
1872 +
1873 + secure-json-parse@2.7.0:
1874 + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
1875 +
1876 + semver@7.8.5:
1877 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1878 + engines: {node: '>=10'}
1879 + hasBin: true
1880 +
1881 + set-cookie-parser@2.7.2:
1882 + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
1883 +
1884 + siginfo@2.0.0:
1885 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1886 +
1887 + sonic-boom@4.2.1:
1888 + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
1889 +
1890 + source-map-js@1.2.1:
1891 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1892 + engines: {node: '>=0.10.0'}
1893 +
1894 + split2@4.2.0:
1895 + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
1896 + engines: {node: '>= 10.x'}
1897 +
1898 + stackback@0.0.2:
1899 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
1900 +
1901 + standard-as-callback@2.1.0:
1902 + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
1903 +
1904 + std-env@3.10.0:
1905 + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
1906 +
1907 + streamsearch@1.1.0:
1908 + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
1909 + engines: {node: '>=10.0.0'}
1910 +
1911 + styled-jsx@5.1.1:
1912 + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
1913 + engines: {node: '>= 12.0.0'}
1914 + peerDependencies:
1915 + '@babel/core': '*'
1916 + babel-plugin-macros: '*'
1917 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
1918 + peerDependenciesMeta:
1919 + '@babel/core':
1920 + optional: true
1921 + babel-plugin-macros:
1922 + optional: true
1923 +
1924 + sucrase@3.35.1:
1925 + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
1926 + engines: {node: '>=16 || 14 >=14.17'}
1927 + hasBin: true
1928 +
1929 + supports-preserve-symlinks-flag@1.0.0:
1930 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
1931 + engines: {node: '>= 0.4'}
1932 +
1933 + symbol-tree@3.2.4:
1934 + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
1935 +
1936 + tailwindcss@3.4.19:
1937 + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
1938 + engines: {node: '>=14.0.0'}
1939 + hasBin: true
1940 +
1941 + thenify-all@1.6.0:
1942 + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
1943 + engines: {node: '>=0.8'}
1944 +
1945 + thenify@3.3.1:
1946 + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
1947 +
1948 + thread-stream@3.2.0:
1949 + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==}
1950 +
1951 + tinybench@2.9.0:
1952 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
1953 +
1954 + tinyexec@0.3.2:
1955 + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
1956 +
1957 + tinyglobby@0.2.17:
1958 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
1959 + engines: {node: '>=12.0.0'}
1960 +
1961 + tinypool@1.1.1:
1962 + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
1963 + engines: {node: ^18.0.0 || >=20.0.0}
1964 +
1965 + tinyrainbow@1.2.0:
1966 + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
1967 + engines: {node: '>=14.0.0'}
1968 +
1969 + tinyspy@3.0.2:
1970 + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
1971 + engines: {node: '>=14.0.0'}
1972 +
1973 + tldts-core@7.4.10:
1974 + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
1975 +
1976 + tldts@7.4.10:
1977 + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
1978 + hasBin: true
1979 +
1980 + to-regex-range@5.0.1:
1981 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1982 + engines: {node: '>=8.0'}
1983 +
1984 + toad-cache@3.7.4:
1985 + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
1986 + engines: {node: '>=20'}
1987 +
1988 + tough-cookie@4.1.4:
1989 + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
1990 + engines: {node: '>=6'}
1991 +
1992 + tough-cookie@6.0.2:
1993 + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
1994 + engines: {node: '>=16'}
1995 +
1996 + tr46@5.1.1:
1997 + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
1998 + engines: {node: '>=18'}
1999 +
2000 + tr46@6.0.0:
2001 + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
2002 + engines: {node: '>=20'}
2003 +
2004 + ts-interface-checker@0.1.13:
2005 + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
2006 +
2007 + tslib@2.8.1:
2008 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
2009 +
2010 + tsx@4.23.11:
2011 + resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}
2012 + engines: {node: '>=18.0.0'}
2013 + hasBin: true
2014 +
2015 + turbo@2.10.9:
2016 + resolution: {integrity: sha512-Yl9+ukxH+UmPtKidpDkjn82tvPoEvFNb9UACd9vUomN1Ft0cwl3rx0P8yC1D93W9EOsWRMjllvIDG8y25sFOog==}
2017 + hasBin: true
2018 +
2019 + typescript@5.9.3:
2020 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
2021 + engines: {node: '>=14.17'}
2022 + hasBin: true
2023 +
2024 + undici-types@6.21.0:
2025 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
2026 +
2027 + undici@8.10.0:
2028 + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
2029 + engines: {node: '>=22.19.0'}
2030 +
2031 + universalify@0.2.0:
2032 + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
2033 + engines: {node: '>= 4.0.0'}
2034 +
2035 + update-browserslist-db@1.3.0:
2036 + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==}
2037 + hasBin: true
2038 + peerDependencies:
2039 + browserslist: '>= 4.21.0'
2040 +
2041 + url-parse@1.5.10:
2042 + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
2043 +
2044 + util-deprecate@1.0.2:
2045 + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
2046 +
2047 + vite-node@2.1.9:
2048 + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
2049 + engines: {node: ^18.0.0 || >=20.0.0}
2050 + hasBin: true
2051 +
2052 + vite@5.4.21:
2053 + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
2054 + engines: {node: ^18.0.0 || >=20.0.0}
2055 + hasBin: true
2056 + peerDependencies:
2057 + '@types/node': ^18.0.0 || >=20.0.0
2058 + less: '*'
2059 + lightningcss: ^1.21.0
2060 + sass: '*'
2061 + sass-embedded: '*'
2062 + stylus: '*'
2063 + sugarss: '*'
2064 + terser: ^5.4.0
2065 + peerDependenciesMeta:
2066 + '@types/node':
2067 + optional: true
2068 + less:
2069 + optional: true
2070 + lightningcss:
2071 + optional: true
2072 + sass:
2073 + optional: true
2074 + sass-embedded:
2075 + optional: true
2076 + stylus:
2077 + optional: true
2078 + sugarss:
2079 + optional: true
2080 + terser:
2081 + optional: true
2082 +
2083 + vitest@2.1.9:
2084 + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
2085 + engines: {node: ^18.0.0 || >=20.0.0}
2086 + hasBin: true
2087 + peerDependencies:
2088 + '@edge-runtime/vm': '*'
2089 + '@types/node': ^18.0.0 || >=20.0.0
2090 + '@vitest/browser': 2.1.9
2091 + '@vitest/ui': 2.1.9
2092 + happy-dom: '*'
2093 + jsdom: '*'
2094 + peerDependenciesMeta:
2095 + '@edge-runtime/vm':
2096 + optional: true
2097 + '@types/node':
2098 + optional: true
2099 + '@vitest/browser':
2100 + optional: true
2101 + '@vitest/ui':
2102 + optional: true
2103 + happy-dom:
2104 + optional: true
2105 + jsdom:
2106 + optional: true
2107 +
2108 + w3c-xmlserializer@5.0.0:
2109 + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
2110 + engines: {node: '>=18'}
2111 +
2112 + webidl-conversions@7.0.0:
2113 + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
2114 + engines: {node: '>=12'}
2115 +
2116 + webidl-conversions@8.0.1:
2117 + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
2118 + engines: {node: '>=20'}
2119 +
2120 + whatwg-encoding@3.1.1:
2121 + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
2122 + engines: {node: '>=18'}
2123 + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
2124 +
2125 + whatwg-mimetype@4.0.0:
2126 + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
2127 + engines: {node: '>=18'}
2128 +
2129 + whatwg-mimetype@5.0.0:
2130 + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
2131 + engines: {node: '>=20'}
2132 +
2133 + whatwg-url@14.2.0:
2134 + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
2135 + engines: {node: '>=18'}
2136 +
2137 + whatwg-url@16.0.1:
2138 + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
2139 + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
2140 +
2141 + whatwg-url@17.1.0:
2142 + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
2143 + engines: {node: ^22.14.0 || >=24.0.0}
2144 +
2145 + why-is-node-running@2.3.0:
2146 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
2147 + engines: {node: '>=8'}
2148 + hasBin: true
2149 +
2150 + ws@8.21.3:
2151 + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
2152 + engines: {node: '>=10.0.0'}
2153 + peerDependencies:
2154 + bufferutil: ^4.0.1
2155 + utf-8-validate: '>=5.0.2'
2156 + peerDependenciesMeta:
2157 + bufferutil:
2158 + optional: true
2159 + utf-8-validate:
2160 + optional: true
2161 +
2162 + xml-name-validator@5.0.0:
2163 + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
2164 + engines: {node: '>=18'}
2165 +
2166 + xmlchars@2.2.0:
2167 + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
2168 +
2169 + yaml@2.9.0:
2170 + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
2171 + engines: {node: '>= 14.6'}
2172 + hasBin: true
2173 +
2174 + zod@3.25.76:
2175 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
2176 +
2177 +snapshots:
2178 +
2179 + '@alloc/quick-lru@5.2.0': {}
2180 +
2181 + '@asamuzakjp/css-color@3.2.0':
2182 + dependencies:
2183 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
2184 + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
2185 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2186 + '@csstools/css-tokenizer': 3.0.4
2187 + lru-cache: 10.4.3
2188 +
2189 + '@asamuzakjp/css-color@6.0.7':
2190 + dependencies:
2191 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
2192 + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
2193 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
2194 + '@csstools/css-tokenizer': 4.0.0
2195 + lru-cache: 11.5.2
2196 +
2197 + '@asamuzakjp/dom-selector@8.3.2':
2198 + dependencies:
2199 + bidi-js: 1.0.3
2200 + css-tree: 3.2.1
2201 + is-potential-custom-element-name: 1.0.1
2202 + lru-cache: 11.5.2
2203 +
2204 + '@bramus/specificity@2.4.2':
2205 + dependencies:
2206 + css-tree: 3.2.1
2207 +
2208 + '@csstools/color-helpers@5.1.0': {}
2209 +
2210 + '@csstools/color-helpers@6.1.0': {}
2211 +
2212 + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
2213 + dependencies:
2214 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2215 + '@csstools/css-tokenizer': 3.0.4
2216 +
2217 + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
2218 + dependencies:
2219 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
2220 + '@csstools/css-tokenizer': 4.0.0
2221 +
2222 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
2223 + dependencies:
2224 + '@csstools/color-helpers': 5.1.0
2225 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
2226 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2227 + '@csstools/css-tokenizer': 3.0.4
2228 +
2229 + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
2230 + dependencies:
2231 + '@csstools/color-helpers': 6.1.0
2232 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
2233 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
2234 + '@csstools/css-tokenizer': 4.0.0
2235 +
2236 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
2237 + dependencies:
2238 + '@csstools/css-tokenizer': 3.0.4
2239 +
2240 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
2241 + dependencies:
2242 + '@csstools/css-tokenizer': 4.0.0
2243 +
2244 + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
2245 + optionalDependencies:
2246 + css-tree: 3.2.1
2247 +
2248 + '@csstools/css-tokenizer@3.0.4': {}
2249 +
2250 + '@csstools/css-tokenizer@4.0.0': {}
2251 +
2252 + '@esbuild/aix-ppc64@0.21.5':
2253 + optional: true
2254 +
2255 + '@esbuild/aix-ppc64@0.23.1':
2256 + optional: true
2257 +
2258 + '@esbuild/aix-ppc64@0.28.2':
2259 + optional: true
2260 +
2261 + '@esbuild/android-arm64@0.21.5':
2262 + optional: true
2263 +
2264 + '@esbuild/android-arm64@0.23.1':
2265 + optional: true
2266 +
2267 + '@esbuild/android-arm64@0.28.2':
2268 + optional: true
2269 +
2270 + '@esbuild/android-arm@0.21.5':
2271 + optional: true
2272 +
2273 + '@esbuild/android-arm@0.23.1':
2274 + optional: true
2275 +
2276 + '@esbuild/android-arm@0.28.2':
2277 + optional: true
2278 +
2279 + '@esbuild/android-x64@0.21.5':
2280 + optional: true
2281 +
2282 + '@esbuild/android-x64@0.23.1':
2283 + optional: true
2284 +
2285 + '@esbuild/android-x64@0.28.2':
2286 + optional: true
2287 +
2288 + '@esbuild/darwin-arm64@0.21.5':
2289 + optional: true
2290 +
2291 + '@esbuild/darwin-arm64@0.23.1':
2292 + optional: true
2293 +
2294 + '@esbuild/darwin-arm64@0.28.2':
2295 + optional: true
2296 +
2297 + '@esbuild/darwin-x64@0.21.5':
2298 + optional: true
2299 +
2300 + '@esbuild/darwin-x64@0.23.1':
2301 + optional: true
2302 +
2303 + '@esbuild/darwin-x64@0.28.2':
2304 + optional: true
2305 +
2306 + '@esbuild/freebsd-arm64@0.21.5':
2307 + optional: true
2308 +
2309 + '@esbuild/freebsd-arm64@0.23.1':
2310 + optional: true
2311 +
2312 + '@esbuild/freebsd-arm64@0.28.2':
2313 + optional: true
2314 +
2315 + '@esbuild/freebsd-x64@0.21.5':
2316 + optional: true
2317 +
2318 + '@esbuild/freebsd-x64@0.23.1':
2319 + optional: true
2320 +
2321 + '@esbuild/freebsd-x64@0.28.2':
2322 + optional: true
2323 +
2324 + '@esbuild/linux-arm64@0.21.5':
2325 + optional: true
2326 +
2327 + '@esbuild/linux-arm64@0.23.1':
2328 + optional: true
2329 +
2330 + '@esbuild/linux-arm64@0.28.2':
2331 + optional: true
2332 +
2333 + '@esbuild/linux-arm@0.21.5':
2334 + optional: true
2335 +
2336 + '@esbuild/linux-arm@0.23.1':
2337 + optional: true
2338 +
2339 + '@esbuild/linux-arm@0.28.2':
2340 + optional: true
2341 +
2342 + '@esbuild/linux-ia32@0.21.5':
2343 + optional: true
2344 +
2345 + '@esbuild/linux-ia32@0.23.1':
2346 + optional: true
2347 +
2348 + '@esbuild/linux-ia32@0.28.2':
2349 + optional: true
2350 +
2351 + '@esbuild/linux-loong64@0.21.5':
2352 + optional: true
2353 +
2354 + '@esbuild/linux-loong64@0.23.1':
2355 + optional: true
2356 +
2357 + '@esbuild/linux-loong64@0.28.2':
2358 + optional: true
2359 +
2360 + '@esbuild/linux-mips64el@0.21.5':
2361 + optional: true
2362 +
2363 + '@esbuild/linux-mips64el@0.23.1':
2364 + optional: true
2365 +
2366 + '@esbuild/linux-mips64el@0.28.2':
2367 + optional: true
2368 +
2369 + '@esbuild/linux-ppc64@0.21.5':
2370 + optional: true
2371 +
2372 + '@esbuild/linux-ppc64@0.23.1':
2373 + optional: true
2374 +
2375 + '@esbuild/linux-ppc64@0.28.2':
2376 + optional: true
2377 +
2378 + '@esbuild/linux-riscv64@0.21.5':
2379 + optional: true
2380 +
2381 + '@esbuild/linux-riscv64@0.23.1':
2382 + optional: true
2383 +
2384 + '@esbuild/linux-riscv64@0.28.2':
2385 + optional: true
2386 +
2387 + '@esbuild/linux-s390x@0.21.5':
2388 + optional: true
2389 +
2390 + '@esbuild/linux-s390x@0.23.1':
2391 + optional: true
2392 +
2393 + '@esbuild/linux-s390x@0.28.2':
2394 + optional: true
2395 +
2396 + '@esbuild/linux-x64@0.21.5':
2397 + optional: true
2398 +
2399 + '@esbuild/linux-x64@0.23.1':
2400 + optional: true
2401 +
2402 + '@esbuild/linux-x64@0.28.2':
2403 + optional: true
2404 +
2405 + '@esbuild/netbsd-arm64@0.28.2':
2406 + optional: true
2407 +
2408 + '@esbuild/netbsd-x64@0.21.5':
2409 + optional: true
2410 +
2411 + '@esbuild/netbsd-x64@0.23.1':
2412 + optional: true
2413 +
2414 + '@esbuild/netbsd-x64@0.28.2':
2415 + optional: true
2416 +
2417 + '@esbuild/openbsd-arm64@0.23.1':
2418 + optional: true
2419 +
2420 + '@esbuild/openbsd-arm64@0.28.2':
2421 + optional: true
2422 +
2423 + '@esbuild/openbsd-x64@0.21.5':
2424 + optional: true
2425 +
2426 + '@esbuild/openbsd-x64@0.23.1':
2427 + optional: true
2428 +
2429 + '@esbuild/openbsd-x64@0.28.2':
2430 + optional: true
2431 +
2432 + '@esbuild/openharmony-arm64@0.28.2':
2433 + optional: true
2434 +
2435 + '@esbuild/sunos-x64@0.21.5':
2436 + optional: true
2437 +
2438 + '@esbuild/sunos-x64@0.23.1':
2439 + optional: true
2440 +
2441 + '@esbuild/sunos-x64@0.28.2':
2442 + optional: true
2443 +
2444 + '@esbuild/win32-arm64@0.21.5':
2445 + optional: true
2446 +
2447 + '@esbuild/win32-arm64@0.23.1':
2448 + optional: true
2449 +
2450 + '@esbuild/win32-arm64@0.28.2':
2451 + optional: true
2452 +
2453 + '@esbuild/win32-ia32@0.21.5':
2454 + optional: true
2455 +
2456 + '@esbuild/win32-ia32@0.23.1':
2457 + optional: true
2458 +
2459 + '@esbuild/win32-ia32@0.28.2':
2460 + optional: true
2461 +
2462 + '@esbuild/win32-x64@0.21.5':
2463 + optional: true
2464 +
2465 + '@esbuild/win32-x64@0.23.1':
2466 + optional: true
2467 +
2468 + '@esbuild/win32-x64@0.28.2':
2469 + optional: true
2470 +
2471 + '@exodus/bytes@1.15.1': {}
2472 +
2473 + '@fastify/ajv-compiler@3.6.0':
2474 + dependencies:
2475 + ajv: 8.20.0
2476 + ajv-formats: 2.1.1(ajv@8.20.0)
2477 + fast-uri: 2.4.4
2478 +
2479 + '@fastify/cors@9.0.1':
2480 + dependencies:
2481 + fastify-plugin: 4.5.1
2482 + mnemonist: 0.39.6
2483 +
2484 + '@fastify/error@3.4.1': {}
2485 +
2486 + '@fastify/fast-json-stringify-compiler@4.3.0':
2487 + dependencies:
2488 + fast-json-stringify: 5.16.1
2489 +
2490 + '@fastify/merge-json-schemas@0.1.1':
2491 + dependencies:
2492 + fast-deep-equal: 3.1.3
2493 +
2494 + '@fastify/rate-limit@9.1.0':
2495 + dependencies:
2496 + '@lukeed/ms': 2.0.2
2497 + fastify-plugin: 4.5.1
2498 + toad-cache: 3.7.4
2499 +
2500 + '@ioredis/commands@1.10.0': {}
2501 +
2502 + '@jridgewell/gen-mapping@0.3.13':
2503 + dependencies:
2504 + '@jridgewell/sourcemap-codec': 1.5.5
2505 + '@jridgewell/trace-mapping': 0.3.31
2506 +
2507 + '@jridgewell/resolve-uri@3.1.2': {}
2508 +
2509 + '@jridgewell/sourcemap-codec@1.5.5': {}
2510 +
2511 + '@jridgewell/trace-mapping@0.3.31':
2512 + dependencies:
2513 + '@jridgewell/resolve-uri': 3.1.2
2514 + '@jridgewell/sourcemap-codec': 1.5.5
2515 +
2516 + '@lukeed/ms@2.0.2': {}
2517 +
2518 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
2519 + optional: true
2520 +
2521 + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
2522 + optional: true
2523 +
2524 + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
2525 + optional: true
2526 +
2527 + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
2528 + optional: true
2529 +
2530 + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
2531 + optional: true
2532 +
2533 + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
2534 + optional: true
2535 +
2536 + '@napi-rs/lzma-linux-x64-gnu@1.5.1':
2537 + optional: true
2538 +
2539 + '@next/env@14.2.35': {}
2540 +
2541 + '@next/swc-darwin-arm64@14.2.33':
2542 + optional: true
2543 +
2544 + '@next/swc-darwin-x64@14.2.33':
2545 + optional: true
2546 +
2547 + '@next/swc-linux-arm64-gnu@14.2.33':
2548 + optional: true
2549 +
2550 + '@next/swc-linux-arm64-musl@14.2.33':
2551 + optional: true
2552 +
2553 + '@next/swc-linux-x64-gnu@14.2.33':
2554 + optional: true
2555 +
2556 + '@next/swc-linux-x64-musl@14.2.33':
2557 + optional: true
2558 +
2559 + '@next/swc-win32-arm64-msvc@14.2.33':
2560 + optional: true
2561 +
2562 + '@next/swc-win32-ia32-msvc@14.2.33':
2563 + optional: true
2564 +
2565 + '@next/swc-win32-x64-msvc@14.2.33':
2566 + optional: true
2567 +
2568 + '@nodelib/fs.scandir@2.1.5':
2569 + dependencies:
2570 + '@nodelib/fs.stat': 2.0.5
2571 + run-parallel: 1.2.0
2572 +
2573 + '@nodelib/fs.stat@2.0.5': {}
2574 +
2575 + '@nodelib/fs.walk@1.2.8':
2576 + dependencies:
2577 + '@nodelib/fs.scandir': 2.1.5
2578 + fastq: 1.20.1
2579 +
2580 + '@pinojs/redact@0.4.0': {}
2581 +
2582 + '@rollup/rollup-android-arm-eabi@4.62.4':
2583 + optional: true
2584 +
2585 + '@rollup/rollup-android-arm64@4.62.4':
2586 + optional: true
2587 +
2588 + '@rollup/rollup-darwin-arm64@4.62.4':
2589 + optional: true
2590 +
2591 + '@rollup/rollup-darwin-x64@4.62.4':
2592 + optional: true
2593 +
2594 + '@rollup/rollup-freebsd-arm64@4.62.4':
2595 + optional: true
2596 +
2597 + '@rollup/rollup-freebsd-x64@4.62.4':
2598 + optional: true
2599 +
2600 + '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
2601 + optional: true
2602 +
2603 + '@rollup/rollup-linux-arm-musleabihf@4.62.4':
2604 + optional: true
2605 +
2606 + '@rollup/rollup-linux-arm64-gnu@4.62.4':
2607 + optional: true
2608 +
2609 + '@rollup/rollup-linux-arm64-musl@4.62.4':
2610 + optional: true
2611 +
2612 + '@rollup/rollup-linux-loong64-gnu@4.62.4':
2613 + optional: true
2614 +
2615 + '@rollup/rollup-linux-loong64-musl@4.62.4':
2616 + optional: true
2617 +
2618 + '@rollup/rollup-linux-ppc64-gnu@4.62.4':
2619 + optional: true
2620 +
2621 + '@rollup/rollup-linux-ppc64-musl@4.62.4':
2622 + optional: true
2623 +
2624 + '@rollup/rollup-linux-riscv64-gnu@4.62.4':
2625 + optional: true
2626 +
2627 + '@rollup/rollup-linux-riscv64-musl@4.62.4':
2628 + optional: true
2629 +
2630 + '@rollup/rollup-linux-s390x-gnu@4.62.4':
2631 + optional: true
2632 +
2633 + '@rollup/rollup-linux-x64-gnu@4.62.4':
2634 + optional: true
2635 +
2636 + '@rollup/rollup-linux-x64-musl@4.62.4':
2637 + optional: true
2638 +
2639 + '@rollup/rollup-openbsd-x64@4.62.4':
2640 + optional: true
2641 +
2642 + '@rollup/rollup-openharmony-arm64@4.62.4':
2643 + optional: true
2644 +
2645 + '@rollup/rollup-win32-arm64-msvc@4.62.4':
2646 + optional: true
2647 +
2648 + '@rollup/rollup-win32-ia32-msvc@4.62.4':
2649 + optional: true
2650 +
2651 + '@rollup/rollup-win32-x64-gnu@4.62.4':
2652 + optional: true
2653 +
2654 + '@rollup/rollup-win32-x64-msvc@4.62.4':
2655 + optional: true
2656 +
2657 + '@swc/counter@0.1.3': {}
2658 +
2659 + '@swc/helpers@0.5.5':
2660 + dependencies:
2661 + '@swc/counter': 0.1.3
2662 + tslib: 2.8.1
2663 +
2664 + '@turbo/darwin-64@2.10.9':
2665 + optional: true
2666 +
2667 + '@turbo/darwin-arm64@2.10.9':
2668 + optional: true
2669 +
2670 + '@turbo/linux-64@2.10.9':
2671 + optional: true
2672 +
2673 + '@turbo/linux-arm64@2.10.9':
2674 + optional: true
2675 +
2676 + '@turbo/windows-64@2.10.9':
2677 + optional: true
2678 +
2679 + '@turbo/windows-arm64@2.10.9':
2680 + optional: true
2681 +
2682 + '@types/estree@1.0.9': {}
2683 +
2684 + '@types/node@20.19.43':
2685 + dependencies:
2686 + undici-types: 6.21.0
2687 +
2688 + '@types/prop-types@15.7.15': {}
2689 +
2690 + '@types/react-dom@18.3.7(@types/react@18.3.31)':
2691 + dependencies:
2692 + '@types/react': 18.3.31
2693 +
2694 + '@types/react@18.3.31':
2695 + dependencies:
2696 + '@types/prop-types': 15.7.15
2697 + csstype: 3.2.3
2698 +
2699 + '@vitest/expect@2.1.9':
2700 + dependencies:
2701 + '@vitest/spy': 2.1.9
2702 + '@vitest/utils': 2.1.9
2703 + chai: 5.3.3
2704 + tinyrainbow: 1.2.0
2705 +
2706 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.43))':
2707 + dependencies:
2708 + '@vitest/spy': 2.1.9
2709 + estree-walker: 3.0.3
2710 + magic-string: 0.30.21
2711 + optionalDependencies:
2712 + vite: 5.4.21(@types/node@20.19.43)
2713 +
2714 + '@vitest/pretty-format@2.1.9':
2715 + dependencies:
2716 + tinyrainbow: 1.2.0
2717 +
2718 + '@vitest/runner@2.1.9':
2719 + dependencies:
2720 + '@vitest/utils': 2.1.9
2721 + pathe: 1.1.2
2722 +
2723 + '@vitest/snapshot@2.1.9':
2724 + dependencies:
2725 + '@vitest/pretty-format': 2.1.9
2726 + magic-string: 0.30.21
2727 + pathe: 1.1.2
2728 +
2729 + '@vitest/spy@2.1.9':
2730 + dependencies:
2731 + tinyspy: 3.0.2
2732 +
2733 + '@vitest/utils@2.1.9':
2734 + dependencies:
2735 + '@vitest/pretty-format': 2.1.9
2736 + loupe: 3.2.1
2737 + tinyrainbow: 1.2.0
2738 +
2739 + abstract-logging@2.0.1: {}
2740 +
2741 + agent-base@7.1.4: {}
2742 +
2743 + ajv-formats@2.1.1(ajv@8.20.0):
2744 + optionalDependencies:
2745 + ajv: 8.20.0
2746 +
2747 + ajv-formats@3.0.1(ajv@8.20.0):
2748 + optionalDependencies:
2749 + ajv: 8.20.0
2750 +
2751 + ajv@8.20.0:
2752 + dependencies:
2753 + fast-deep-equal: 3.1.3
2754 + fast-uri: 3.1.5
2755 + json-schema-traverse: 1.0.0
2756 + require-from-string: 2.0.2
2757 +
2758 + any-promise@1.3.0: {}
2759 +
2760 + anymatch@3.1.3:
2761 + dependencies:
2762 + normalize-path: 3.0.0
2763 + picomatch: 2.3.2
2764 +
2765 + arg@5.0.2: {}
2766 +
2767 + assertion-error@2.0.1: {}
2768 +
2769 + asynckit@0.4.0: {}
2770 +
2771 + atomic-sleep@1.0.0: {}
2772 +
2773 + autoprefixer@10.5.4(postcss@8.5.26):
2774 + dependencies:
2775 + browserslist: 4.28.8
2776 + caniuse-lite: 1.0.30001809
2777 + fraction.js: 5.3.4
2778 + picocolors: 1.1.1
2779 + postcss: 8.5.26
2780 + postcss-value-parser: 4.2.0
2781 +
2782 + avvio@8.4.0:
2783 + dependencies:
2784 + '@fastify/error': 3.4.1
2785 + fastq: 1.20.1
2786 +
2787 + baseline-browser-mapping@2.11.13: {}
2788 +
2789 + bidi-js@1.0.3:
2790 + dependencies:
2791 + require-from-string: 2.0.2
2792 +
2793 + binary-extensions@2.3.0: {}
2794 +
2795 + braces@3.0.3:
2796 + dependencies:
2797 + fill-range: 7.1.1
2798 +
2799 + browserslist@4.28.8:
2800 + dependencies:
2801 + baseline-browser-mapping: 2.11.13
2802 + caniuse-lite: 1.0.30001809
2803 + electron-to-chromium: 1.5.403
2804 + node-releases: 2.0.53
2805 + update-browserslist-db: 1.3.0(browserslist@4.28.8)
2806 +
2807 + bullmq@5.81.3:
2808 + dependencies:
2809 + cron-parser: 4.9.0
2810 + ioredis: 5.11.1
2811 + msgpackr: 2.0.5
2812 + node-abort-controller: 3.1.1
2813 + semver: 7.8.5
2814 + tslib: 2.8.1
2815 + transitivePeerDependencies:
2816 + - supports-color
2817 +
2818 + busboy@1.6.0:
2819 + dependencies:
2820 + streamsearch: 1.1.0
2821 +
2822 + cac@6.7.14: {}
2823 +
2824 + call-bind-apply-helpers@1.0.2:
2825 + dependencies:
2826 + es-errors: 1.3.0
2827 + function-bind: 1.1.2
2828 +
2829 + camelcase-css@2.0.1: {}
2830 +
2831 + caniuse-lite@1.0.30001809: {}
2832 +
2833 + chai@5.3.3:
2834 + dependencies:
2835 + assertion-error: 2.0.1
2836 + check-error: 2.1.3
2837 + deep-eql: 5.0.2
2838 + loupe: 3.2.1
2839 + pathval: 2.0.1
2840 +
2841 + check-error@2.1.3: {}
2842 +
2843 + chokidar@3.6.0:
2844 + dependencies:
2845 + anymatch: 3.1.3
2846 + braces: 3.0.3
2847 + glob-parent: 5.1.2
2848 + is-binary-path: 2.1.0
2849 + is-glob: 4.0.3
2850 + normalize-path: 3.0.0
2851 + readdirp: 3.6.0
2852 + optionalDependencies:
2853 + fsevents: 2.3.3
2854 +
2855 + client-only@0.0.1: {}
2856 +
2857 + cluster-key-slot@1.1.1: {}
2858 +
2859 + combined-stream@1.0.8:
2860 + dependencies:
2861 + delayed-stream: 1.0.0
2862 +
2863 + commander@4.1.1: {}
2864 +
2865 + cookie@0.7.2: {}
2866 +
2867 + cron-parser@4.9.0:
2868 + dependencies:
2869 + luxon: 3.7.2
2870 +
2871 + css-tree@3.2.1:
2872 + dependencies:
2873 + mdn-data: 2.27.1
2874 + source-map-js: 1.2.1
2875 +
2876 + cssesc@3.0.0: {}
2877 +
2878 + cssstyle@4.6.0:
2879 + dependencies:
2880 + '@asamuzakjp/css-color': 3.2.0
2881 + rrweb-cssom: 0.8.0
2882 +
2883 + csstype@3.2.3: {}
2884 +
2885 + data-urls@5.0.0:
2886 + dependencies:
2887 + whatwg-mimetype: 4.0.0
2888 + whatwg-url: 14.2.0
2889 +
2890 + data-urls@7.0.0:
2891 + dependencies:
2892 + whatwg-mimetype: 5.0.0
2893 + whatwg-url: 16.0.1
2894 + transitivePeerDependencies:
2895 + - '@noble/hashes'
2896 +
2897 + debug@4.4.3:
2898 + dependencies:
2899 + ms: 2.1.3
2900 +
2901 + decimal.js@10.6.0: {}
2902 +
2903 + deep-eql@5.0.2: {}
2904 +
2905 + delayed-stream@1.0.0: {}
2906 +
2907 + denque@2.1.0: {}
2908 +
2909 + detect-libc@2.1.2:
2910 + optional: true
2911 +
2912 + didyoumean@1.2.2: {}
2913 +
2914 + dlv@1.1.3: {}
2915 +
2916 + dunder-proto@1.0.1:
2917 + dependencies:
2918 + call-bind-apply-helpers: 1.0.2
2919 + es-errors: 1.3.0
2920 + gopd: 1.2.0
2921 +
2922 + electron-to-chromium@1.5.403: {}
2923 +
2924 + entities@6.0.1: {}
2925 +
2926 + entities@8.0.0: {}
2927 +
2928 + es-define-property@1.0.1: {}
2929 +
2930 + es-errors@1.3.0: {}
2931 +
2932 + es-module-lexer@1.7.0: {}
2933 +
2934 + es-object-atoms@1.1.2:
2935 + dependencies:
2936 + es-errors: 1.3.0
2937 +
2938 + es-set-tostringtag@2.1.0:
2939 + dependencies:
2940 + es-errors: 1.3.0
2941 + get-intrinsic: 1.3.0
2942 + has-tostringtag: 1.0.2
2943 + hasown: 2.0.4
2944 +
2945 + esbuild@0.21.5:
2946 + optionalDependencies:
2947 + '@esbuild/aix-ppc64': 0.21.5
2948 + '@esbuild/android-arm': 0.21.5
2949 + '@esbuild/android-arm64': 0.21.5
2950 + '@esbuild/android-x64': 0.21.5
2951 + '@esbuild/darwin-arm64': 0.21.5
2952 + '@esbuild/darwin-x64': 0.21.5
2953 + '@esbuild/freebsd-arm64': 0.21.5
2954 + '@esbuild/freebsd-x64': 0.21.5
2955 + '@esbuild/linux-arm': 0.21.5
2956 + '@esbuild/linux-arm64': 0.21.5
2957 + '@esbuild/linux-ia32': 0.21.5
2958 + '@esbuild/linux-loong64': 0.21.5
2959 + '@esbuild/linux-mips64el': 0.21.5
2960 + '@esbuild/linux-ppc64': 0.21.5
2961 + '@esbuild/linux-riscv64': 0.21.5
2962 + '@esbuild/linux-s390x': 0.21.5
2963 + '@esbuild/linux-x64': 0.21.5
2964 + '@esbuild/netbsd-x64': 0.21.5
2965 + '@esbuild/openbsd-x64': 0.21.5
2966 + '@esbuild/sunos-x64': 0.21.5
2967 + '@esbuild/win32-arm64': 0.21.5
2968 + '@esbuild/win32-ia32': 0.21.5
2969 + '@esbuild/win32-x64': 0.21.5
2970 +
2971 + esbuild@0.23.1:
2972 + optionalDependencies:
2973 + '@esbuild/aix-ppc64': 0.23.1
2974 + '@esbuild/android-arm': 0.23.1
2975 + '@esbuild/android-arm64': 0.23.1
2976 + '@esbuild/android-x64': 0.23.1
2977 + '@esbuild/darwin-arm64': 0.23.1
2978 + '@esbuild/darwin-x64': 0.23.1
2979 + '@esbuild/freebsd-arm64': 0.23.1
2980 + '@esbuild/freebsd-x64': 0.23.1
2981 + '@esbuild/linux-arm': 0.23.1
2982 + '@esbuild/linux-arm64': 0.23.1
2983 + '@esbuild/linux-ia32': 0.23.1
2984 + '@esbuild/linux-loong64': 0.23.1
2985 + '@esbuild/linux-mips64el': 0.23.1
2986 + '@esbuild/linux-ppc64': 0.23.1
2987 + '@esbuild/linux-riscv64': 0.23.1
2988 + '@esbuild/linux-s390x': 0.23.1
2989 + '@esbuild/linux-x64': 0.23.1
2990 + '@esbuild/netbsd-x64': 0.23.1
2991 + '@esbuild/openbsd-arm64': 0.23.1
2992 + '@esbuild/openbsd-x64': 0.23.1
2993 + '@esbuild/sunos-x64': 0.23.1
2994 + '@esbuild/win32-arm64': 0.23.1
2995 + '@esbuild/win32-ia32': 0.23.1
2996 + '@esbuild/win32-x64': 0.23.1
2997 +
2998 + esbuild@0.28.2:
2999 + optionalDependencies:
3000 + '@esbuild/aix-ppc64': 0.28.2
3001 + '@esbuild/android-arm': 0.28.2
3002 + '@esbuild/android-arm64': 0.28.2
3003 + '@esbuild/android-x64': 0.28.2
3004 + '@esbuild/darwin-arm64': 0.28.2
3005 + '@esbuild/darwin-x64': 0.28.2
3006 + '@esbuild/freebsd-arm64': 0.28.2
3007 + '@esbuild/freebsd-x64': 0.28.2
3008 + '@esbuild/linux-arm': 0.28.2
3009 + '@esbuild/linux-arm64': 0.28.2
3010 + '@esbuild/linux-ia32': 0.28.2
3011 + '@esbuild/linux-loong64': 0.28.2
3012 + '@esbuild/linux-mips64el': 0.28.2
3013 + '@esbuild/linux-ppc64': 0.28.2
3014 + '@esbuild/linux-riscv64': 0.28.2
3015 + '@esbuild/linux-s390x': 0.28.2
3016 + '@esbuild/linux-x64': 0.28.2
3017 + '@esbuild/netbsd-arm64': 0.28.2
3018 + '@esbuild/netbsd-x64': 0.28.2
3019 + '@esbuild/openbsd-arm64': 0.28.2
3020 + '@esbuild/openbsd-x64': 0.28.2
3021 + '@esbuild/openharmony-arm64': 0.28.2
3022 + '@esbuild/sunos-x64': 0.28.2
3023 + '@esbuild/win32-arm64': 0.28.2
3024 + '@esbuild/win32-ia32': 0.28.2
3025 + '@esbuild/win32-x64': 0.28.2
3026 +
3027 + escalade@3.2.0: {}
3028 +
3029 + estree-walker@3.0.3:
3030 + dependencies:
3031 + '@types/estree': 1.0.9
3032 +
3033 + expect-type@1.4.0: {}
3034 +
3035 + fast-check@3.23.2:
3036 + dependencies:
3037 + pure-rand: 6.1.0
3038 +
3039 + fast-content-type-parse@1.1.0: {}
3040 +
3041 + fast-decode-uri-component@1.0.1: {}
3042 +
3043 + fast-deep-equal@3.1.3: {}
3044 +
3045 + fast-glob@3.3.3:
3046 + dependencies:
3047 + '@nodelib/fs.stat': 2.0.5
3048 + '@nodelib/fs.walk': 1.2.8
3049 + glob-parent: 5.1.2
3050 + merge2: 1.4.1
3051 + micromatch: 4.0.8
3052 +
3053 + fast-json-stringify@5.16.1:
3054 + dependencies:
3055 + '@fastify/merge-json-schemas': 0.1.1
3056 + ajv: 8.20.0
3057 + ajv-formats: 3.0.1(ajv@8.20.0)
3058 + fast-deep-equal: 3.1.3
3059 + fast-uri: 2.4.4
3060 + json-schema-ref-resolver: 1.0.1
3061 + rfdc: 1.4.1
3062 +
3063 + fast-querystring@1.1.2:
3064 + dependencies:
3065 + fast-decode-uri-component: 1.0.1
3066 +
3067 + fast-uri@2.4.4: {}
3068 +
3069 + fast-uri@3.1.5: {}
3070 +
3071 + fastify-plugin@4.5.1: {}
3072 +
3073 + fastify@4.29.1:
3074 + dependencies:
3075 + '@fastify/ajv-compiler': 3.6.0
3076 + '@fastify/error': 3.4.1
3077 + '@fastify/fast-json-stringify-compiler': 4.3.0
3078 + abstract-logging: 2.0.1
3079 + avvio: 8.4.0
3080 + fast-content-type-parse: 1.1.0
3081 + fast-json-stringify: 5.16.1
3082 + find-my-way: 8.2.2
3083 + light-my-request: 5.14.0
3084 + pino: 9.14.0
3085 + process-warning: 3.0.0
3086 + proxy-addr: 2.0.7
3087 + rfdc: 1.4.1
3088 + secure-json-parse: 2.7.0
3089 + semver: 7.8.5
3090 + toad-cache: 3.7.4
3091 +
3092 + fastq@1.20.1:
3093 + dependencies:
3094 + reusify: 1.1.0
3095 +
3096 + fdir@6.5.0(picomatch@4.0.5):
3097 + optionalDependencies:
3098 + picomatch: 4.0.5
3099 +
3100 + fill-range@7.1.1:
3101 + dependencies:
3102 + to-regex-range: 5.0.1
3103 +
3104 + find-my-way@8.2.2:
3105 + dependencies:
3106 + fast-deep-equal: 3.1.3
3107 + fast-querystring: 1.1.2
3108 + safe-regex2: 3.1.0
3109 +
3110 + form-data@4.0.6:
3111 + dependencies:
3112 + asynckit: 0.4.0
3113 + combined-stream: 1.0.8
3114 + es-set-tostringtag: 2.1.0
3115 + hasown: 2.0.4
3116 + mime-types: 2.1.35
3117 +
3118 + forwarded@0.2.0: {}
3119 +
3120 + fraction.js@5.3.4: {}
3121 +
3122 + fsevents@2.3.3:
3123 + optional: true
3124 +
3125 + function-bind@1.1.2: {}
3126 +
3127 + get-intrinsic@1.3.0:
3128 + dependencies:
3129 + call-bind-apply-helpers: 1.0.2
3130 + es-define-property: 1.0.1
3131 + es-errors: 1.3.0
3132 + es-object-atoms: 1.1.2
3133 + function-bind: 1.1.2
3134 + get-proto: 1.0.1
3135 + gopd: 1.2.0
3136 + has-symbols: 1.1.0
3137 + hasown: 2.0.4
3138 + math-intrinsics: 1.1.0
3139 +
3140 + get-proto@1.0.1:
3141 + dependencies:
3142 + dunder-proto: 1.0.1
3143 + es-object-atoms: 1.1.2
3144 +
3145 + glob-parent@5.1.2:
3146 + dependencies:
3147 + is-glob: 4.0.3
3148 +
3149 + glob-parent@6.0.2:
3150 + dependencies:
3151 + is-glob: 4.0.3
3152 +
3153 + gopd@1.2.0: {}
3154 +
3155 + graceful-fs@4.2.11: {}
3156 +
3157 + has-symbols@1.1.0: {}
3158 +
3159 + has-tostringtag@1.0.2:
3160 + dependencies:
3161 + has-symbols: 1.1.0
3162 +
3163 + hasown@2.0.4:
3164 + dependencies:
3165 + function-bind: 1.1.2
3166 +
3167 + html-encoding-sniffer@4.0.0:
3168 + dependencies:
3169 + whatwg-encoding: 3.1.1
3170 +
3171 + html-encoding-sniffer@6.0.0:
3172 + dependencies:
3173 + '@exodus/bytes': 1.15.1
3174 + transitivePeerDependencies:
3175 + - '@noble/hashes'
3176 +
3177 + http-proxy-agent@7.0.2:
3178 + dependencies:
3179 + agent-base: 7.1.4
3180 + debug: 4.4.3
3181 + transitivePeerDependencies:
3182 + - supports-color
3183 +
3184 + https-proxy-agent@7.0.6:
3185 + dependencies:
3186 + agent-base: 7.1.4
3187 + debug: 4.4.3
3188 + transitivePeerDependencies:
3189 + - supports-color
3190 +
3191 + iconv-lite@0.6.3:
3192 + dependencies:
3193 + safer-buffer: 2.1.2
3194 +
3195 + ioredis@5.11.1:
3196 + dependencies:
3197 + '@ioredis/commands': 1.10.0
3198 + cluster-key-slot: 1.1.1
3199 + debug: 4.4.3
3200 + denque: 2.1.0
3201 + redis-errors: 1.2.0
3202 + redis-parser: 3.0.0
3203 + standard-as-callback: 2.1.0
3204 + transitivePeerDependencies:
3205 + - supports-color
3206 +
3207 + ipaddr.js@1.9.1: {}
3208 +
3209 + is-binary-path@2.1.0:
3210 + dependencies:
3211 + binary-extensions: 2.3.0
3212 +
3213 + is-core-module@2.16.2:
3214 + dependencies:
3215 + hasown: 2.0.4
3216 +
3217 + is-extglob@2.1.1: {}
3218 +
3219 + is-glob@4.0.3:
3220 + dependencies:
3221 + is-extglob: 2.1.1
3222 +
3223 + is-number@7.0.0: {}
3224 +
3225 + is-potential-custom-element-name@1.0.1: {}
3226 +
3227 + jiti@1.21.7: {}
3228 +
3229 + js-tokens@4.0.0: {}
3230 +
3231 + jsdom@24.1.3:
3232 + dependencies:
3233 + cssstyle: 4.6.0
3234 + data-urls: 5.0.0
3235 + decimal.js: 10.6.0
3236 + form-data: 4.0.6
3237 + html-encoding-sniffer: 4.0.0
3238 + http-proxy-agent: 7.0.2
3239 + https-proxy-agent: 7.0.6
3240 + is-potential-custom-element-name: 1.0.1
3241 + nwsapi: 2.2.24
3242 + parse5: 7.3.0
3243 + rrweb-cssom: 0.7.1
3244 + saxes: 6.0.0
3245 + symbol-tree: 3.2.4
3246 + tough-cookie: 4.1.4
3247 + w3c-xmlserializer: 5.0.0
3248 + webidl-conversions: 7.0.0
3249 + whatwg-encoding: 3.1.1
3250 + whatwg-mimetype: 4.0.0
3251 + whatwg-url: 14.2.0
3252 + ws: 8.21.3
3253 + xml-name-validator: 5.0.0
3254 + transitivePeerDependencies:
3255 + - bufferutil
3256 + - supports-color
3257 + - utf-8-validate
3258 +
3259 + jsdom@30.0.1:
3260 + dependencies:
3261 + '@asamuzakjp/css-color': 6.0.7
3262 + '@asamuzakjp/dom-selector': 8.3.2
3263 + '@bramus/specificity': 2.4.2
3264 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
3265 + '@exodus/bytes': 1.15.1
3266 + css-tree: 3.2.1
3267 + data-urls: 7.0.0
3268 + decimal.js: 10.6.0
3269 + html-encoding-sniffer: 6.0.0
3270 + is-potential-custom-element-name: 1.0.1
3271 + lru-cache: 11.5.2
3272 + parse5: 8.0.1
3273 + saxes: 6.0.0
3274 + symbol-tree: 3.2.4
3275 + tough-cookie: 6.0.2
3276 + undici: 8.10.0
3277 + w3c-xmlserializer: 5.0.0
3278 + webidl-conversions: 8.0.1
3279 + whatwg-mimetype: 5.0.0
3280 + whatwg-url: 17.1.0
3281 + xml-name-validator: 5.0.0
3282 + transitivePeerDependencies:
3283 + - '@noble/hashes'
3284 +
3285 + json-schema-ref-resolver@1.0.1:
3286 + dependencies:
3287 + fast-deep-equal: 3.1.3
3288 +
3289 + json-schema-traverse@1.0.0: {}
3290 +
3291 + light-my-request@5.14.0:
3292 + dependencies:
3293 + cookie: 0.7.2
3294 + process-warning: 3.0.0
3295 + set-cookie-parser: 2.7.2
3296 +
3297 + lilconfig@3.1.3: {}
3298 +
3299 + lines-and-columns@1.2.4: {}
3300 +
3301 + loose-envify@1.4.0:
3302 + dependencies:
3303 + js-tokens: 4.0.0
3304 +
3305 + loupe@3.2.1: {}
3306 +
3307 + lru-cache@10.4.3: {}
3308 +
3309 + lru-cache@11.5.2: {}
3310 +
3311 + luxon@3.7.2: {}
3312 +
3313 + magic-string@0.30.21:
3314 + dependencies:
3315 + '@jridgewell/sourcemap-codec': 1.5.5
3316 +
3317 + math-intrinsics@1.1.0: {}
3318 +
3319 + mdn-data@2.27.1: {}
3320 +
3321 + merge2@1.4.1: {}
3322 +
3323 + micromatch@4.0.8:
3324 + dependencies:
3325 + braces: 3.0.3
3326 + picomatch: 2.3.2
3327 +
3328 + mime-db@1.52.0: {}
3329 +
3330 + mime-types@2.1.35:
3331 + dependencies:
3332 + mime-db: 1.52.0
3333 +
3334 + mnemonist@0.39.6:
3335 + dependencies:
3336 + obliterator: 2.0.5
3337 +
3338 + ms@2.1.3: {}
3339 +
3340 + msgpackr-extract@3.0.4:
3341 + dependencies:
3342 + node-gyp-build-optional-packages: 5.2.2
3343 + optionalDependencies:
3344 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
3345 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
3346 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
3347 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
3348 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
3349 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
3350 + optional: true
3351 +
3352 + msgpackr@2.0.5:
3353 + optionalDependencies:
3354 + msgpackr-extract: 3.0.4
3355 +
3356 + mz@2.7.0:
3357 + dependencies:
3358 + any-promise: 1.3.0
3359 + object-assign: 4.1.1
3360 + thenify-all: 1.6.0
3361 +
3362 + nanoid@3.3.18: {}
3363 +
3364 + next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
3365 + dependencies:
3366 + '@next/env': 14.2.35
3367 + '@swc/helpers': 0.5.5
3368 + busboy: 1.6.0
3369 + caniuse-lite: 1.0.30001809
3370 + graceful-fs: 4.2.11
3371 + postcss: 8.4.31
3372 + react: 18.3.1
3373 + react-dom: 18.3.1(react@18.3.1)
3374 + styled-jsx: 5.1.1(react@18.3.1)
3375 + optionalDependencies:
3376 + '@next/swc-darwin-arm64': 14.2.33
3377 + '@next/swc-darwin-x64': 14.2.33
3378 + '@next/swc-linux-arm64-gnu': 14.2.33
3379 + '@next/swc-linux-arm64-musl': 14.2.33
3380 + '@next/swc-linux-x64-gnu': 14.2.33
3381 + '@next/swc-linux-x64-musl': 14.2.33
3382 + '@next/swc-win32-arm64-msvc': 14.2.33
3383 + '@next/swc-win32-ia32-msvc': 14.2.33
3384 + '@next/swc-win32-x64-msvc': 14.2.33
3385 + transitivePeerDependencies:
3386 + - '@babel/core'
3387 + - babel-plugin-macros
3388 +
3389 + node-abort-controller@3.1.1: {}
3390 +
3391 + node-gyp-build-optional-packages@5.2.2:
3392 + dependencies:
3393 + detect-libc: 2.1.2
3394 + optional: true
3395 +
3396 + node-releases@2.0.53: {}
3397 +
3398 + normalize-path@3.0.0: {}
3399 +
3400 + nwsapi@2.2.24: {}
3401 +
3402 + object-assign@4.1.1: {}
3403 +
3404 + object-hash@3.0.0: {}
3405 +
3406 + obliterator@2.0.5: {}
3407 +
3408 + on-exit-leak-free@2.1.2: {}
3409 +
3410 + parse5@7.3.0:
3411 + dependencies:
3412 + entities: 6.0.1
3413 +
3414 + parse5@8.0.1:
3415 + dependencies:
3416 + entities: 8.0.0
3417 +
3418 + path-parse@1.0.7: {}
3419 +
3420 + pathe@1.1.2: {}
3421 +
3422 + pathval@2.0.1: {}
3423 +
3424 + picocolors@1.1.1: {}
3425 +
3426 + picomatch@2.3.2: {}
3427 +
3428 + picomatch@4.0.5: {}
3429 +
3430 + pify@2.3.0: {}
3431 +
3432 + pino-abstract-transport@2.0.0:
3433 + dependencies:
3434 + split2: 4.2.0
3435 +
3436 + pino-std-serializers@7.1.0: {}
3437 +
3438 + pino@9.14.0:
3439 + dependencies:
3440 + '@pinojs/redact': 0.4.0
3441 + atomic-sleep: 1.0.0
3442 + on-exit-leak-free: 2.1.2
3443 + pino-abstract-transport: 2.0.0
3444 + pino-std-serializers: 7.1.0
3445 + process-warning: 5.1.0
3446 + quick-format-unescaped: 4.0.4
3447 + real-require: 0.2.0
3448 + safe-stable-stringify: 2.5.0
3449 + sonic-boom: 4.2.1
3450 + thread-stream: 3.2.0
3451 +
3452 + pirates@4.0.7: {}
3453 +
3454 + postcss-import@15.1.0(postcss@8.5.26):
3455 + dependencies:
3456 + postcss: 8.5.26
3457 + postcss-value-parser: 4.2.0
3458 + read-cache: 1.0.0
3459 + resolve: 1.22.12
3460 +
3461 + postcss-js@4.1.0(postcss@8.5.26):
3462 + dependencies:
3463 + camelcase-css: 2.0.1
3464 + postcss: 8.5.26
3465 +
3466 + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.11)(yaml@2.9.0):
3467 + dependencies:
3468 + lilconfig: 3.1.3
3469 + optionalDependencies:
3470 + jiti: 1.21.7
3471 + postcss: 8.5.26
3472 + tsx: 4.23.11
3473 + yaml: 2.9.0
3474 +
3475 + postcss-nested@6.2.0(postcss@8.5.26):
3476 + dependencies:
3477 + postcss: 8.5.26
3478 + postcss-selector-parser: 6.1.4
3479 +
3480 + postcss-selector-parser@6.1.4:
3481 + dependencies:
3482 + cssesc: 3.0.0
3483 + util-deprecate: 1.0.2
3484 +
3485 + postcss-value-parser@4.2.0: {}
3486 +
3487 + postcss@8.4.31:
3488 + dependencies:
3489 + nanoid: 3.3.18
3490 + picocolors: 1.1.1
3491 + source-map-js: 1.2.1
3492 +
3493 + postcss@8.5.26:
3494 + dependencies:
3495 + nanoid: 3.3.18
3496 + picocolors: 1.1.1
3497 + source-map-js: 1.2.1
3498 +
3499 + process-warning@3.0.0: {}
3500 +
3501 + process-warning@5.1.0: {}
3502 +
3503 + proxy-addr@2.0.7:
3504 + dependencies:
3505 + forwarded: 0.2.0
3506 + ipaddr.js: 1.9.1
3507 +
3508 + psl@1.15.0:
3509 + dependencies:
3510 + punycode: 2.3.1
3511 +
3512 + punycode@2.3.1: {}
3513 +
3514 + pure-rand@6.1.0: {}
3515 +
3516 + querystringify@2.2.0: {}
3517 +
3518 + queue-microtask@1.2.3: {}
3519 +
3520 + quick-format-unescaped@4.0.4: {}
3521 +
3522 + react-dom@18.3.1(react@18.3.1):
3523 + dependencies:
3524 + loose-envify: 1.4.0
3525 + react: 18.3.1
3526 + scheduler: 0.23.2
3527 +
3528 + react@18.3.1:
3529 + dependencies:
3530 + loose-envify: 1.4.0
3531 +
3532 + read-cache@1.0.0:
3533 + dependencies:
3534 + pify: 2.3.0
3535 +
3536 + readdirp@3.6.0:
3537 + dependencies:
3538 + picomatch: 2.3.2
3539 +
3540 + real-require@0.2.0: {}
3541 +
3542 + redis-errors@1.2.0: {}
3543 +
3544 + redis-parser@3.0.0:
3545 + dependencies:
3546 + redis-errors: 1.2.0
3547 +
3548 + require-from-string@2.0.2: {}
3549 +
3550 + requires-port@1.0.0: {}
3551 +
3552 + resolve@1.22.12:
3553 + dependencies:
3554 + es-errors: 1.3.0
3555 + is-core-module: 2.16.2
3556 + path-parse: 1.0.7
3557 + supports-preserve-symlinks-flag: 1.0.0
3558 +
3559 + ret@0.4.3: {}
3560 +
3561 + reusify@1.1.0: {}
3562 +
3563 + rfdc@1.4.1: {}
3564 +
3565 + rollup@4.62.4:
3566 + dependencies:
3567 + '@types/estree': 1.0.9
3568 + optionalDependencies:
3569 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1
3570 + '@rollup/rollup-android-arm-eabi': 4.62.4
3571 + '@rollup/rollup-android-arm64': 4.62.4
3572 + '@rollup/rollup-darwin-arm64': 4.62.4
3573 + '@rollup/rollup-darwin-x64': 4.62.4
3574 + '@rollup/rollup-freebsd-arm64': 4.62.4
3575 + '@rollup/rollup-freebsd-x64': 4.62.4
3576 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4
3577 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4
3578 + '@rollup/rollup-linux-arm64-gnu': 4.62.4
3579 + '@rollup/rollup-linux-arm64-musl': 4.62.4
3580 + '@rollup/rollup-linux-loong64-gnu': 4.62.4
3581 + '@rollup/rollup-linux-loong64-musl': 4.62.4
3582 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4
3583 + '@rollup/rollup-linux-ppc64-musl': 4.62.4
3584 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4
3585 + '@rollup/rollup-linux-riscv64-musl': 4.62.4
3586 + '@rollup/rollup-linux-s390x-gnu': 4.62.4
3587 + '@rollup/rollup-linux-x64-gnu': 4.62.4
3588 + '@rollup/rollup-linux-x64-musl': 4.62.4
3589 + '@rollup/rollup-openbsd-x64': 4.62.4
3590 + '@rollup/rollup-openharmony-arm64': 4.62.4
3591 + '@rollup/rollup-win32-arm64-msvc': 4.62.4
3592 + '@rollup/rollup-win32-ia32-msvc': 4.62.4
3593 + '@rollup/rollup-win32-x64-gnu': 4.62.4
3594 + '@rollup/rollup-win32-x64-msvc': 4.62.4
3595 + fsevents: 2.3.3
3596 +
3597 + rrweb-cssom@0.7.1: {}
3598 +
3599 + rrweb-cssom@0.8.0: {}
3600 +
3601 + run-parallel@1.2.0:
3602 + dependencies:
3603 + queue-microtask: 1.2.3
3604 +
3605 + safe-regex2@3.1.0:
3606 + dependencies:
3607 + ret: 0.4.3
3608 +
3609 + safe-stable-stringify@2.5.0: {}
3610 +
3611 + safer-buffer@2.1.2: {}
3612 +
3613 + saxes@6.0.0:
3614 + dependencies:
3615 + xmlchars: 2.2.0
3616 +
3617 + scheduler@0.23.2:
3618 + dependencies:
3619 + loose-envify: 1.4.0
3620 +
3621 + secure-json-parse@2.7.0: {}
3622 +
3623 + semver@7.8.5: {}
3624 +
3625 + set-cookie-parser@2.7.2: {}
3626 +
3627 + siginfo@2.0.0: {}
3628 +
3629 + sonic-boom@4.2.1:
3630 + dependencies:
3631 + atomic-sleep: 1.0.0
3632 +
3633 + source-map-js@1.2.1: {}
3634 +
3635 + split2@4.2.0: {}
3636 +
3637 + stackback@0.0.2: {}
3638 +
3639 + standard-as-callback@2.1.0: {}
3640 +
3641 + std-env@3.10.0: {}
3642 +
3643 + streamsearch@1.1.0: {}
3644 +
3645 + styled-jsx@5.1.1(react@18.3.1):
3646 + dependencies:
3647 + client-only: 0.0.1
3648 + react: 18.3.1
3649 +
3650 + sucrase@3.35.1:
3651 + dependencies:
3652 + '@jridgewell/gen-mapping': 0.3.13
3653 + commander: 4.1.1
3654 + lines-and-columns: 1.2.4
3655 + mz: 2.7.0
3656 + pirates: 4.0.7
3657 + tinyglobby: 0.2.17
3658 + ts-interface-checker: 0.1.13
3659 +
3660 + supports-preserve-symlinks-flag@1.0.0: {}
3661 +
3662 + symbol-tree@3.2.4: {}
3663 +
3664 + tailwindcss@3.4.19(tsx@4.23.11)(yaml@2.9.0):
3665 + dependencies:
3666 + '@alloc/quick-lru': 5.2.0
3667 + arg: 5.0.2
3668 + chokidar: 3.6.0
3669 + didyoumean: 1.2.2
3670 + dlv: 1.1.3
3671 + fast-glob: 3.3.3
3672 + glob-parent: 6.0.2
3673 + is-glob: 4.0.3
3674 + jiti: 1.21.7
3675 + lilconfig: 3.1.3
3676 + micromatch: 4.0.8
3677 + normalize-path: 3.0.0
3678 + object-hash: 3.0.0
3679 + picocolors: 1.1.1
3680 + postcss: 8.5.26
3681 + postcss-import: 15.1.0(postcss@8.5.26)
3682 + postcss-js: 4.1.0(postcss@8.5.26)
3683 + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.11)(yaml@2.9.0)
3684 + postcss-nested: 6.2.0(postcss@8.5.26)
3685 + postcss-selector-parser: 6.1.4
3686 + resolve: 1.22.12
3687 + sucrase: 3.35.1
3688 + transitivePeerDependencies:
3689 + - tsx
3690 + - yaml
3691 +
3692 + thenify-all@1.6.0:
3693 + dependencies:
3694 + thenify: 3.3.1
3695 +
3696 + thenify@3.3.1:
3697 + dependencies:
3698 + any-promise: 1.3.0
3699 +
3700 + thread-stream@3.2.0:
3701 + dependencies:
3702 + real-require: 0.2.0
3703 +
3704 + tinybench@2.9.0: {}
3705 +
3706 + tinyexec@0.3.2: {}
3707 +
3708 + tinyglobby@0.2.17:
3709 + dependencies:
3710 + fdir: 6.5.0(picomatch@4.0.5)
3711 + picomatch: 4.0.5
3712 +
3713 + tinypool@1.1.1: {}
3714 +
3715 + tinyrainbow@1.2.0: {}
3716 +
3717 + tinyspy@3.0.2: {}
3718 +
3719 + tldts-core@7.4.10: {}
3720 +
3721 + tldts@7.4.10:
3722 + dependencies:
3723 + tldts-core: 7.4.10
3724 +
3725 + to-regex-range@5.0.1:
3726 + dependencies:
3727 + is-number: 7.0.0
3728 +
3729 + toad-cache@3.7.4: {}
3730 +
3731 + tough-cookie@4.1.4:
3732 + dependencies:
3733 + psl: 1.15.0
3734 + punycode: 2.3.1
3735 + universalify: 0.2.0
3736 + url-parse: 1.5.10
3737 +
3738 + tough-cookie@6.0.2:
3739 + dependencies:
3740 + tldts: 7.4.10
3741 +
3742 + tr46@5.1.1:
3743 + dependencies:
3744 + punycode: 2.3.1
3745 +
3746 + tr46@6.0.0:
3747 + dependencies:
3748 + punycode: 2.3.1
3749 +
3750 + ts-interface-checker@0.1.13: {}
3751 +
3752 + tslib@2.8.1: {}
3753 +
3754 + tsx@4.23.11:
3755 + dependencies:
3756 + esbuild: 0.28.2
3757 + optionalDependencies:
3758 + fsevents: 2.3.3
3759 +
3760 + turbo@2.10.9:
3761 + optionalDependencies:
3762 + '@turbo/darwin-64': 2.10.9
3763 + '@turbo/darwin-arm64': 2.10.9
3764 + '@turbo/linux-64': 2.10.9
3765 + '@turbo/linux-arm64': 2.10.9
3766 + '@turbo/windows-64': 2.10.9
3767 + '@turbo/windows-arm64': 2.10.9
3768 +
3769 + typescript@5.9.3: {}
3770 +
3771 + undici-types@6.21.0: {}
3772 +
3773 + undici@8.10.0: {}
3774 +
3775 + universalify@0.2.0: {}
3776 +
3777 + update-browserslist-db@1.3.0(browserslist@4.28.8):
3778 + dependencies:
3779 + browserslist: 4.28.8
3780 + escalade: 3.2.0
3781 + picocolors: 1.1.1
3782 +
3783 + url-parse@1.5.10:
3784 + dependencies:
3785 + querystringify: 2.2.0
3786 + requires-port: 1.0.0
3787 +
3788 + util-deprecate@1.0.2: {}
3789 +
3790 + vite-node@2.1.9(@types/node@20.19.43):
3791 + dependencies:
3792 + cac: 6.7.14
3793 + debug: 4.4.3
3794 + es-module-lexer: 1.7.0
3795 + pathe: 1.1.2
3796 + vite: 5.4.21(@types/node@20.19.43)
3797 + transitivePeerDependencies:
3798 + - '@types/node'
3799 + - less
3800 + - lightningcss
3801 + - sass
3802 + - sass-embedded
3803 + - stylus
3804 + - sugarss
3805 + - supports-color
3806 + - terser
3807 +
3808 + vite@5.4.21(@types/node@20.19.43):
3809 + dependencies:
3810 + esbuild: 0.21.5
3811 + postcss: 8.5.26
3812 + rollup: 4.62.4
3813 + optionalDependencies:
3814 + '@types/node': 20.19.43
3815 + fsevents: 2.3.3
3816 +
3817 + vitest@2.1.9(@types/node@20.19.43)(jsdom@24.1.3):
3818 + dependencies:
3819 + '@vitest/expect': 2.1.9
3820 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.43))
3821 + '@vitest/pretty-format': 2.1.9
3822 + '@vitest/runner': 2.1.9
3823 + '@vitest/snapshot': 2.1.9
3824 + '@vitest/spy': 2.1.9
3825 + '@vitest/utils': 2.1.9
3826 + chai: 5.3.3
3827 + debug: 4.4.3
3828 + expect-type: 1.4.0
3829 + magic-string: 0.30.21
3830 + pathe: 1.1.2
3831 + std-env: 3.10.0
3832 + tinybench: 2.9.0
3833 + tinyexec: 0.3.2
3834 + tinypool: 1.1.1
3835 + tinyrainbow: 1.2.0
3836 + vite: 5.4.21(@types/node@20.19.43)
3837 + vite-node: 2.1.9(@types/node@20.19.43)
3838 + why-is-node-running: 2.3.0
3839 + optionalDependencies:
3840 + '@types/node': 20.19.43
3841 + jsdom: 24.1.3
3842 + transitivePeerDependencies:
3843 + - less
3844 + - lightningcss
3845 + - msw
3846 + - sass
3847 + - sass-embedded
3848 + - stylus
3849 + - sugarss
3850 + - supports-color
3851 + - terser
3852 +
3853 + vitest@2.1.9(@types/node@20.19.43)(jsdom@30.0.1):
3854 + dependencies:
3855 + '@vitest/expect': 2.1.9
3856 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.43))
3857 + '@vitest/pretty-format': 2.1.9
3858 + '@vitest/runner': 2.1.9
3859 + '@vitest/snapshot': 2.1.9
3860 + '@vitest/spy': 2.1.9
3861 + '@vitest/utils': 2.1.9
3862 + chai: 5.3.3
3863 + debug: 4.4.3
3864 + expect-type: 1.4.0
3865 + magic-string: 0.30.21
3866 + pathe: 1.1.2
3867 + std-env: 3.10.0
3868 + tinybench: 2.9.0
3869 + tinyexec: 0.3.2
3870 + tinypool: 1.1.1
3871 + tinyrainbow: 1.2.0
3872 + vite: 5.4.21(@types/node@20.19.43)
3873 + vite-node: 2.1.9(@types/node@20.19.43)
3874 + why-is-node-running: 2.3.0
3875 + optionalDependencies:
3876 + '@types/node': 20.19.43
3877 + jsdom: 30.0.1
3878 + transitivePeerDependencies:
3879 + - less
3880 + - lightningcss
3881 + - msw
3882 + - sass
3883 + - sass-embedded
3884 + - stylus
3885 + - sugarss
3886 + - supports-color
3887 + - terser
3888 +
3889 + w3c-xmlserializer@5.0.0:
3890 + dependencies:
3891 + xml-name-validator: 5.0.0
3892 +
3893 + webidl-conversions@7.0.0: {}
3894 +
3895 + webidl-conversions@8.0.1: {}
3896 +
3897 + whatwg-encoding@3.1.1:
3898 + dependencies:
3899 + iconv-lite: 0.6.3
3900 +
3901 + whatwg-mimetype@4.0.0: {}
3902 +
3903 + whatwg-mimetype@5.0.0: {}
3904 +
3905 + whatwg-url@14.2.0:
3906 + dependencies:
3907 + tr46: 5.1.1
3908 + webidl-conversions: 7.0.0
3909 +
3910 + whatwg-url@16.0.1:
3911 + dependencies:
3912 + '@exodus/bytes': 1.15.1
3913 + tr46: 6.0.0
3914 + webidl-conversions: 8.0.1
3915 + transitivePeerDependencies:
3916 + - '@noble/hashes'
3917 +
3918 + whatwg-url@17.1.0:
3919 + dependencies:
3920 + '@exodus/bytes': 1.15.1
3921 + tr46: 6.0.0
3922 + webidl-conversions: 8.0.1
3923 + transitivePeerDependencies:
3924 + - '@noble/hashes'
3925 +
3926 + why-is-node-running@2.3.0:
3927 + dependencies:
3928 + siginfo: 2.0.0
3929 + stackback: 0.0.2
3930 +
3931 + ws@8.21.3: {}
3932 +
3933 + xml-name-validator@5.0.0: {}
3934 +
3935 + xmlchars@2.2.0: {}
3936 +
3937 + yaml@2.9.0: {}
3938 +
3939 + zod@3.25.76: {}
added pnpm-workspace.yaml +9 −0
@@ -0,0 +1,9 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: pnpm-workspace.yaml
5 +# Purpose: pnpm workspace definition for the earth-now monorepo (apps + packages)
6 +
7 +packages:
8 + - "apps/*"
9 + - "packages/*"
added scripts/check-headers.ignore +10 −0
@@ -0,0 +1,10 @@
1 +# earth-now.co
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +# File: scripts/check-headers.ignore
5 +# Purpose: Files exempt from the mandatory author-header lint (generated output, lockfiles, third-party)
6 +#
7 +# One pattern per line. 'dir/' = prefix match, '*' = wildcard, otherwise exact relative path.
8 +pnpm-lock.yaml
9 +infra/migrations/generated/
10 +apps/web/next-env.d.ts
added scripts/check-headers.ts +116 −0
@@ -0,0 +1,116 @@
1 +/**
2 + * earth-now.co
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + * File: scripts/check-headers.ts
6 + * Purpose: CI lint — fail any source file missing or with a malformed mandatory author header
7 + */
8 +
9 +import { readFileSync, readdirSync, statSync } from "node:fs";
10 +import { join, relative, extname } from "node:path";
11 +
12 +const ROOT = join(import.meta.dirname, "..");
13 +
14 +// Extensions where comments are allowed and the header is mandatory.
15 +const CHECKED_EXTENSIONS = new Set([
16 + ".ts",
17 + ".tsx",
18 + ".js",
19 + ".mjs",
20 + ".cjs",
21 + ".py",
22 + ".sql",
23 + ".sh",
24 + ".yaml",
25 + ".yml",
26 +]);
27 +
28 +const ALWAYS_SKIPPED_DIRS = new Set([
29 + "node_modules",
30 + "dist",
31 + ".next",
32 + ".turbo",
33 + ".git",
34 + "coverage",
35 +]);
36 +
37 +function loadIgnorePatterns(): string[] {
38 + const raw = readFileSync(join(ROOT, "scripts", "check-headers.ignore"), "utf8");
39 + return raw
40 + .split("\n")
41 + .map((l) => l.trim())
42 + .filter((l) => l.length > 0 && !l.startsWith("#"));
43 +}
44 +
45 +function isIgnored(relPath: string, patterns: string[]): boolean {
46 + return patterns.some((p) => {
47 + if (p.endsWith("/")) return relPath.startsWith(p);
48 + if (p.includes("*")) {
49 + const rx = new RegExp(
50 + "^" + p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",
51 + );
52 + return rx.test(relPath);
53 + }
54 + return relPath === p;
55 + });
56 +}
57 +
58 +function* walk(dir: string): Generator<string> {
59 + for (const entry of readdirSync(dir)) {
60 + const full = join(dir, entry);
61 + const st = statSync(full);
62 + if (st.isDirectory()) {
63 + if (ALWAYS_SKIPPED_DIRS.has(entry)) continue;
64 + yield* walk(full);
65 + } else {
66 + yield full;
67 + }
68 + }
69 +}
70 +
71 +interface HeaderProblem {
72 + file: string;
73 + reason: string;
74 +}
75 +
76 +// The header must contain these lines (comment syntax varies by language).
77 +function checkHeader(relPath: string, content: string): string | null {
78 + const head = content.slice(0, 600);
79 + if (!head.includes("earth-now.co")) return "missing 'earth-now.co' banner line";
80 + if (!head.includes("Author:") || !head.includes("Simon-Pierre Boucher"))
81 + return "missing 'Author: Simon-Pierre Boucher' line";
82 + if (!head.includes("Contact:") || !head.includes("contact@spboucher.ai"))
83 + return "missing 'Contact: contact@spboucher.ai' line";
84 + const fileLine = head.match(/File:\s+(\S+)/);
85 + if (!fileLine) return "missing 'File:' line";
86 + if (fileLine[1] !== relPath)
87 + return `'File:' line says '${fileLine[1]}' but actual path is '${relPath}'`;
88 + if (!/Purpose:\s+\S/.test(head)) return "missing or empty 'Purpose:' line";
89 + // Shebang scripts may put the header right after the shebang; otherwise it must open the file.
90 + const firstMeaningful = content.startsWith("#!")
91 + ? content.slice(content.indexOf("\n") + 1)
92 + : content;
93 + if (!/^\s*(\/\*\*|#|--)/.test(firstMeaningful))
94 + return "header must be the first thing in the file (after an optional shebang)";
95 + return null;
96 +}
97 +
98 +const patterns = loadIgnorePatterns();
99 +const problems: HeaderProblem[] = [];
100 +let checked = 0;
101 +
102 +for (const file of walk(ROOT)) {
103 + const rel = relative(ROOT, file);
104 + if (!CHECKED_EXTENSIONS.has(extname(file))) continue;
105 + if (isIgnored(rel, patterns)) continue;
106 + checked++;
107 + const reason = checkHeader(rel, readFileSync(file, "utf8"));
108 + if (reason) problems.push({ file: rel, reason });
109 +}
110 +
111 +if (problems.length > 0) {
112 + console.error(`✗ ${problems.length} file(s) with missing/malformed author header:\n`);
113 + for (const p of problems) console.error(` ${p.file} — ${p.reason}`);
114 + process.exit(1);
115 +}
116 +console.log(`✓ Author header OK on ${checked} source files.`);
added tsconfig.base.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["ES2022"],
5 + "module": "ESNext",
6 + "moduleResolution": "bundler",
7 + "strict": true,
8 + "noUncheckedIndexedAccess": true,
9 + "noImplicitOverride": true,
10 + "exactOptionalPropertyTypes": true,
11 + "forceConsistentCasingInFileNames": true,
12 + "esModuleInterop": true,
13 + "skipLibCheck": true,
14 + "resolveJsonModule": true,
15 + "isolatedModules": true,
16 + "declaration": true,
17 + "declarationMap": true,
18 + "sourceMap": true
19 + }
20 +}
added turbo.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "$schema": "https://turbo.build/schema.json",
3 + "tasks": {
4 + "build": {
5 + "dependsOn": ["^build"],
6 + "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
7 + },
8 + "dev": {
9 + "cache": false,
10 + "persistent": true
11 + },
12 + "test": {
13 + "dependsOn": ["^build"],
14 + "outputs": []
15 + },
16 + "lint": {
17 + "outputs": []
18 + },
19 + "typecheck": {
20 + "dependsOn": ["^build"],
21 + "outputs": []
22 + }
23 + }
24 +}
25