ARCHITECTURE OVERVIEW

How STORMTRACE Works

A guided tour of the SupercellSentinel codebase — from the big picture down to the database columns and the jobs that pull the weather in. Written for someone new to web development: every technical term gets explained the first time it shows up.

Read top-to-bottom, or jump around with the menu on the left. Green boxes are plain-English explainers.

01 · What this app is

STORMTRACE (codename SupercellSentinel) is a weather tracker and alert app. Its distinctive idea: take professional/public weather data and compare it, live, against a physical weather station sitting at home. When the two disagree — say the public forecast says calm but your own backyard anemometer is reading 60 km/h gusts — the app surfaces that disagreement in plain language as a Verdict.

It does five things:

Why "reconcile"? Two sources rarely agree perfectly. The app's job isn't to pick a winner — it's to show you the gap and translate it ("readings agree" / "minor divergence" / "sources diverge"). That gap is the interesting signal for a storm-watcher.

The tech stack at a glance

LayerTechnologyWhat it is
Web appNext.js 16 (App Router) + React 19 + TypeScriptThe framework that renders pages and also hosts the backend API.
StylingTailwind CSS v4 + "Greenscreen" design systemThe phosphor-terminal green-on-black look.
MapsLeaflet / MapLibre + RainViewerInteractive map, radar tiles, wind/temp overlays.
DatabasePostgreSQL + TimescaleDB, via Drizzle ORMStores users, history, forecasts, alerts. Timescale specialises in time-series.
CollectorPython + APSchedulerA standalone daemon that polls severe-weather feeds.
AnalysisPython + Jupyter notebooksScores forecast accuracy against ground truth.
HostingA Linux server — nginx + systemdRuns everything behind HTTPS.

02 · A 5-minute web-dev primer

If these words are already familiar, skip ahead. Otherwise, here are the concepts that the rest of the doc leans on.

Client vs. server

The client is the user's browser. The server is a computer the app owns. The browser asks ("requests"), the server answers ("responds"). Secrets (passwords, API keys) live only on the server — never sent to the browser.

Frontend vs. backend

Frontend = what you see and click (HTML/CSS/JavaScript in the browser). Backend = the logic and data behind it (talking to databases and other services). Next.js does both in one project.

An API & an "endpoint"

An API is a doorway one program uses to ask another for data. An endpoint is one specific door, named by a URL like /api/station. You hit it, it returns data (usually JSON — a simple text format for structured data).

Rendering: SSR

This app uses server-side rendering: the server builds the finished HTML before sending it, so the page arrives already filled with data. (The alternative — sending a blank page that fills itself in the browser — is "client-side rendering".)

Database, ORM, migration

A database stores data in tables (rows & columns). An ORM (here, Drizzle) lets you describe those tables in TypeScript instead of raw SQL. A migration is a versioned script that changes the database's shape — like a git commit for your tables.

Cron, daemon, env vars

A cron job runs on a timer ("every 5 minutes"). A daemon is a program that runs forever in the background. Environment variables (env vars) are settings/secrets fed in from outside the code, so you never hard-code passwords.

The one-sentence mental model: The browser shows a page → the page (or a timer) asks an API endpoint for data → the endpoint calls a data client that fetches from the weather services and/or the database → JSON flows back → the page draws it.

03 · The big picture

STORMTRACE is really four programs sharing one database. Keeping them separate means the website staying responsive even while heavy data-collection grinds away in the background.

