A self-hosted Open-Meteo instance that isn't backfilled yet answers historical
requests with all-null values (or only its recent sync window), which
_finalize_frame reduces to a near-empty frame. That frame is not None, so the
loader would accept it as source=open-meteo and cache it indefinitely as a
complete record — never falling back to NASA and never self-healing after the
backfill lands. Require at least MIN_ARCHIVE_DAYS before accepting an archive
result; below that, fall through to the NASA backup and don't cache the short
frame. Guards the prod cutover window.
Get the 45-year historical record off the rate-limited public Open-Meteo
archive API by running a private Open-Meteo instance that serves the
era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the
compressed .om archive in object storage, mounted on the host with rclone.
- climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin
models=era5_seamless on the archive fetches only, so a self-hosted instance
serves the same 0.1° resolution; forecast path unchanged. The public API's
default is already seamless, so dev/beta (URL unset) behave identically.
- docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers
(era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage
mount; the overlay points the app at the local instance.
- Makefile: om-up / om-down / om-backfill (one-time full-history backfill).
- Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR,
and provisions the host rclone systemd mount from the bucket credentials.
- deploy/openmeteo: operator runbook + rclone mount unit template.
Get the 45-year historical record off the rate-limited public Open-Meteo
archive API by running a private Open-Meteo instance that serves the
era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the
compressed .om archive in object storage, mounted on the host with rclone.
- climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin
models=era5_seamless on the archive fetches only, so a self-hosted instance
serves the same 0.1° resolution; forecast path unchanged. The public API's
default is already seamless, so dev/beta (URL unset) behave identically.
- docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers
(era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage
mount; the overlay points the app at the local instance.
- Makefile: om-up / om-down / om-backfill (one-time full-history backfill).
- Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR,
and provisions the host rclone systemd mount from the bucket credentials.
- deploy/openmeteo: operator runbook + rclone mount unit template.
Terraform config under terraform/ manages the two existing VPS hosts and hands the
app to docker-compose, with local state:
- prod: the new 48GB/12-core VPS (release branch, thermograph.org), sized larger.
- beta: the old VPS 75.119.132.91 (main branch, testing tier), no public domain.
- The LAN dev box stays on deploy/deploy-dev.sh (dev branch) — out of Terraform.
A reusable module (modules/thermograph-host) SSHes each host to install docker/
compose/ufw (+ Caddy when a domain is set), sync the checkout to the host's branch,
render /etc/thermograph.env from Terraform variables (secrets pushed via provisioner
content, never on local disk), `docker compose up -d`, and health-check. Named
volumes are preserved on re-apply, so the Postgres data is never recreated.
Container resources are now env-driven in docker-compose.yml (APP_CPUS/DB_CPUS/
DB_MEMORY/WORKERS) with unchanged defaults, so Terraform can size each host.
deploy/db/init/20-tuning.sql sets the memory budget via ALTER SYSTEM (applied on a
fresh volume, effective after the post-init restart): shared_buffers 2GB,
effective_cache_size 6GB, work_mem 64MB, maintenance_work_mem 512MB, max_wal_size
4GB, plus duckdb.max_memory 4GB for parquet processing.
Compose: the db container gets an 8g memory ceiling + 1g shm_size (parallel-query
shared memory) in prod; the dev overlay resets mem_limit so dev memory is uncapped
too (the ~8 GB budget still comes from the tuning). Verified the settings take
effect and pg_duckdb still loads.
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
Run Thermograph as a docker-compose stack (app + Postgres 18) and standardize the
data layer on Postgres, while keeping the test suite on SQLite.
- accounts/db.py: DSN-driven engines. On Postgres, a per-worker read-write +
read-only asyncpg pair (the RO engine pins read-only transactions, used by the
pure-GET endpoints) plus a sync psycopg engine for the notifier thread; the
SQLite path is preserved for tests/local (selected when THERMOGRAPH_DATABASE_URL
is unset). models.py: boolean server_default -> sa.false().
- store.py / metrics.py: dialect-flexible — Postgres UNLOGGED tables via psycopg
when configured, else the existing raw-sqlite3 paths byte-for-byte; sync
interfaces and every fail-soft contract preserved.
- Alembic (backend/alembic/) manages the accounts schema; the container entrypoint
runs `alembic upgrade head` before uvicorn (4 workers). migrate_accounts_to_pg.py
copies the accounts data SQLite->PG through the ORM (UUID/bool/JSON coerced),
skips access_token, and resets identity sequences.
- Dockerfile + docker-compose.yml: app image (uvicorn, 4 workers, loopback 8137)
and a Postgres 18 db (2 CPUs) running pg_duckdb (deploy/db/) so the parquet
climate cache is queryable in-DB via read_parquet('/parquet/cache/*.parquet').
- deploy.sh/thermograph.service rewired to manage the compose stack; env example,
Makefile targets (up/down/db-up), and deploy/POSTGRES-MIGRATION.md cutover runbook.
Tests stay on SQLite (dialect fallback) — 323 pass. The full Postgres stack was
verified via docker compose: alembic migrations, register/login, the RO endpoint,
store/metrics round-trips, and the accounts data migration.
The deploy scripts pulled code and restarted but never applied the
hand-written SQL migrations in deploy/migrations/, so column additions to
the long-lived accounts DB had to be run by hand.
Add deploy/migrate-db.py, an idempotent runner that resolves the accounts
DB the same way the app does (THERMOGRAPH_ACCOUNTS_DB or
<repo>/data/accounts.sqlite), applies any files not yet recorded in a
schema_migrations table, and:
- backs the DB up before touching it;
- baselines a fresh DB (no file, or no user table) rather than ALTERing
a table create_all() will build with the current schema on next start;
- tolerates an already-present column/index (hand-applied or model-built)
by recording it instead of failing.
Wire it into deploy.sh (prod) and deploy-dev.sh (dev) just before the
service restarts, so new code never queries a column an older DB lacks.
No env or systemd changes: the migration runs as the deploy user, who owns
the data dir.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
The overall climate-shift score was a weighted roll-up (feels-like and humidity
2x, wind and gusts 0.5x, and so on). Make it an equal average instead: every
metric — and, through each metric's own mean over the 10/25/50/75/90 percentiles,
every percentile — counts the same.
- scoring._overall: plain mean of the present metrics' divergences, mapped to
0-100, replacing the weighted sum. Missing metrics still drop out cleanly.
- Drop the now-unused per-metric weights from the METRICS table and the scored
entries (nothing rendered them; only the roll-up read them).
- views.SCORE_VER s4 -> s5 so cached weighted scores are recomputed.
- score.js: the hero note now reads "Every metric counts equally" instead of
"Temperature, feels-like and humidity are weighted most".
Verified against Seattle: overall mad equals the equal mean of the eight metric
mads (7.9), and the entries no longer carry a weight.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The overall climate-shift score was a weighted roll-up (feels-like and humidity
2x, wind and gusts 0.5x, and so on). Make it an equal average instead: every
metric — and, through each metric's own mean over the 10/25/50/75/90 percentiles,
every percentile — counts the same.
- scoring._overall: plain mean of the present metrics' divergences, mapped to
0-100, replacing the weighted sum. Missing metrics still drop out cleanly.
- Drop the now-unused per-metric weights from the METRICS table and the scored
entries (nothing rendered them; only the roll-up read them).
- views.SCORE_VER s4 -> s5 so cached weighted scores are recomputed.
- score.js: the hero note now reads "Every metric counts equally" instead of
"Temperature, feels-like and humidity are weighted most".
Verified against Seattle: overall mad equals the equal mean of the eight metric
mads (7.9), and the entries no longer carry a weight.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
Rain intensity gains a Severe tier and Dry becomes strictly no-rain:
- The top half of Very Heavy (95th–99th rain-day percentile) becomes a new
"Severe" tier; Very Heavy keeps the 90–95 band. Eight rain tiers now — Trace /
Light / Brisk / Typical / Heavy / Very Heavy / Severe / Extreme — which refill
the wet-2..wet-9 colour ramp contiguously (no gap), so Heavy/Very Heavy shift
one shade lighter and Severe takes the second-darkest.
- A day is Dry only when it didn't rain at all; any measurable rain, however
slight, is at least Trace. _grade_precip splits on > 0 rather than the 0.01"
threshold (which still governs the separate dry-streak metric).
- The distribution strip drops the range under the Dry column — every dry day is
zero, so a "0–0" span was noise.
_precip_ladder derives its percentile marks from RAIN_BANDS now, so adding or
splitting a tier can't leave a hard-coded list behind (that was the bug the 95th
mark would have hit). The detail-view ladder derives from the band table as before.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Rain intensity gains a Severe tier and Dry becomes strictly no-rain:
- The top half of Very Heavy (95th–99th rain-day percentile) becomes a new
"Severe" tier; Very Heavy keeps the 90–95 band. Eight rain tiers now — Trace /
Light / Brisk / Typical / Heavy / Very Heavy / Severe / Extreme — which refill
the wet-2..wet-9 colour ramp contiguously (no gap), so Heavy/Very Heavy shift
one shade lighter and Severe takes the second-darkest.
- A day is Dry only when it didn't rain at all; any measurable rain, however
slight, is at least Trace. _grade_precip splits on > 0 rather than the 0.01"
threshold (which still governs the separate dry-streak metric).
- The distribution strip drops the range under the Dry column — every dry day is
zero, so a "0–0" span was noise.
_precip_ladder derives its percentile marks from RAIN_BANDS now, so adding or
splitting a tier can't leave a hard-coded list behind (that was the bug the 95th
mark would have hit). The detail-view ladder derives from the band table as before.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Move the flat backend/tests/*.py into domain subfolders so the suite
mirrors the code's concerns:
data/ climate, grading, scoring, grid, places, store
web/ api, content, homepage, views
notifications/ notify, digest, discord (+ dm/interactions/link)
accounts/ api_accounts
core/ metrics, singleton, dashboard
conftest.py stays at the tests/ root, so its sys.path setup and shared
fixtures still apply to every subfolder. The four tests that derive repo
paths from __file__ get their depth bumped one level to match their new
location. Pytest discovers the subfolders recursively; the CI command
(python -m pytest backend/tests) is unchanged.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
The rain-intensity scale drops its two hyphenated compound labels for single
words and loses a tier, going from eight to seven:
Trace / Light / Brisk / Typical / Heavy / Very Heavy / Extreme
- Light–Mod -> Brisk, Moderate -> Typical (renames only; classes unchanged).
- Mod–Heavy is merged up into Heavy, whose floor drops from the 75th to the 60th
rain-day percentile, so Heavy now spans 60–90.
- The lightest tier (already the merged Very Light) is renamed Trace.
grading.py RAIN_BANDS and the frontend SCALE_RAIN mirror stay in lockstep, and
the detail-view ladder derives from the table so it follows automatically. The
now-unreferenced wet-6 colour token is kept so any day still cached under that
class renders until the derived store recomputes; the chart's percentile fan also
keeps it for a smooth gradient.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The rain-intensity scale drops its two hyphenated compound labels for single
words and loses a tier, going from eight to seven:
Trace / Light / Brisk / Typical / Heavy / Very Heavy / Extreme
- Light–Mod -> Brisk, Moderate -> Typical (renames only; classes unchanged).
- Mod–Heavy is merged up into Heavy, whose floor drops from the 75th to the 60th
rain-day percentile, so Heavy now spans 60–90.
- The lightest tier (already the merged Very Light) is renamed Trace.
grading.py RAIN_BANDS and the frontend SCALE_RAIN mirror stay in lockstep, and
the detail-view ladder derives from the table so it follows automatically. The
now-unreferenced wet-6 colour token is kept so any day still cached under that
class renders until the derived store recomputes; the chart's percentile fan also
keeps it for a smooth gradient.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The account menu offered 'Link Discord' to every signed-in user even on a
server with no Discord OAuth app configured, where it dead-ends (the start route
303-bounces to /alerts). Add GET /api/v2/discord/config reporting whether linking
is enabled, and have the menu render the entry only when it is. On a server
without Discord set up nothing surfaces; setting the OAuth env vars later makes
the entry appear on its own — no code change needed to turn it on.
The account menu offered 'Link Discord' to every signed-in user even on a
server with no Discord OAuth app configured, where it dead-ends (the start route
303-bounces to /alerts). Add GET /api/v2/discord/config reporting whether linking
is enabled, and have the menu render the entry only when it is. On a server
without Discord set up nothing surfaces; setting the OAuth env vars later makes
the entry appear on its own — no code change needed to turn it on.
The rain-intensity scale had a Trace tier (below the 1st percentile of a place's
rain days) sitting under Very Light — a sliver category that mostly showed 1% and
crowded the distribution strip. Fold it into Very Light, which now bottoms out the
scale at 0, so the lightest measurable rain reads as Very Light.
- grading.py: RAIN_BANDS drops the Trace band; Very Light's floor goes 1 -> 0. The
detail-view ladder derives from the table, so it follows automatically.
- shared.js: SCALE_RAIN drops the Trace row; Very Light's range becomes "<10".
Eight rain tiers now instead of nine; the strip shows one fewer column. The wet-1
colour token is kept (unreferenced by new gradings) so any day still cached with
the old class renders until the derived store recomputes.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The rain-intensity scale had a Trace tier (below the 1st percentile of a place's
rain days) sitting under Very Light — a sliver category that mostly showed 1% and
crowded the distribution strip. Fold it into Very Light, which now bottoms out the
scale at 0, so the lightest measurable rain reads as Very Light.
- grading.py: RAIN_BANDS drops the Trace band; Very Light's floor goes 1 -> 0. The
detail-view ladder derives from the table, so it follows automatically.
- shared.js: SCALE_RAIN drops the Trace row; Very Light's range becomes "<10".
Eight rain tiers now instead of nine; the strip shows one fewer column. The wet-1
colour token is kept (unreferenced by new gradings) so any day still cached with
the old class renders until the derived store recomputes.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The note explaining the hidden digest form contained a literal "{# … #}" in its
prose. Jinja doesn't nest comments, so the inner "#}" closed the comment early and
the trailing "wrapper. #}" rendered as visible text at the top of every footer.
Reworded the note without comment delimiters. Adds a test asserting no base-
template page's footer contains a stray "{#"/"#}" (fails against the old template).
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Adds Discord DM as a notification channel beside web push, for users who linked
their Discord account and opted in. It rides the same fan-out point as push
(_dispatch_discord next to _dispatch_push in the notifier pass), reusing the
transport-agnostic title/body/deep-link, and stays best-effort and isolated: the
in-app row is already committed, and push/email remain the fallback for anyone
Discord can't reach (its DM rule requires a shared server / user install).
- discord.py: send_dm() opens the DM channel then posts an embed via the bot token,
with one capped 429 retry. Absolute deep links (a DM can't resolve a relative
path the way the service worker can).
- notify.py: _dispatch_discord() delivers only when the subscriber has a linked
discord_id and discord_dm on; no-ops entirely when no bot token is configured.
- models.py: User.discord_dm (opt-in flag) + migration 002. Linking sets it True
(an active opt-in); a new POST /discord/dm mutes it without unlinking, and unlink
clears it. Exposed on UserRead; account popover shows an on/off toggle.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Adds Discord DM as a notification channel beside web push, for users who linked
their Discord account and opted in. It rides the same fan-out point as push
(_dispatch_discord next to _dispatch_push in the notifier pass), reusing the
transport-agnostic title/body/deep-link, and stays best-effort and isolated: the
in-app row is already committed, and push/email remain the fallback for anyone
Discord can't reach (its DM rule requires a shared server / user install).
- discord.py: send_dm() opens the DM channel then posts an embed via the bot token,
with one capped 429 retry. Absolute deep links (a DM can't resolve a relative
path the way the service worker can).
- notify.py: _dispatch_discord() delivers only when the subscriber has a linked
discord_id and discord_dm on; no-ops entirely when no bot token is configured.
- models.py: User.discord_dm (opt-in flag) + migration 002. Linking sets it True
(an active opt-in); a new POST /discord/dm mutes it without unlinking, and unlink
clears it. Exposed on UserRead; account popover shows an on/off toggle.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Backend (scoring.py):
- Fold the parallel WEIGHTS/METRIC_LABELS/DIRECTION_WORDS/TEMP_DIR_METRICS maps
into one METRICS descriptor so a scored metric is defined in one place.
- Share one _build_entry between the seasonal and annual paths.
- Add _freq_for so the annual entry reads the wet-day / heat-stress-day share
directly instead of re-running the full precip_divergence just for it.
- Drop unconsumed payload: per-q v6/pct and the overall bias.
- Drop _slice's dead 'annual' branch; derive SLICES from SEASONS.
Frontend (score.js):
- One scoreCell() and signedPts() helper for the four score-cell sites and the
two signed-points chips; reuse .section-title / .table-wrap instead of cloning.
Behavior-preserving (identical scores); bumps the score cache version.
Backend (scoring.py):
- Fold the parallel WEIGHTS/METRIC_LABELS/DIRECTION_WORDS/TEMP_DIR_METRICS maps
into one METRICS descriptor so a scored metric is defined in one place.
- Share one _build_entry between the seasonal and annual paths.
- Add _freq_for so the annual entry reads the wet-day / heat-stress-day share
directly instead of re-running the full precip_divergence just for it.
- Drop unconsumed payload: per-q v6/pct and the overall bias.
- Drop _slice's dead 'annual' branch; derive SLICES from SEASONS.
Frontend (score.js):
- One scoreCell() and signedPts() helper for the four score-cell sites and the
two signed-points chips; reuse .section-title / .table-wrap instead of cloning.
Behavior-preserving (identical scores); bumps the score cache version.
Lets a signed-in user connect their Discord account (OAuth2 identify), storing the
Discord user id that a later feature (DM alerts) will deliver to. Standard
authorization-code flow, all server-side:
- backend/discord_link.py: /discord/link/start redirects to Discord's consent
screen; /discord/link/callback exchanges the code, reads the Discord user id, and
stores it; /discord/unlink forgets it. The `state` is signed with the app auth
secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a
callback can't be replayed or bound to another account. Every route requires an
active session, so linking acts on whoever is actually logged in.
- models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead
so the frontend can show link state.
- app.py: the router under /api/v2/discord.
- account.js: a "Link Discord" / "Unlink Discord" control in the account popover.
- deploy/migrations/001-user-discord-id.sql: the manual column add for the existing
prod accounts DB. This project has no Alembic — create_all only makes missing
tables, so an added column needs a hand-applied migration (documented in-file).
- env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Lets a signed-in user connect their Discord account (OAuth2 identify), storing the
Discord user id that a later feature (DM alerts) will deliver to. Standard
authorization-code flow, all server-side:
- backend/discord_link.py: /discord/link/start redirects to Discord's consent
screen; /discord/link/callback exchanges the code, reads the Discord user id, and
stores it; /discord/unlink forgets it. The `state` is signed with the app auth
secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a
callback can't be replayed or bound to another account. Every route requires an
active session, so linking acts on whoever is actually logged in.
- models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead
so the frontend can show link state.
- app.py: the router under /api/v2/discord.
- account.js: a "Link Discord" / "Unlink Discord" control in the account popover.
- deploy/migrations/001-user-discord-id.sql: the manual column add for the existing
prod accounts DB. This project has no Alembic — create_all only makes missing
tables, so an added column needs a hand-applied migration (documented in-file).
- env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Lets a signed-in user connect their Discord account (OAuth2 identify), storing the
Discord user id that a later feature (DM alerts) will deliver to. Standard
authorization-code flow, all server-side:
- backend/discord_link.py: /discord/link/start redirects to Discord's consent
screen; /discord/link/callback exchanges the code, reads the Discord user id, and
stores it; /discord/unlink forgets it. The `state` is signed with the app auth
secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a
callback can't be replayed or bound to another account. Every route requires an
active session, so linking acts on whoever is actually logged in.
- models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead
so the frontend can show link state.
- app.py: the router under /api/v2/discord.
- account.js: a "Link Discord" / "Unlink Discord" control in the account popover.
- deploy/migrations/001-user-discord-id.sql: the manual column add for the existing
prod accounts DB. This project has no Alembic — create_all only makes missing
tables, so an added column needs a hand-applied migration (documented in-file).
- env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Adds Discord slash commands with no bot process and no gateway connection: Discord
POSTs each interaction to a FastAPI route, and the app answers it. First command is
/grade <city>, returning today's grade for a curated city from the warm cache
(reusing homepage._grade_city, so it answers well within the 3-second deadline and
costs no upstream quota).
- backend/discord_interactions.py: Ed25519 verification (PyNaCl) over the RAW
request body — Discord probes the endpoint with bad signatures and disables it if
they aren't rejected with 401. Routes PING to PONG and application-commands to
their handler; unknown/unsupported interactions are acknowledged, not errored.
City lookup is exact-name-then-prefix over the population-sorted city set; unknown
or not-yet-warm cities get an ephemeral note.
- app.py: POST {BASE}/discord/interactions, reading request.body() (not json()) so
the bytes match the signature.
- scripts/register_discord_commands.py: one-off upsert of the command definitions
via Discord REST (app id + bot token).
- PyNaCl added to requirements; Discord public-key / app-id / bot-token documented
in the env example. Endpoint URL: https://thermograph.org/discord/interactions.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Adds Discord slash commands with no bot process and no gateway connection: Discord
POSTs each interaction to a FastAPI route, and the app answers it. First command is
/grade <city>, returning today's grade for a curated city from the warm cache
(reusing homepage._grade_city, so it answers well within the 3-second deadline and
costs no upstream quota).
- backend/discord_interactions.py: Ed25519 verification (PyNaCl) over the RAW
request body — Discord probes the endpoint with bad signatures and disables it if
they aren't rejected with 401. Routes PING to PONG and application-commands to
their handler; unknown/unsupported interactions are acknowledged, not errored.
City lookup is exact-name-then-prefix over the population-sorted city set; unknown
or not-yet-warm cities get an ephemeral note.
- app.py: POST {BASE}/discord/interactions, reading request.body() (not json()) so
the bytes match the signature.
- scripts/register_discord_commands.py: one-off upsert of the command definitions
via Discord REST (app id + bot token).
- PyNaCl added to requirements; Discord public-key / app-id / bot-token documented
in the env example. Endpoint URL: https://thermograph.org/discord/interactions.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The Android app is a Trusted Web Activity — a thin native shell that opens the
live PWA full-screen in the user's Chrome engine, so it is literally the web app
and the existing VAPID web push keeps working inside it via Android notification
delegation (no Firebase, no backend change).
Adds the build config and the domain-verification file so the app can be built
and sideloaded:
- deploy/twa/twa-manifest.json — Bubblewrap config; packageId org.thermograph.twa,
enableNotifications true (web-push delegation + Android 13 POST_NOTIFICATIONS),
colors/icons from manifest.webmanifest.
- frontend/.well-known/assetlinks.json — Digital Asset Links, served at the domain
root as application/json (verified via the static mount). The signing-key
fingerprint is a placeholder until the keystore is generated.
- deploy/twa/README.md — build/sideload/verify runbook.
Building the signed APK, generating the keystore, filling the fingerprint, and
sideloading are local manual steps (documented). Play Store submission is deferred.
No backend/ changes.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
iOS only grants Web Push to a site the user has added to the Home Screen via
Safari (16.4+), and it needs the apple-mobile-web-app meta tags to launch
standalone rather than in a Safari tab. Those were absent. Adds them to both
header families — the five standalone frontend pages and base.html.j2 — alongside
the existing theme-color:
apple-mobile-web-app-capable, mobile-web-app-capable,
apple-mobile-web-app-status-bar-style, apple-mobile-web-app-title
Adds ios-install.js: a dismissible hint shown only to real iOS Safari visitors
who haven't installed yet (skips Chrome/Firefox-iOS and standalone launches),
pointing them at Share -> Add to Home Screen and noting it's how to enable alerts.
There is no beforeinstallprompt on iOS, so a pointer to the control is the only
nudge available. The dismissal persists under the thermograph: localStorage
prefix — not tg:, which cache.js sweeps on every load.
No backend change: an installed iOS home-screen app reuses the existing service
worker and VAPID web-push stack as-is.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
iOS only grants Web Push to a site the user has added to the Home Screen via
Safari (16.4+), and it needs the apple-mobile-web-app meta tags to launch
standalone rather than in a Safari tab. Those were absent. Adds them to both
header families — the five standalone frontend pages and base.html.j2 — alongside
the existing theme-color:
apple-mobile-web-app-capable, mobile-web-app-capable,
apple-mobile-web-app-status-bar-style, apple-mobile-web-app-title
Adds ios-install.js: a dismissible hint shown only to real iOS Safari visitors
who haven't installed yet (skips Chrome/Firefox-iOS and standalone launches),
pointing them at Share -> Add to Home Screen and noting it's how to enable alerts.
There is no beforeinstallprompt on iOS, so a pointer to the control is the only
nudge available. The dismissal persists under the thermograph: localStorage
prefix — not tg:, which cache.js sweeps on every load.
No backend change: an installed iOS home-screen app reuses the existing service
worker and VAPID web-push stack as-is.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
On a narrow (2-up) phone card, a long grade like 'Extreme shift — windier'
crammed into the card's top-right corner and wrapped into a cluster. Restructure
each card to a clean vertical stack — metric name, score, tier, detail line — so
the label always has room, on mobile and desktop alike.
Also reword the wet-bulb explainer to state plainly what it is (the lowest
temperature evaporating sweat can cool you to).
Annual scores were computed from an all-year pooled distribution, which widens
the reference spread and hides a shift confined to one season — Seattle's daily
high read 16 despite a summer high of 65. Build each metric's annual score
(and the overall) as the mean of its four seasonal divergences instead, so a
real seasonal shift shows through (that high now reads 28). Frequency read-outs
(precip wet days, wet-bulb heat-stress days) stay pooled over the year.
Also lock the by-season table to fixed, uniform columns (min-width to scroll on
a phone) so each metric lines up vertically across the seasons, and show the
per-percentile detail as the season-averaged shift.
Bumps the score cache version.
Annual scores were computed from an all-year pooled distribution, which widens
the reference spread and hides a shift confined to one season — Seattle's daily
high read 16 despite a summer high of 65. Build each metric's annual score
(and the overall) as the mean of its four seasonal divergences instead, so a
real seasonal shift shows through (that high now reads 28). Frequency read-outs
(precip wet days, wet-bulb heat-stress days) stay pooled over the year.
Also lock the by-season table to fixed, uniform columns (min-width to scroll on
a phone) so each metric lines up vertically across the seasons, and show the
per-percentile detail as the season-averaged shift.
Bumps the score cache version.
Swaps the stepped-path mark for the "C1 v2" design: an 8x8 grid of graded
day-cells tracing the same climb (blue Low -> green Normal -> a hot loop -> a
stepped ascent to the near-record red cell). It keeps the exact geometry of the
old mark — same 512 viewBox, same 32/448/rx96 rounded badge, same band palette —
so it is a drop-in that needs no alignment changes.
Everywhere the mark appears:
- The inline header SVG in all six lockups (the five standalone frontend pages
and backend/templates/base.html.j2), kept at width/height 28 with aria-hidden.
Because the box and viewBox are unchanged, the .logo/.brand alignment CSS
(the -2px cap-height nudge, the 28->22px compact size, the 10px gap) is
untouched and the mark stays centred on the "Thermograph" wordmark.
- favicon.svg, regenerated from the new art.
- Every raster: favicon-16/32/48, apple-touch-icon (180), logo-192, logo (512),
and the two maskable icons. The maskable pair keeps the established recipe —
the badge scaled into the central safe zone on a full-bleed opaque square — so
an OS shape mask never clips the art.
Bumps every icon reference to ?v=3 (previously a mix of ?v=2 and unversioned) so
browsers and installed PWAs refetch the new icons.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Swaps the stepped-path mark for the "C1 v2" design: an 8x8 grid of graded
day-cells tracing the same climb (blue Low -> green Normal -> a hot loop -> a
stepped ascent to the near-record red cell). It keeps the exact geometry of the
old mark — same 512 viewBox, same 32/448/rx96 rounded badge, same band palette —
so it is a drop-in that needs no alignment changes.
Everywhere the mark appears:
- The inline header SVG in all six lockups (the five standalone frontend pages
and backend/templates/base.html.j2), kept at width/height 28 with aria-hidden.
Because the box and viewBox are unchanged, the .logo/.brand alignment CSS
(the -2px cap-height nudge, the 28->22px compact size, the 10px gap) is
untouched and the mark stays centred on the "Thermograph" wordmark.
- favicon.svg, regenerated from the new art.
- Every raster: favicon-16/32/48, apple-touch-icon (180), logo-192, logo (512),
and the two maskable icons. The maskable pair keeps the established recipe —
the badge scaled into the central safe zone on a full-bleed opaque square — so
an OS shape mask never clips the art.
Bumps every icon reference to ?v=3 (previously a mix of ?v=2 and unversioned) so
browsers and installed PWAs refetch the new icons.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The by-season chips wrapped freely, so a metric's scores didn't line up
across seasons. Render it as a table instead — seasons as rows, one aligned
column per metric (plus an Overall column), tinted cells, horizontal scroll
on narrow screens.
- Explain on the page what wet-bulb temperature measures (the evaporative-cooling
ceiling on shedding heat), so the metric isn't opaque.
- Report the share of heat-stress "wet-bulb" days (peak wet bulb >= 26 C) vs
normal days, recent window vs the full record — mirroring the precip wet-day
frequency.
- Present the overall total as a direction-agnostic net change (magnitude only),
not "warmer/cooler"; per-metric cards still carry direction.
Bumps the score cache version.
- Explain on the page what wet-bulb temperature measures (the evaporative-cooling
ceiling on shedding heat), so the metric isn't opaque.
- Report the share of heat-stress "wet-bulb" days (peak wet bulb >= 26 C) vs
normal days, recent window vs the full record — mirroring the precip wet-day
frequency.
- Present the overall total as a direction-agnostic net change (magnitude only),
not "warmer/cooler"; per-metric cards still carry direction.
Bumps the score cache version.
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
The distribution strip printed each bar's average with its full unit inline, so
humidity read "4.0 g/m³" in a ~38px phone column and clipped on every bar —
temperature never did, because it shows a bare "40°". Wind ("5 mph") and precip
were heading the same way.
The unit now appears once, top-right of the strip, and every bar value is bare:
"4.0" under a "g/m³" label, "5" under "mph", "0.00" under "in". It reacts to the
unit toggle like the values do (°F↔°C, mm↔in, mph↔km/h). Temperature keeps its
degree glyph on the value since it is iconic and never clipped.
With the unit no longer on the humidity label, its calendar title drops the
"(g/m³ of water vapor)" parenthetical that would otherwise repeat it — every
metric's title is unitless now, the strip's own label carries it.
Also fixes a pre-existing crash: calendar.js referenced DRY_CAP without importing
it from shared.js, so selecting the dry-streak metric threw and left its legend
unrendered. Exported it.
Verified at 390 and 1440 in light and dark, imperial and metric, across all five
calendar metrics and the compare strips: no label clips, no console errors.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
* Hide footer email digest signup for now
Comment out the compact digest signup form in the site footer; restore
by removing the surrounding Jinja comment wrapper.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Update digest test for hidden footer form
The footer signup form is now hidden; assert its absence site-wide.
The /digest endpoint stays live and its tests are unchanged.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62