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>
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 | + "&": "&", | |
| 20 | + "<": "<", | |
| 21 | + ">": ">", | |
| 22 | + '"': """, | |
| 23 | + "'": "'", | |
| 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 | ||