Figure 1 — System overview
flowchart TB
    subgraph ext["☁️  External data sources"]
      direction LR
      OM["Open-Meteo
(forecast · archive/ERA5 · current)"] TUYA["Tuya Cloud
(Webber WS603 station)"] MA["MeteoAlarm EDR"] FEEDS["NWS · SPC · NHC
GDACS · USGS"] RV["RainViewer · MapTiler · Windy"] end subgraph app["🖥️ Next.js app (one server process)"] direction TB PAGES["Pages (frontend)
radar · dashboard · charts · auth"] APIR["API routes (backend)
/api/station · /api/wind · /api/cron/*"] LIB["lib/ — data clients + Verdict logic"] PAGES --> LIB APIR --> LIB end COLL["🐍 Python collector
(standalone daemon)"] DB[("🗄️ PostgreSQL + TimescaleDB")] NB["📓 Jupyter notebooks
(forecast-skill analysis)"] USER(["👤 Browser"]) USER -->|HTTPS| PAGES OM --> LIB TUYA --> LIB RV --> PAGES MA --> COLL FEEDS --> COLL LIB <-->|read/write| DB COLL -->|write alerts| DB NB -->|read-only| DB classDef db fill:#0c3,stroke:#093,color:#031; class DB db;

The four programs

ProgramLanguageJobLives in
Web appTypeScriptServes the website and the API. Also hosts the scheduled "cron" endpoints that pull station + forecast data.app/, lib/, components/
CollectorPythonA background daemon that polls global severe-weather feeds and writes alerts to the DB. Completely independent of the website.collector/
DatabaseSQLThe shared memory. Everything reads/writes here.db/
NotebooksPythonOffline analysis — reads the DB (read-only) to score forecast accuracy.analysis/
Design choice The website and the collector never talk to each other directly — they only ever meet at the database. That decoupling means a crash in the noisy collector can't take down the site, and either can be deployed independently.

04 · The frontend — what the user sees

The frontend is built with Next.js's "App Router". The rule there is simple: the folder structure under app/ is the URL structure. A folder called dashboard becomes the page at /dashboard; a file named page.tsx is the page itself.

Internationalisation (i18n) & routing

Every page lives under app/[lang]/. The [lang] in brackets is a dynamic segment — a placeholder that matches en or pl. So the dashboard is really at /en/dashboard or /pl/dashboard. A small redirect layer (proxy.ts) catches any URL without a language prefix and bounces you to one based on your browser's Accept-Language header.

app/[lang]/
  page.tsx          → the radar/map home page   (/en)
  dashboard/        → station-vs-public + Verdict (/en/dashboard)
  charts/           → history time-series        (/en/charts)
  login/ register/  → auth screens
  dictionaries/     → en.json · pl.json (all UI text, both languages)
What's [lang] doing? Rather than building the site twice, the code reads a "dictionary" — en.json or pl.json — and looks up every label by key. Same page, two languages.

Server Components vs. Client Components

Next.js pages are Server Components by default: their code runs on the server, can talk to the database directly, and ships zero JavaScript to the browser. When a piece needs interactivity (clicking, dragging the map), it's marked "use client" and becomes a Client Component that runs in the browser.

The home page app/[lang]/page.tsx is a great example — it's a server component that fetches everything up front, then hands the data to an interactive client component:

// runs on the SERVER — fetches in parallel before rendering
const [user, location] = await Promise.all([getCurrentUser(), getLocation()]);
const [warnings, station, forecast] = await Promise.all([
  getAlertsForLocation(location),
  authed ? fetchStation() : null,   // station is auth-gated
  fetchForecast(location).catch(() => null),
]);
return <RadarConsole … station={station} forecast={forecast} />;  // → client

The big interactive piece, RadarConsole (a 550-line client component), owns the map, the zoom controls, the overlays, and the live polling. Notice it lazy-loads the actual map with next/dynamic and ssr:false — Leaflet needs a real browser window, so it must not run on the server.

The design system

The whole app wears a green phosphor-terminal skin — the "Greenscreen / STORMTRACE" design system in Greenscreen/. Fonts (VT323, Share Tech Mono), the flicker animation, and the colour tokens (--phosphor, --watch amber, --warn red) all come from there. The severity colours are meaningful, not decorative: green = agree, amber = watch, red = warn.

05 · A page load, step by step

Let's trace exactly what happens when someone opens the home radar page. This ties the frontend, the data clients, and the database together.

Figure 2 — Loading the radar home page
sequenceDiagram
    autonumber
    participant B as Browser
    participant P as proxy.ts
    participant S as page.tsx (server)
    participant L as lib/ data clients
    participant DB as Database
    participant OM as Open-Meteo / Tuya

    B->>P: GET /
    P-->>B: redirect → /en  (locale picked)
    B->>S: GET /en
    Note over S: Server Component runs
    S->>L: getCurrentUser() (session cookie)
    L->>DB: look up session
    DB-->>L: user or null
    par fetch in parallel
        S->>L: getAlertsForLocation()
        L->>DB: active warnings
    and
        S->>L: fetchStation()  (only if logged in)
        L->>OM: Tuya signed request
    and
        S->>L: fetchForecast()
        L->>OM: Open-Meteo current
    end
    OM-->>L: readings
    L-->>S: station · forecast · warnings
    S-->>B: finished HTML (already filled)
    Note over B: RadarConsole "hydrates"
→ map becomes interactive B->>B: polls /api/wind, /api/temp,
RainViewer frames on a timer

Two things worth calling out:

06 · API routes — the backend

Anything under app/api/ is a backend endpoint, not a page. A file app/api/station/route.ts answers requests to the URL /api/station. These are what the browser's JavaScript calls after the page has loaded (e.g. when you drag the map and need fresh wind data), and what the scheduled timers hit.

They fall into four families:

EndpointFamilyWhat it returns / does
/api/stationLive dataLatest home-station reading (auth-gated).
/api/forecastLive dataOpen-Meteo current conditions for a location.
/api/weather/alertsLive dataActive severe-weather warnings near a point.
/api/weather/status · /api/statusLive dataHealth of each data source (LIVE / MOCK / OFFLINE).
/api/historyLive dataPaginated stored readings for the charts page.
/api/wind · /api/tempMap overlaysWind/temperature grids for the radar — read-through cached (see below).
/api/global/rv · /api/webcamsMap overlaysGlobal radar proxy; Windy webcam layer.
/api/auth/login · logout · signup · verify-emailAuthSession + registration flow.
/api/cron/poll-station~5–10 minCaptures station + live model analysis → DB.
/api/cron/poll-forecasthourlyCaptures the 5-day forecast → DB.
/api/cron/archive-catchupdailyBackfills ERA5 reanalysis "ground truth" → DB.

The read-through cache pattern (wind & temp)

The radar overlays are a nice example of defensive design. Open-Meteo has a daily request limit, and the map can fire many overlay requests. So /api/wind uses a read-through cache backed by the database:

Figure 3 — /api/wind read-through cache
flowchart LR
    A["Request
lat·lon·km"] --> B{"Fresh entry
in DB cache?
(< 15 min)"} B -- yes --> C["✅ serve cached"] B -- no --> D["Fetch from
Open-Meteo"] D -- ok --> E["Store in DB,
then serve"] D -- fail --> F{"Stale entry
exists?
(< 2 h)"} F -- yes --> G["⚠️ serve stale"] F -- no --> H["🟡 synthetic
field (SIM)"]

The center coordinates are bucketed to ~0.25° (≈28 km) so a small map nudge reuses the same cached grid instead of triggering a fresh fetch. The cache lives in the database, and each response notes whether it was served fresh, from cache, stale, or as a synthetic fallback.

07 · Data clients & the Verdict

The lib/ folder is the brains: it holds the data clients (code that talks to each external service) plus the comparison logic. Pages and API routes never call the outside world directly — they go through lib/, which keeps secrets server-side and gives every caller the same fallback behaviour.

The three Open-Meteo clients

Open-Meteo is a free, keyless weather API. The app uses three different "flavours" of it, each in its own file:

ClientOpen-Meteo endpointUsed for
openMeteo.ts → fetchForecast()/v1/forecast?current="Right now" conditions for the dashboard & radar. Falls back to DB, then mock.
openMeteo-forecast.ts/v1/forecast?hourly=The 5-day hourly forecast (what each model predicts), captured hourly.
openMeteo-archive.ts/v1/archive (ERA5)Reanalysis "ground truth" — what actually happened — backfilled daily.
Forecast vs. ERA5 reanalysis: A forecast is a guess about the future. ERA5 is a high-quality reconstruction of the past, published with a few days' lag. Storing both lets the app later ask: "how close was the guess to reality?" That comparison is the whole research point (§11).

One source of truth for weather variables

A subtle but important file: lib/weather-vars.ts. It defines the canonical list of weather variables once — and records, per variable, what each Open-Meteo endpoint calls it and which station metric it maps to. The database columns, the pollers, and the station mapping all derive from this one list, so adding a variable is a one-line change. (Example: ERA5 has no UV index, so uv_index simply omits its archive name.)

The Tuya / station client

The home station is a Webber WS603 read through the Tuya IoT Cloud. lib/tuya.ts is a hand-rolled client: it signs each request with HMAC-SHA256 (a cryptographic signature proving the request is authentic), caches the access token, and reads the device's "data points" (DPs). The raw DP codes vary per device, so lib/normalize.ts maps them onto clean metric names (and applies scale factors — many DPs are integers ×10).

No creds? No problem. If Tuya credentials aren't set, config.mockStation is true and the client returns synthetic data. The whole app runs on believable mock data out of the box — that's why npm run dev just works.

The orchestrator: lib/data.ts

The dashboard's data is assembled in one place — lib/data.ts → getDashboard(). It fetches the station + forecast + warnings, runs the comparison for each metric, computes the Verdict, and gathers the history series. One call, everything the dashboard needs.

The Verdict engine

This is the conceptual heart of the app, and it's refreshingly small (lib/compare.ts). For each compared metric it computes delta = station − public, then grades the absolute gap against per-metric thresholds into one of three severities. The overall Verdict is just the worst metric's severity.

Figure 4 — How a Verdict is computed
flowchart TB
    subgraph perMetric["For each metric (temp, wind, gust, …)"]
      M1["delta = station − public"] --> M2["abs = |delta|"]
      M2 --> M3{"abs ≥ warn
threshold?"} M3 -- yes --> W["severity = WARN 🔴"] M3 -- no --> M4{"abs ≥ watch
threshold?"} M4 -- yes --> WT["severity = WATCH 🟠"] M4 -- no --> OK["severity = OK 🟢"] end W --> AGG["Verdict = worst
severity across
all metrics"] WT --> AGG OK --> AGG AGG --> MSG["message:
warn→'diverge'
watch→'minor'
ok→'agree'"]

The thresholds (what counts as "watch" vs "warn" for each metric) live in METRIC_META in lib/metrics.ts. The result carries a worst field naming the most-divergent metric, so the UI can point right at it. The message keys (agree/minor/diverge) are looked up in the language dictionary, keeping the logic language-agnostic.

08 · Scheduled jobs — pulling the data in

The live pages show "now". But the history, charts, and research all need data recorded over time. That's the job of the three cron endpoints under /api/cron/. They aren't pages — nobody visits them in a browser. Instead, a timer on the server calls them on a schedule (the README recommends systemd timers; see §13).

Figure 5 — The three data-capture jobs
flowchart LR
    subgraph timers["⏱️ systemd timers"]
      T1["every 10 min"]
      T2["hourly"]
      T3["daily"]
    end
    T1 --> P1["/api/cron/poll-station"]
    T2 --> P2["/api/cron/poll-forecast"]
    T3 --> P3["/api/cron/archive-catchup"]

    P1 -->|"station reading"| O1["observations
source=station"] P1 -->|"live best-estimate
(all locations)"| O2["observations
source=model_analysis"] P2 -->|"5-day hourly forecast"| F["forecasts
model · lead_hours"] P3 -->|"ERA5 reanalysis
(12-day window)"| O3["observations
source=era5_archive"] O1 --> DB[("Database")] O2 --> DB F --> DB O3 --> DB classDef db fill:#0c3,stroke:#093,color:#031; class DB db;
JobCadenceWhat it writesWhy
poll-station~10 minHome WS603 reading (source=station) + a live model best-estimate for every tracked location (source=model_analysis).The model_analysis series is what the dashboard compares the station against, and the radar's fallback when Open-Meteo is down.
poll-forecasthourlyThe 5-day hourly forecast, each row tagged with its model and lead_hours (how far ahead it was predicting).Records what was predicted, so it can later be scored against what happened. Defaults to one blended model (icon_seamless) to save ~⅔ of API calls; can fan out to 3 models with FORECAST_MODELS_ENV=multi.
archive-catchupdailyRe-pulls the trailing 12-day ERA5 window (source=era5_archive) for every location.ERA5 finalises with a ~5-day lag, so re-pulling a 12-day window upserts finalised values over earlier provisional ones — keeping a clean ground-truth series.
What's an "upsert"? "Update-or-insert". Each row has a unique key (e.g. location + source + time). On a repeat, instead of creating a duplicate, the new data overwrites the old. That's how the daily ERA5 re-pull can safely correct provisional numbers.

Securing the cron endpoints

The data-capture endpoints aren't meant for the public — they're guarded so only the scheduler can trigger them. Unauthorised requests are rejected.

09 · The Python collector — severe-weather alerts

Severe-weather warnings (tornado warnings, hurricanes, earthquakes…) come from a separate, always-on Python daemon in collector/. Why separate? These feeds are numerous, flaky, and global — exactly the kind of noisy work you want isolated from the website. It shares only the database.

It uses APScheduler to run three "tiers" at different rates (collector/main.py, collector/engine.py):

Figure 6 — Collector tiers & flow
flowchart TB
    subgraph sched["APScheduler (one async loop)"]
      L["LIVE · every 3 min"]
      S["SEMI-LIVE · every 15 min"]
      R["ROUTINE · hourly"]
    end
    L --> LF["NWS · GDACS · USGS"]
    S --> SF["NHC · MeteoAlarm · SPC"]
    R --> RF["prune old
raw snapshots"] LF --> CB{"Circuit breaker
per source"} SF --> CB CB -- open --> SKIP["skip + log
circuit_open"] CB -- closed --> FETCH["fetch feed"] FETCH --> UP["normalise →
upsert weather_alerts"] UP --> EXP["mark vanished
alerts inactive"] EXP --> LOG["log poll metrics
(poll_log)"] FETCH -. error .-> FAIL["record failure
→ may open breaker"] classDef w fill:#fff3,stroke:#999;
TierCadenceSources
LIVE3 minNWS (US warnings), GDACS (global disasters), USGS (earthquakes)
SEMI-LIVE15 minNHC (cyclones), MeteoAlarm (Europe), SPC (US convective outlooks)
ROUTINEhourlyMaintenance — prune raw snapshots older than the retention window

Each feed has its own fetcher in collector/fetchers/ (one file per source) that normalises wildly different formats into a single weather_alerts row shape. Two resilience patterns stand out:

Historical note The collector used to also poll Open-Meteo observations; that responsibility moved to the TypeScript cron jobs (§8). Its ROUTINE tier is now just snapshot cleanup — the code comments still say "Open-Meteo observations" in places.

It ships as a systemd service (ss-collector.service) so the OS keeps it running and restarts it if it dies.

10 · The database

One PostgreSQL database is the shared memory for everything. The schema is defined in TypeScript with Drizzle ORM in db/schema.ts — that single file is the source of truth, and migrations are generated from it.

The tables

Figure 7 — Core tables (simplified)
erDiagram
    locations ||--o{ observations : "has many"
    locations ||--o{ forecasts : "has many"
    users ||--o{ sessions : "has"
    users ||--o{ email_verification_tokens : "has"

    locations {
      serial id PK
      text slug UK
      text name
      float lat
      float lon
      text kind "station|city|reference"
    }
    observations {
      timestamp time PK "hypertable partition"
      int location_id FK
      text source "station|model_analysis|era5_archive"
      float temperature
      float wind_speed
      float wind_gust
      float precip
      string etc "~18 weather columns"
    }
    forecasts {
      timestamp valid_at PK "hypertable partition"
      timestamp issued_at
      int lead_hours
      int location_id FK
      text model "ecmwf|icon|gfs"
      string etc "same weather columns"
    }
    weather_alerts {
      text id PK "source:eventId"
      text source
      text event_type
      text severity
      bool is_active
      jsonb geometry
    }
    users {
      serial id PK
      text email UK
      text password_hash
      bool email_verified
    }
    grid_snapshots {
      serial id PK
      text kind "wind|temp"
      jsonb data
      timestamp fetched_at
    }
    

Grouped by who owns them:

Table(s)Written byPurpose
locationsseed scriptThe tracked points: home station + a few contrasting cities/reference sites.
observationscron poll-station + archive-catchupWhat was measured. source distinguishes station / live model / ERA5. Timescale hypertable.
forecastscron poll-forecastWhat each model predicted, with lead_hours. Timescale hypertable.
warningsweb appMeteoAlarm/rule warnings shown in the UI (snapshot history).
weather_alerts · raw_snapshots · poll_log · tracked_locationsPython collectorNormalised global alerts, raw debug payloads, collector health metrics, and the hotspots it watches.
users · sessions · email_verification_tokensweb app (auth)Accounts, login sessions, email-verification tokens.
grid_snapshotsweb app (/api/wind, /api/temp)The read-through radar overlay cache.
user_rules(future)User-defined alert thresholds — the planned Phase-4 alerts engine.

Why TimescaleDB?

TimescaleDB is a Postgres extension built for time-series data. observations and forecasts become hypertables — automatically partitioned by time, so years of minute-by-minute data stay fast. The setup in db/timescale.sql also adds:

Hypertable, in one line: a regular-looking table that Timescale secretly splits into time-bucketed chunks behind the scenes, so queries and storage stay efficient as it grows forever.

Migrations

You don't edit the live database by hand. You change schema.ts, run npm run db:generate (Drizzle writes a numbered SQL file into db/migrations/), then npm run db:migrate applies it. The numbered files (0000_…0004_…) are the version history of the schema.

11 · The teaching loop — the research payoff

Here's where the pieces click together into something genuinely clever. Because the app stores both what was predicted (forecasts) and what actually happened (observations), it can score forecast accuracy automatically.

The join lives in a SQL view, forecast_errors (db/analytics.sql). For every forecast row it finds the matching observation at that exact location and valid-time, preferring the station as truth and falling back to ERA5, then computes the per-variable error:

Figure 8 — The forecast-verification loop
flowchart LR
    F["forecasts
(what was predicted,
by model + lead_hours)"] O["observations
(what happened:
station ▶ else ERA5)"] F -->|"join on
location + valid_at"| V["forecast_errors view
err = forecast − truth"] O --> V V --> NB["📓 notebooks score
MAE / RMSE
by model & lead time"] NB --> INSIGHT["💡 'ICON's temp error
grows ~0.3°C per day of lead'"]
MAE / RMSE? Two standard ways to summarise "how wrong, on average". MAE = mean absolute error (average size of the miss). RMSE = root-mean-square error (same idea, but punishes big misses harder). Plot them against lead time and you see forecasts decay as they reach further into the future.

This is the loop that justifies all the capture machinery: predict → wait → observe → measure the gap → learn. It's also why the project nickname is Sentinel — it's quietly keeping score on the forecasters.

12 · Analysis notebooks

The offline analysis lives in analysis/ as Jupyter notebooks — interactive Python documents mixing code, charts, and prose. They connect to the database read-only (through a restricted Postgres role, see §13) so exploration can never corrupt production data.

NotebookWhat it explores
01_explore.ipynbERA5 history exploration — sanity-checking the stored climatology.
02_forecast_skill.ipynbForecast skill scoring — MAE/RMSE vs. lead time, per model (reads forecast_errors).
03_backtest_model.ipynbNext-day temperature backtesting — can a simple model beat the pros?

13 · Deployment — where it all runs

In production everything runs on a single Linux server. The pieces are wired together by the operating system's standard tools — nginx (a web server / reverse proxy) out front, and systemd (the Linux service manager) keeping the long-running pieces alive and firing the timers.

Figure 9 — Production topology
flowchart TB
    NET(["🌍 Internet"]) -->|HTTPS :443| NGINX["nginx
(TLS, reverse proxy)"] NGINX -->|"reverse proxy"| APP["Next.js app
(systemd service)"] subgraph os["systemd"] APP COLL["collector daemon
(ss-collector.service)"] TM1["⏱ poll-station.timer → curl /api/cron/poll-station"] TM2["⏱ poll-forecast.timer"] TM3["⏱ archive-catchup.timer"] end TM1 --> APP TM2 --> APP TM3 --> APP APP --> PG[("PostgreSQL
+ TimescaleDB")] COLL --> PG classDef db fill:#0c3,stroke:#093,color:#031; class PG db;

How the schedule actually fires: a systemd .timer wakes on its calendar (e.g. every 10 minutes), which starts a one-shot .service that triggers the matching data-capture endpoint. Clean separation: the schedule is OS config, the work is app code.

The read-only analyst role

The notebooks log in through a separate, locked-down database user — it can SELECT but not change anything. Principle of least privilege: analysis can't break production.

14 · Config, secrets & mock mode

All server configuration funnels through one validated file, lib/config.ts, which parses environment variables with Zod (a library that checks shapes and supplies defaults). Secrets are read here and never reach the browser.

The neat part is how it decides whether to use real or fake data, per source. The public forecast (Open-Meteo) needs no key, so it runs live by default; the home station only goes live when its credentials are present, and otherwise returns believable synthetic data.

So with no configuration at all, the public forecast is real while the station is mocked — and the app honestly flags which is which with a MOCK/LIVE badge. Provide the station credentials and it flips to live with no code change. This is why the project can promise "just start it".

Secrets stay server-side Every credential and API key is read only on the server and is never sent to the browser. When a given source isn't configured, the app degrades gracefully rather than failing.

Auth, briefly

Accounts use server-side sessions: logging in sets an opaque, http-only cookie, and passwords are stored only as salted one-way hashes using an industry-standard algorithm. No password or usable session token is ever exposed to the client.

15 · Glossary

TermMeaning
App RouterNext.js's modern routing system where folders under app/ map to URLs.
Server / Client ComponentReact component that runs on the server (default) vs. in the browser ("use client").
SSRServer-Side Rendering — the server sends finished, data-filled HTML.
HydrationThe browser "waking up" server-rendered HTML into an interactive page.
API endpointA URL that returns data instead of a page (here, under /api/).
JSONA lightweight text format for structured data — the lingua franca of web APIs.
ORMObject-Relational Mapper — describe DB tables in code (Drizzle) instead of raw SQL.
MigrationA versioned script that changes the database schema.
UpsertInsert a row, or update it in place if a matching one already exists.
HypertableA Timescale table auto-partitioned by time for fast time-series queries.
Continuous aggregateA self-refreshing rolled-up summary table (e.g. daily averages).
Cron jobCode that runs automatically on a schedule.
DaemonA program that runs continuously in the background.
Circuit breakerA guard that stops calling a failing service for a while, then retries.
Read-through cacheServe from cache; on a miss, fetch + store, then serve.
ERA5A high-quality reconstruction of past weather (reanalysis), used as "ground truth".
Reverse proxyA front server (nginx) that receives requests and forwards them to the app.
Env varA configuration value/secret supplied from outside the code.