Add a lightweight GET /api/v2/place?lat=&lon= endpoint that snaps to the grid
cell and returns the reverse-geocoded "neighborhood, city, region" label (the
same string the view endpoints expose as place, cached + throttled). On the
compare page, adding a location now fires this right away so the chip flips from
coordinates to the place name immediately — instead of only resolving when the
full comparison loads on Refresh. Best-effort: the series load still resolves the
name, so a failed/again call is harmless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.
- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
Nominatim calls, re-checking the cache under the lock), so concurrent callers
resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
coordinates fallback can't stick for the life of the token.
Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.
- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
Nominatim calls, re-checking the cache under the lock), so concurrent callers
resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
coordinates fallback can't stick for the life of the token.
Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Picker map: smaller city labels, darker/bolder roads (split tile layers)
Split the CARTO Voyager basemap into two raster layers so label size and road
weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset
-1 (bold, prominent roads) plus a labels-only layer at natural size on top (small,
crisp city names). The darken/saturate filter now targets the base layer only
(.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label
text at full contrast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
* Location label: show coordinates immediately, then resolve to place name
On selecting a location, write the raw coordinates to the location label right
away, so the user sees the picked spot instantly instead of a stale/blank label.
The existing post-fetch render already overwrites it with the reverse-geocoded
"neighborhood, city, region" string (data.place), so the label now reads
coords → place name live.
- app.js / calendar.js / day.js: set coords in the selection handler before the
fetch; the render/initOnce overwrite is unchanged.
- compare.js: show coordinates through the pending + loading states (instead of
"Locating…") so each chip's coords are replaced live by the place name once
scored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.
Follow-up to the picker restyle: use the colorful CARTO "Voyager" basemap in
both themes (the monochrome dark-matter tiles read as "no color"), render tiles
one zoom lower at 512px (tileSize 512 + zoomOffset -1, @2x for crispness) so
roads and city labels are noticeably larger, and add a saturate/contrast filter
on the tile pane to push the colors further.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
* Revert "Merge pull request #22 from griffemi/fem-orchid-palette"
This reverts commit f7d16238f2, reversing
changes made to 34e4fed1f0.
* Restyle location-picker map: themed CARTO basemap + branded pin
Replace the plain gray OpenStreetMap raster in the location picker with a
theme-aware CARTO basemap (no API key): sleek "dark matter" tiles under the
dark theme, colorful "voyager" tiles under the light theme, so the map
matches whichever is active. Swap Leaflet's default blue marker for an
accent-colored teardrop pin (the same glyph the Find button uses), and theme
the zoom controls + attribution to sit in the app's surfaces.
- mappicker.js: theme-aware CARTO tileLayer (retina, CARTO attribution);
L.divIcon "mp-pin" used for the marker
- style.css: .mp-pin styling, themed .leaflet-bar / attribution, map shadow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A shared units module in nav.js holds the active unit (persisted), converts
Fahrenheit API values for display, and notifies pages to repaint on a switch.
A segmented °F/°C control is injected into the top-right of every header
(left of the view tabs); Weekly, Day and Calendar convert every temperature —
cards, dense timeline, trend chart axis/labels, tooltips and the PNG export —
while wind, humidity and precip stay as-is. Compare keeps its Fahrenheit
comfort slider, so the toggle is hidden there.
Replace the plain gray OpenStreetMap raster in the location picker with a
theme-aware CARTO basemap (no API key): sleek "dark matter" tiles under the
dark theme, colorful "voyager" tiles under the light theme, so the map
matches whichever is active. Swap Leaflet's default blue marker for an
accent-colored teardrop pin (the same glyph the Find button uses), and theme
the zoom controls + attribution to sit in the app's surfaces.
- mappicker.js: theme-aware CARTO tileLayer (retina, CARTO attribution);
L.divIcon "mp-pin" used for the marker
- style.css: .mp-pin styling, themed .leaflet-bar / attribution, map shadow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
Replace the clinical blue→green→red diverging temperature scale, the
green→navy rain ramp, and the orange accent with a warmer, more vibrant
"Orchid Sunset" palette: violet/lavender cool end, soft blush middle,
rose/magenta warm end, hot-pink accent.
Keeps the diverging structure grading relies on (cool reads cold, warm
reads hot) and updates every mirror of the literals so PNG export and the
self-contained subpages stay in sync:
- style.css :root tier + precip vars, accent, cta-cal gradient/shadows,
dry-metric toggle; light-theme overrides for the near-white middle tiers
so "Normal" stays legible on white
- app.js TIER_COLORS, chartPalette() fallbacks, dry-streak ramp
- calendar.js TIER_COLORS, PRECIP_COLORS, dry-streak ramp
- day.js TIER_COLORS, PRECIP_COLORS, DRY_COLOR
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
Move each metric card's grade status into the top-right corner and put the
median on its own 'normal: <value>' line under the big number. Bold the
coordinates, climatology year range and sample-size count in the location
head, and label the sample line 'N days sample size (day ±7)'.
Calendar: drop the tagline and use a compact, roughly half-height header.
The Weekly location card joined coordinates, cell size, climatology range
and sample count into one dot-separated meta line. Stack them as one fact
per line so the important context reads top-down instead of as a run of
tags. Drop the '~N sq mi cell' wording from the Weekly/Day/Calendar meta
lines and the map hint.
Reverse-geocode at zoom 14 and prefer neighbourhood/suburb ahead of the
city so labels read 'Gowanus, New York' when OSM has that detail, falling
back to the city (and county) when it doesn't.
Replace the recurring '±7-day window' phrasing in the UI with plain
language ('a typical day', 'days around this date', 'typical climate').
Reverse-geocode at zoom 14 and prefer neighbourhood/suburb ahead of the
city so labels read 'Gowanus, New York' when OSM has that detail, falling
back to the city (and county) when it doesn't.
Replace the recurring '±7-day window' phrasing in the UI with plain
language ('a typical day', 'days around this date', 'typical climate').
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.
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.
The whole comparison — locations, comfort target, band, judged temperature
and date range — is now encoded in the URL hash (c/t/b/s/e/loc) and read
back on load, so a link reproduces exactly what's on screen. Comfort, band
and judged-temperature edits still re-rank instantly and update the hash
without a fetch.
Editing the date range or the location set no longer refetches on its own:
it marks the comparison dirty and reveals a single "Refresh comparison"
button next to Add location; the load runs only when it's tapped. Pending
locations show a dashed chip until then. The initial view (from a shared
link, the saved set, or the last-viewed spot) still loads on arrival.
- 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>
- 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>
- 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>
- 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.
- 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.
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.
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.
Two related client-cache improvements in nav.js:
- Day-of-fetch freshness: every cached API response is now tagged with
the viewer's local calendar day, and a cache hit requires both the TTL
AND a same-day tag. A cache stored yesterday is refetched today, so a
new day's data (and the calendar's "days shown") can't stay hidden
behind an otherwise-fresh cache across a midnight rollover.
- Cross-view prefetch now covers the Day page (api/v2/day), which it
previously never warmed, and skips any view that already has a fresh
same-day cache — so it only fetches the pages actually missing one.
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 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>
Caption every category column with its name (two reserved lines so the
value figures stay aligned whether a name wraps or not), and add a
"Show count" checkbox in the strip header that swaps every share figure
for the raw day count (state persists in localStorage, re-renders in
place via a delegated listener on #cal-totals).
Also plot the neutral "Normal" median category as a real bar instead of
a bare baseline tick, so the strip reads as a full distribution; bar
heights now scale to the tallest category (Normal included).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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