CLAUDE.md — earth-now.co
Project Overview
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.
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.
Product pillars:
- Live everywhere: dashboard, shareable public pages (
/m/:token), embeddable widgets (JS, iframe, SVG badge). - Statistical honesty: every number is traceable (source, observation date, interpolation method, uncertainty).
- Robustness: resilient ingestion pipeline; never a frozen or absurd counter in production.
Architecture Principle #1: A Counter Is a Model, Not a Stream
NEVER push one value per second over the network. The server ships a CounterModel; the client animates locally.
// The project's central contract — types/counter-model.ts
interface CounterModel {
metricId: string; // e.g. "co2_emissions_ytd"
anchorValue: number; // value at the anchor point
anchorTime: string; // ISO 8601 UTC of the anchor point
rateFn: RateFunction; // how the value evolves from the anchor
uncertainty?: { low: number; high: number }; // 90% CI at time T
observedAt: string; // date of the last REAL observation
sourceId: string; // key in the source registry
modelVersion: string; // e.g. "seasonal-spline-v2"
displayHints: { decimals: number; sigFigs?: number; unit: string };
}
type RateFunction =
| { kind: "linear"; perSecond: number }
| { kind: "piecewise"; segments: Array<{ from: string; perSecond: number }> }
| { kind: "seasonal"; base: number; harmonics: Harmonic[] } // Fourier
| { kind: "spline"; knots: Array<[time: string, value: number]> };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.
Statistical Methodology (the heart of the product)
Every metric declares its model level in the registry. Escalation by level:
Level 0 — Linear: rate = (forecast_annual_value - last_obs) / remaining_seconds. Acceptable only as a fallback or for genuinely quasi-linear metrics.
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.
Level 2 — Observation/forecast fusion: when a source publishes both delayed observations and projections (e.g. UN WPP, Global Carbon Budget, NOAA), we blend:
- Interpolation via monotone spline (PCHIP) between observed points — never a natural cubic spline (possible overshoot ⇒ counters absurdly going backwards).
- 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.
- Holt-Winters internally for nowcasting frequently-published metrics (weekly/monthly) when the source provides no forecast.
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).
Validity constraints (enforced by packages/models/validate.ts):
- Cumulative counters (YTD births, YTD CO₂ emitted):
rateFnstrictly ≥ 0, reset on Jan 1 UTC viapiecewise. - Stocks (population, CO₂ ppm): may move both ways, but |derivative| bounded by a max declared in the registry.
- 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).
Metric & Source Registry
Everything goes through packages/registry/metrics/*.yaml. A metric does not exist until it is declared there. Example:
id: world_population
name: { fr: "Population mondiale", en: "World population" }
unit: people
kind: stock
model: seasonal-spline-v2 # level 2
sources:
- id: un_wpp_2024
url: https://population.un.org/wpp/
license: CC BY 3.0 IGO
cadence: yearly
variants: [low, median, high]
refresh: on_source_update
display: { decimals: 0, sigFigs: 7 }
constraints: { maxAbsRatePerSec: 10 }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.
Architecture
apps/
web/ # Next.js 14 (App Router) — dashboard, /m/:token pages, /embed
api/ # Fastify (TS) — REST + SSE
ingest/ # Ingestion workers (cron + BullMQ queue)
packages/
registry/ # Metric + source YAML (product source of truth)
models/ # Interpolators, fitting, validation (pure, no I/O)
counter/ # Shared client runtime: value(model, t) — used by web, widget AND badge
widget/ # Embeddable widget.js (IIFE build < 15 KB gzip, zero dependencies)
infra/ # Docker, Terraform, migrationsData flow:
ingestdownloads/parses sources on their cadence (exponential retry, checksums, raw archive to S3 — we keep ALL raw data for re-fitting).- Normalization →
observations(append-only, PostgreSQL + TimescaleDB). modelsre-fits the CounterModel of each impacted metric → validation → versioned in DB + Redis cache.- Redis pub/sub publish on
metric:{id}→ SSE servers relay the new model to connected clients. - SVG/PNG badges: rendered server-side with the same
packages/counter, CDN cache 60 s.
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).
Stack
- Strict TypeScript everywhere (
"strict": true, no unjustifiedany). Node 20+, pnpm workspaces, Turborepo. - Next.js 14 + Tailwind (web); Fastify (api); BullMQ + Redis (ingest); PostgreSQL 16 + TimescaleDB; Drizzle ORM + SQL migrations.
- 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) underapps/ingest/fitters-pycalled by the orchestrator — but prefer TS whenever possible to share code with the client. - Real time: SSE (EventSource) for model distribution; no WebSocket unless a future bidirectional need arises. Reconnection:
Last-Event-ID+ Redis Streams (15 min buffer). - Cloudflare CDN in front of
/badge/*andwidget.js.
Mandatory File Header
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:
/**
* earth-now.co
* Author: Simon-Pierre Boucher
* Contact: contact@spboucher.ai
* File: <relative/path/to/file.ts>
* Purpose: <one-line description of what this file does>
*/Adapt the comment syntax to the language (# for Python/YAML/shell, -- for SQL). Rules:
- The
File:line must match the file's actual path in the repo — update it if the file is moved/renamed. Purpose:is one line, kept accurate when the file's role changes.- CI lint rule (
scripts/check-headers.ts, run inpnpm lint) fails any file missing or with a malformed header. When creating a new file, Claude must add this header first, before any code. - Generated files (build output, lockfiles, migrations auto-generated by drizzle-kit) are exempt and listed in
scripts/check-headers.ignore.
Deployment
- Production runs on node
m3u96b, exposed publicly through ngrok at www.earth-now.co. - Stack on
m3u96b: Docker Compose (infra/docker-compose.prod.yml) running web, api, ingest, PostgreSQL/Timescale, and Redis; ngrok tunnel (reserved domainwww.earth-now.co) fronting the web/api reverse proxy on port 8080. - Deploy flow:bash
pnpm build # turbo build of all apps pnpm deploy:m3u96b # rsync + docker compose up -d on node m3u96b pnpm tunnel:status # verify the ngrok tunnel is up and serving www.earth-now.co - ngrok config lives in
infra/ngrok.yml(reserved domain + edge). The ngrok authtoken is provided via environment/secret manager — never commit it. - Health check after every deploy:
GET https://www.earth-now.co/api/healthmust return 200 andGET /sse/healthmust hold an SSE connection open ≥ 10 s. - 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.
Commands
pnpm dev # whole monorepo (turbo)
pnpm dev:web # frontend only
pnpm test # vitest, all packages
pnpm test:models # interpolator tests (the most critical ones)
pnpm lint && pnpm typecheck # lint includes the file-header check
pnpm db:migrate # drizzle-kit
pnpm ingest:run <sourceId> # force an ingestion locally
pnpm models:refit <metricId> # manual re-fit + diff report
pnpm deploy:m3u96b # deploy to production node m3u96b (ngrok → www.earth-now.co)Code Conventions
- Time: UTC everywhere internally, ISO 8601; convert to local time only at display. No hand-rolled date arithmetic — use
date-fnsorTemporal. - Units: SI in the data layer; display conversions (t → Gt, etc.) live in
displayHints, never in the pipeline. packages/modelsandpackages/counterare 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.- Numbers: never floats for ingested cumulative values (use integers in the base unit when possible); formatting via
Intl.NumberFormat. - Every new metric = a PR containing: registry YAML + data fixture + golden model test + source license review.
- i18n: fr + en from day one (keys in the registry and
apps/web/messages).
Testing — Non-negotiable
- 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. - Property-based tests (fast-check): monotonicity of cumulatives, derivative bounds, continuity at piecewise junctions,
value(anchorTime) === anchorValue. - Client/server consistency test:
packages/counterrun in Node and in jsdom must produce bit-identical values for the same model. - Ingestion: every source parser has fixtures of the real format (including degraded cases: truncated file, renamed columns).
Production Guardrails
- 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.
- Ingestion failing > 2× the source's cadence ⇒ "stale" badge on the metric + alert (no silent failure).
- Public endpoints (
/m,/embed,/badge,/sse): rate limiting per IP + token, read-only open CORS, revocable share tokens, no user data exposed. - 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.
What Claude Must Do in This Repo
- Before adding/modifying a metric: read the registry YAML and its associated golden tests.
- Never modify
packages/modelswithout bumpingmodelVersionand updating the golden fixtures. - Prefer extending an existing
RateFunctionover creating a new one; any new kind requires implementation inpackages/counter(client + server) AND badge rendering. - When creating any file: add the mandatory author header (Simon-Pierre Boucher / contact@spboucher.ai) before any code.
- When in doubt about source data (format, license, cadence): flag it explicitly rather than assuming.
- UI text displaying numbers goes through helpers in
packages/counter/format.ts— never inlinetoFixed. - After any production deploy to
m3u96b, run the health checks against https://www.earth-now.co before declaring the deploy done.