Commit graph

71 commits

Author SHA1 Message Date
Emi Griffith
6fd2d7c981 Containerize the app and move the databases to PostgreSQL 18 (#220)
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.
2026-07-20 06:28:23 +00:00
Emi Griffith
1e89bc71c9 Apply accounts-DB migrations on deploy (#219)
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
2026-07-20 06:09:15 +00:00
Emi Griffith
78d8289627 Link a Thermograph account to Discord via OAuth2 (#208)
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
2026-07-20 02:26:33 +00:00
Emi Griffith
eaf0cac321 Serve a /grade Discord slash command over HTTP interactions (#207)
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
2026-07-20 02:19:29 +00:00
Emi Griffith
810e080ff3 Post the daily "most unusual right now" feed to Discord (#205)
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
2026-07-20 00:48:22 +00:00
Emi Griffith
372f7536a6 Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.

Rebuild it around the Weekly view, which is untouched:

- Hero with the question as the h1, and a grade card showing a real graded
  example — the most unusual city we're currently tracking, either tail. The
  card's frame and text slots are server-rendered with reserved heights, so
  app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
  is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
  ~1000-page /climate surface.
- Monthly digest form, in the footer of every page.

Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.

"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.

Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.

Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.

Also adds /privacy, linked from the footer and kept out of the sitemap.

The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
Emi Griffith
0f978bc708 Sync main into dev (#163)
* Serve the app on thermograph.org at root; redirect emigriffith.dev/thermograph

Move Thermograph to its own domain. thermograph.org serves the app at its root,
and emigriffith.dev/thermograph* permanently redirects there (prefix stripped,
so deep links map straight across).

- app.py: BASE now supports an empty root prefix. THERMOGRAPH_BASE=/ (or empty)
  yields BASE="" so routes sit at "/", "/calendar", "/api/v2/…" with no double
  slashes; the bare-base redirect is skipped and the static mount falls back to
  "/". Non-empty values keep the existing "/thermograph" sub-path behavior
  unchanged (backward compatible).
- deploy/Caddyfile: add a thermograph.org site block reverse-proxying to the
  uvicorn on 127.0.0.1:8137; turn emigriffith.dev's /thermograph handler into a
  permanent redirect to thermograph.org.
- deploy/thermograph.env.example: default THERMOGRAPH_BASE=/.
- DEPLOY.md: document the two-domain layout and that deploy.sh does not ship the
  Caddyfile or env — those are applied on the VPS by hand.

The frontend already uses base-relative URLs, so it follows the root base with no
changes. Verified both modes locally (root serves /, /calendar, /api/v2/*; the
/thermograph sub-path still redirects bare→slash and 404s at root).

* Add dashboard (#144)

* Auto-submit IndexNow on deploy when the URL set changes (#130)

Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.

* Add ops metrics endpoint + SSH/Termux dashboard (#131)

Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.

* Dashboard: run under the app venv, not the box's default python3 (#132)

The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.

* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)

The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.

* Dashboard: make the live view a scrollable in-place pager (#134)

The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.

--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.

* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)

The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.

* Dashboard: add a STORAGE section (archive + DB sizes) (#136)

Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.

* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)

The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.

* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)

Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.

* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)

Values below 500 MB show in MB (one decimal); 500 MB and up in GB.

* Dashboard: accounts totals only; log client IPs per request (#140)

Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.

Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.

* Dashboard: wrap-aware pager so the header stops scrolling off (#141)

The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.

* Add log storage size to the ops dashboard STORAGE section (#142)

The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.

Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.

* Dashboard: show last-10-minute traffic per category (#143)

Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.

The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".

* Add dashboard #2 (#145)

* Auto-submit IndexNow on deploy when the URL set changes (#130)

Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.

* Add ops metrics endpoint + SSH/Termux dashboard (#131)

Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.

* Dashboard: run under the app venv, not the box's default python3 (#132)

The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.

* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)

The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.

* Dashboard: make the live view a scrollable in-place pager (#134)

The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.

--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.

* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)

The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.

* Dashboard: add a STORAGE section (archive + DB sizes) (#136)

Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.

* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)

The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.

* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)

Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.

* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)

Values below 500 MB show in MB (one decimal); 500 MB and up in GB.

* Dashboard: accounts totals only; log client IPs per request (#140)

Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.

Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.

* Dashboard: wrap-aware pager so the header stops scrolling off (#141)

The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.

* Add log storage size to the ops dashboard STORAGE section (#142)

The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.

Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.

* Dashboard: show last-10-minute traffic per category (#143)

Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.

The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".

* Run prod on 3 uvicorn workers with a shared metrics store

A single slow upstream weather fetch (cache-miss during an open-meteo/NASA
degradation) blocked the one uvicorn worker and took the whole app down for
~2 min — real users got aborted connections. Give prod concurrency headroom so
one slow request can't freeze the rest.

- deploy/thermograph.service: worker count is env-driven (`--workers ${WORKERS}`,
  default 1); prod sets WORKERS=3 in /etc/thermograph.env. Dev's separate
  --user unit is untouched (stays single-worker).
- metrics.py: with multiple workers, per-process counters would split the tally
  and the ops dashboard (polls one random worker) would see only a fraction.
  Add a shared SQLite store selected by THERMOGRAPH_METRICS_DB so every worker
  tallies into one place; unset keeps the zero-dependency in-memory store for
  dev/tests/single-worker. Public API and snapshot shape unchanged.
- The unit defaults THERMOGRAPH_METRICS_DB and clears it on each (re)start via
  ExecStartPre, so bumping WORKERS can never silently fragment the dashboard and
  "since start" tallies keep their old meaning.
- Tests: cover the SQLite store — cross-worker aggregation, window roll-off,
  and env-based selection. Full suite: 178 passed.

Note: applying to prod also needs the unit reinstalled + WORKERS=3 in
/etc/thermograph.env — deploy.sh only pulls+restarts, it doesn't reinstall the
systemd unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mRqdHWRdUjEvEdfwig3wg

---------

Co-authored-by: root <root@vmi3417050.contaboserver.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:02:03 +00:00
Emi Griffith
1838527854 Gate the subscription notifier to one worker (#161)
Prod runs 3 uvicorn workers, but the in-process subscription notifier was
started in every worker's lifespan (it was written assuming a single-worker
deploy). Its sweep fetches recent-forecast/archive data from Open-Meteo on a
15-min timer independent of any request, so running it 3× tripled the
background upstream load against a shared quota — the source of the overnight
429/503 rate-limit errors in logs/errors, and 3× redundant subscription scans.

Elect one worker to own the notifier via a non-blocking exclusive flock on a
lockfile (THERMOGRAPH_SINGLETON_LOCK): the first worker wins and holds the fd
for its lifetime; others stand down; the OS releases the lock if the leader
dies, so a restart re-elects cleanly. Unset (single worker / dev / tests)
always wins, so behavior there is unchanged. Mirrors the shared-metrics store's
env-selected cross-worker coordination.

The neighbor warmer stays per-worker on purpose: each worker drains its own
request-fed queue, so gating it would leave non-leader queues undrained.

- backend/singleton.py: flock-based leader election (claim()).
- backend/app.py: gate notify.start() on singleton.claim().
- deploy/thermograph.service: default THERMOGRAPH_SINGLETON_LOCK to
  data/notifier.lock (needs the unit reinstalled on prod to take effect).
- deploy/thermograph.env.example: document it alongside WORKERS.
- Tests: leader election — single-worker default, first-wins, idempotent
  re-claim, second-holder stands down, re-election after release. 183 passed.

Co-authored-by: root <root@vmi3417050.contaboserver.net>
2026-07-17 12:54:55 +00:00
Emi Griffith
1a980c424f Sync main into dev (multi-worker prod + shared metrics store) (#160)
* Serve the app on thermograph.org at root; redirect emigriffith.dev/thermograph

Move Thermograph to its own domain. thermograph.org serves the app at its root,
and emigriffith.dev/thermograph* permanently redirects there (prefix stripped,
so deep links map straight across).

- app.py: BASE now supports an empty root prefix. THERMOGRAPH_BASE=/ (or empty)
  yields BASE="" so routes sit at "/", "/calendar", "/api/v2/…" with no double
  slashes; the bare-base redirect is skipped and the static mount falls back to
  "/". Non-empty values keep the existing "/thermograph" sub-path behavior
  unchanged (backward compatible).
- deploy/Caddyfile: add a thermograph.org site block reverse-proxying to the
  uvicorn on 127.0.0.1:8137; turn emigriffith.dev's /thermograph handler into a
  permanent redirect to thermograph.org.
- deploy/thermograph.env.example: default THERMOGRAPH_BASE=/.
- DEPLOY.md: document the two-domain layout and that deploy.sh does not ship the
  Caddyfile or env — those are applied on the VPS by hand.

The frontend already uses base-relative URLs, so it follows the root base with no
changes. Verified both modes locally (root serves /, /calendar, /api/v2/*; the
/thermograph sub-path still redirects bare→slash and 404s at root).

* Add dashboard (#144)

* Auto-submit IndexNow on deploy when the URL set changes (#130)

Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.

* Add ops metrics endpoint + SSH/Termux dashboard (#131)

Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.

* Dashboard: run under the app venv, not the box's default python3 (#132)

The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.

* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)

The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.

* Dashboard: make the live view a scrollable in-place pager (#134)

The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.

--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.

* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)

The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.

* Dashboard: add a STORAGE section (archive + DB sizes) (#136)

Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.

* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)

The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.

* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)

Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.

* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)

Values below 500 MB show in MB (one decimal); 500 MB and up in GB.

* Dashboard: accounts totals only; log client IPs per request (#140)

Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.

Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.

* Dashboard: wrap-aware pager so the header stops scrolling off (#141)

The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.

* Add log storage size to the ops dashboard STORAGE section (#142)

The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.

Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.

* Dashboard: show last-10-minute traffic per category (#143)

Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.

The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".

* Add dashboard #2 (#145)

* Auto-submit IndexNow on deploy when the URL set changes (#130)

Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.

* Add ops metrics endpoint + SSH/Termux dashboard (#131)

Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.

* Dashboard: run under the app venv, not the box's default python3 (#132)

The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.

* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)

The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.

* Dashboard: make the live view a scrollable in-place pager (#134)

The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.

--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.

* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)

The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.

* Dashboard: add a STORAGE section (archive + DB sizes) (#136)

Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.

* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)

The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.

* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)

Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.

* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)

Values below 500 MB show in MB (one decimal); 500 MB and up in GB.

* Dashboard: accounts totals only; log client IPs per request (#140)

Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.

Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.

* Dashboard: wrap-aware pager so the header stops scrolling off (#141)

The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.

* Add log storage size to the ops dashboard STORAGE section (#142)

The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.

Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.

* Dashboard: show last-10-minute traffic per category (#143)

Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.

The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".

* Run prod on 3 uvicorn workers with a shared metrics store

A single slow upstream weather fetch (cache-miss during an open-meteo/NASA
degradation) blocked the one uvicorn worker and took the whole app down for
~2 min — real users got aborted connections. Give prod concurrency headroom so
one slow request can't freeze the rest.

- deploy/thermograph.service: worker count is env-driven (`--workers ${WORKERS}`,
  default 1); prod sets WORKERS=3 in /etc/thermograph.env. Dev's separate
  --user unit is untouched (stays single-worker).
- metrics.py: with multiple workers, per-process counters would split the tally
  and the ops dashboard (polls one random worker) would see only a fraction.
  Add a shared SQLite store selected by THERMOGRAPH_METRICS_DB so every worker
  tallies into one place; unset keeps the zero-dependency in-memory store for
  dev/tests/single-worker. Public API and snapshot shape unchanged.
- The unit defaults THERMOGRAPH_METRICS_DB and clears it on each (re)start via
  ExecStartPre, so bumping WORKERS can never silently fragment the dashboard and
  "since start" tallies keep their old meaning.
- Tests: cover the SQLite store — cross-worker aggregation, window roll-off,
  and env-based selection. Full suite: 178 passed.

Note: applying to prod also needs the unit reinstalled + WORKERS=3 in
/etc/thermograph.env — deploy.sh only pulls+restarts, it doesn't reinstall the
systemd unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mRqdHWRdUjEvEdfwig3wg

---------

Co-authored-by: root <root@vmi3417050.contaboserver.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:43:22 +00:00
Emi Griffith
0799a9ab90 Push: surface delivery failures (no longer a silent success) (#157)
A failed web push (e.g. VAPID key mismatch -> 401/403) was swallowed: /push/test still
returned 202 and the UI showed 'Sent', while the only trace was a journald WARNING.
Now: push.send logs failures to the errors JSONL (phase=push) so they show up like other
errors; /push/test returns a 'failed' count; and the 'Send test' button reports 'No
device' / 'Sent' / 'Failed' from the real result. Document the VAPID env vars (missing
from thermograph.env.example) and how to pin/diagnose them.
2026-07-17 00:12:14 +00:00
Emi Griffith
1b4e2ec122 Auto-submit IndexNow on deploy when the URL set changes (#130)
Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.
2026-07-16 13:38:59 -07:00
Emi Griffith
7492516a04 Add IndexNow, stable sitemap lastmod, and search-verification meta (#127)
Faster search-engine indexing for the ~14k climate/city/record URLs:

- IndexNow (backend/indexnow.py): instantly notify Bing/DuckDuckGo/Yandex of new
  or changed URLs. Per-host key resolved env → gitignored file → generated (mirrors
  push.py), served at /{key}.txt, and echoed in submissions. `submit_all()` + a
  `make indexnow` CLI push every indexable URL, batched under the 10k cap. Google
  doesn't use IndexNow, so it stays on the sitemap.
- Sitemap: replace the per-request today() <lastmod> (which churns every fetch and
  trains crawlers to ignore lastmod) with a stable content-build date; factor the
  URL list into public_paths() shared with IndexNow so the two never drift.
- Search-console verification: render google-site-verification + msvalidate.01
  <meta> tags from env into every page's <head> — the SEO pages via base.html.j2
  and the static pages (incl. the homepage Google verifies) via _page().
- Docs: env example documents the three new vars; .gitignore ignores the key.

Tests: key-file route, stable single-date lastmod, IndexNow payload/batching, and
verification meta on both SEO and static pages.
2026-07-16 18:08:09 +00:00
Emi Griffith
906c0fd8c7 SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.

Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
Emi Griffith
00d4c054c4 Serve the app on thermograph.org at root; redirect emigriffith.dev/thermograph (#91)
Move Thermograph to its own domain. thermograph.org now serves the app at its
root, and emigriffith.dev/thermograph* permanently redirects there (prefix
stripped, so deep links map straight across).

- app.py: BASE now supports an empty root prefix. THERMOGRAPH_BASE=/ (or empty)
  yields BASE="" so routes sit at "/", "/calendar", "/api/v2/…" with no double
  slashes; the bare-base redirect is skipped and the static mount falls back to
  "/". Non-empty values keep the existing "/thermograph" sub-path behavior
  unchanged (backward compatible).
- deploy/Caddyfile: add a thermograph.org site block reverse-proxying to the
  uvicorn on 127.0.0.1:8137; turn emigriffith.dev's /thermograph handler into a
  permanent redirect to thermograph.org (handle_path strips the prefix; the bare
  /thermograph goes to the root).
- deploy/thermograph.env.example: default THERMOGRAPH_BASE=/ (app owns the domain).
- DEPLOY.md: document the two-domain layout and that deploy.sh does not ship the
  Caddyfile or env — those are applied on the VPS by hand.

The frontend already uses base-relative URLs, so it follows the root base with no
changes. Verified both modes locally (root serves /, /calendar, /api/v2/*; the
/thermograph sub-path still redirects bare→slash and 404s at root) and the full
test suite (123) passes.
2026-07-15 19:58:32 +00:00
9d315f81c6 Caddyfile: redirect old raw-IP URLs to the canonical domain (#16)
Requests to http://<vps-ip>/... were bounced by the default HTTP->HTTPS rule to
https on the bare IP, which has no certificate and fails the TLS handshake, so
pre-domain bookmarks dead-ended with a blank page. Add an http://<ip> site block
that 301-redirects to https://emigriffith.dev, preserving the path.
2026-07-11 03:40:08 +00:00
4fb071039b Caddyfile: use a named path matcher for the /thermograph routes (#14)
The handle directive takes a single matcher token; two space-separated paths
(/thermograph /thermograph/*) fail to parse with 'Wrong argument count'. Match
both via a named @thermograph path matcher instead. Verified with caddy validate.
2026-07-11 03:23:28 +00:00
Emi Griffith
55800ba66d Merge remote-tracking branch 'origin/main' into promote-check
# Conflicts:
#	frontend/compare.html
#	frontend/compare.js
#	frontend/style.css
2026-07-10 20:20:56 -07:00
624813ca4e Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
  domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
  404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
  uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match. (#11)

* Add dev CI/CD pipeline deploying to a LAN server (#6)

* Weekly page: no-rain visuals adopt the dry-streak look

On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:

- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
  new optional color override on rdCell.

Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z

* Calendar totals: compact per-category status lines

Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.

The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.

Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Calendar totals strip: wider bars, 0.1%-precision small shares (#5)

Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".


Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Weekly timeline: label dry streak instead of a dot

On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dev CI/CD pipeline deploying to a LAN server

PRs into dev run a build + boot/health check, auto-merge on green, and
deploy the merged branch to a self-hosted runner on the LAN box, which
runs the app as a sudo-free systemd --user service on 0.0.0.0:8137.

- ci-cd.yml: build -> auto-merge -> deploy (self-hosted)
- deploy-dev.yml: deploy on direct pushes / manual dispatch
- deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh
- CLAUDE.md: dev is the PR base branch; commit/PR message conventions
- DEPLOY-DEV.md: pipeline docs

* Match runner service name in docs to the installed user unit

* Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Build LAN dev venv on pinned Python 3.12 via uv (#7)

This machine's default python3 is 3.14, which has no prebuilt pyarrow/pandas
wheels, so plain venv + pip fell back to a failing source build. Use uv to pin
the interpreter (fetching a managed CPython 3.12 if needed), independent of the
runner's PATH and pyenv state.

* Add comfort-temperature compare view; simplify Feels calendar filter (#9)

Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).

Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.

* Scope app to /thermograph, free the domain root for a portfolio (#10)

- Drop the app-level "/" redirect so the FastAPI app no longer claims the
  domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
  404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
  uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:03:39 -07:00
ccf67a257e Scope app to /thermograph, free the domain root for a portfolio (#10)
- Drop the app-level "/" redirect so the FastAPI app no longer claims the
  domain root; it stays scoped to THERMOGRAPH_BASE (/thermograph). Root now
  404s at the app, leaving it for another service behind the proxy.
- Caddyfile: serve a static portfolio at / and reverse-proxy /thermograph* to
  uvicorn, on emigriffith.dev with automatic Let's Encrypt TLS.
- Default THERMOGRAPH_BASE to /thermograph in the env example to match.
2026-07-11 03:01:16 +00:00
f731df0da8 Add dev CI/CD pipeline deploying to a LAN server (#6)
* Weekly page: no-rain visuals adopt the dry-streak look

On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:

- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
  new optional color override on rdCell.

Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z

* Calendar totals: compact per-category status lines

Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.

The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.

Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Calendar totals strip: wider bars, 0.1%-precision small shares (#5)

Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".


Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Weekly timeline: label dry streak instead of a dot

On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add dev CI/CD pipeline deploying to a LAN server

PRs into dev run a build + boot/health check, auto-merge on green, and
deploy the merged branch to a self-hosted runner on the LAN box, which
runs the app as a sudo-free systemd --user service on 0.0.0.0:8137.

- ci-cd.yml: build -> auto-merge -> deploy (self-hosted)
- deploy-dev.yml: deploy on direct pushes / manual dispatch
- deploy-dev.sh + thermograph-dev.service + provision-dev-lan.sh
- CLAUDE.md: dev is the PR base branch; commit/PR message conventions
- DEPLOY-DEV.md: pipeline docs

* Match runner service name in docs to the installed user unit

* Pin CI Python to 3.12 for prebuilt pyarrow/pandas wheels

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:13:18 +00:00
Emi Griffith
9fd1839ce9 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00