The glyph previously sat outside the h1, centered by the flex row
against the whole title block - so its position relative to the
wordmark shifted between pages with and without a tagline. Move it
inside the h1 (and inside the home link on server-rendered pages) and
lay the lockup out as its own flex row, so mark and wordmark are always
aligned to each other; the tagline now sits under the full lockup.
Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
The mark is a quantized climb through the temperature-band tokens
(cold -> cool -> normal -> warm -> hot -> very-hot) to a rec-hot
terminal at a dashed record line, on the dark rounded tile.
- favicon.svg (canonical) + favicon-16/32/48.png fallbacks; the 16px
raster drops the whisper strata and record dash so the climb survives
- apple-touch-icon.png 180 rendered edge-to-edge (iOS supplies rounding)
- maskable 192/512 scale the artwork 0.78 about center so every element
clears the 40%-radius circular safe zone; any-purpose 192/512 and the
512 OG logo.png regenerated; reused filenames cache-busted with ?v=2
- header lockup: inline glyph referencing var(--cold) etc so a palette
change propagates, beside a lowercase 650-weight wordmark
- PNGs are palette-quantized with the band/chrome hexes pinned exact;
full asset set is ~20 KB
Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
Put Alerts in the main navigation (after Climate) across the interactive pages
and the SEO/climate pages, so it's a discoverable entry point rather than only the
header bell. The bell stays; this is an additional way in.
The signed-out /alerts gate now leads with a clear "What are weather alerts?"
explainer — what they do, how the percentile threshold works, and an example —
above the sign-in / create-account button, so visitors understand the feature
before signing in.
The extra nav item pushed the inline header past ~800px into a horizontal
scrollbar; trim the tab padding in the 641–1000px band so the row fits again
(phones already collapse into the hamburger).
- Compact the mobile menu: narrower panel (208px), tighter rows, and the nav
list now stretches to the panel width and left-aligns (it was centering because
the base .view-nav align-self:center overrode the panel's stretch).
- Stylish separation between entries: an inset hairline between each nav item,
suppressed around the active pill so nothing crosses it.
- Rename the "Day" view to "Day Detail" across the app nav, and add it to the
climate/city page nav (base.html.j2), which was missing it.
On phones the header spanned two rows: the title block plus a control bar of
the °F/°C toggle, bell, account and hamburger. Wrap the nav in a .nav-panel
inside the <details> menu and inject the °F/°C toggle and account/bell into that
panel instead of into the header row. On desktop the panel is display:contents,
so the inline top bar renders exactly as before (order restored via flex order);
on phones the panel becomes a right-anchored, viewport-clamped dropdown and the
header collapses to a single row (logo + name + hamburger, tagline hidden).
The viewport clamp also fixes the SEO/city pages, whose menu dropdown previously
ran off-screen.
- Mobile header: the title column was collapsing to a sliver behind the F/C +
bell + account + hamburger cluster, forcing one-word-per-line text and floating
the logo at the tall column's center. Give the title (logo + name + tagline) the
whole first row (flex-basis 0 floored by a min-width so it sits beside the logo,
not below it) and let the controls wrap to a control bar below; top-align the
logo with the name. The small lone hamburger on SEO pages still fits the title row.
- Add a Climate link to the interactive view nav so the hamburger reaches the
city/records section. Account + subscription (bell) icons live in the control bar.
- Collapse the view navigation into a hamburger menu on phones. The nav is
wrapped in a <details> (pure CSS, no JS — so it works on the crawlable SEO
pages too): transparent (display:contents) on desktop so the tabs render
inline, a dropdown behind a hamburger under 640px. The °F/°C toggle, bell and
account stay in the header bar; units.js/account.js now anchor their injected
controls on the wrapper.
- Replace the header logo: the ▚ glyph was a rotated Unicode char that fonts
rendered inconsistently (warped). Use a crisp inline SVG of the two accent
squares (currentColor) across all headers.
- Month pages: turn the plain "Visiting <city> in <month>?" line into a bordered
callout with a CTA button, matching the city page's travel box.
Verified in Firefox and Chromium at desktop and 390px, light and dark: desktop
nav inline, mobile hamburger opens the dropdown, logo crisp, travel note styled.
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
Make the app installable and deliver existing alert notifications as OS
push, alongside the in-app bell.
Backend:
- PushSubscription model (per-device endpoint + keys, owned by a user) and
register/unregister/test endpoints under /api/v2/push, cookie-auth scoped
to the user like the subscription routes.
- push.py: VAPID key management (env -> data/vapid.json -> generated) and a
pywebpush send helper that reports gone endpoints for pruning. No DB coupling.
- notify.py: after creating an in-app Notification, dispatch Web Push to the
user's devices (guarded — a push failure never affects the in-app write;
endpoints reported gone are pruned).
- Serve the .webmanifest with the correct media type.
Frontend:
- manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages.
- sw.js: push + notificationclick handlers (push-only; no fetch caching, so it
doesn't fight the existing IndexedDB cache). Registered globally in nav.js in
secure contexts.
- push-client.js + a "Notifications on this device" toggle and test-send on the
/alerts page, subscribing through the existing cookie-aware apiFetch.
Push and service workers require a secure context, so this is active over HTTPS
(or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
- Compare: move the "Judge each day by its" basis toggle out of the filter sheet into
the inline comfort card above the results (a scoring control, like the comfort
slider). Handler is id-based, so no JS change for the move.
- Metric selector is duplicated, not relocated: revert the sheetOnly relocation in
filtersheet.js; keep the inline selector visible on all viewports and add a synced
copy inside each page's filter sheet (mobile-only via CSS). The sheet copy clones the
inline buttons; a single setter (setDistMetric / setColorMetric) drives the shared
state and reflects the active metric on both, so picking any of the 8 metrics in
either selector syncs the other and re-renders. Applies to compare's "Distribution
by" and calendar's "Color the calendar by".
Frontend-only; no backend or payload change.
- Season filter: add a "Full year" select-all row above the four seasons (checked at
12 months, clears to none, indeterminate when partial). One shared change in
seasonFilterDropdown/applySeasonMonthChange/syncSeasonChecks covers both pages; the
summary already reads "All year" at 12.
- Metric selector rides the filter sheet on phones: initFilterSheet gains a sheetOnly
option that relocates given controls into the sheet at the 640px breakpoint and
restores them inline on desktop (matchMedia; reparenting keeps listeners). Compare
passes its "Distribution by" control, calendar its "Color the calendar by" block.
- Compare comfort/tolerance slider moved out of the params panel into a standalone
full-width card above the results, so it's always visible instead of buried in the
mobile pill sheet. The params sheet now holds basis + season + date range.
Frontend-only; no backend or payload change.
- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no
min-width:0, so each row expanded to the 9–10-column connected bar chart's
min-content and widened the whole page (zoom-out, clipped filter sheet). Add
grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its
own .ct-strip, and tighten .ct-col to 34px on phones.
- Add a Load/Refresh button inside the compare filter sheet's date-range block, wired
to the same refresh()/isDirty(); editing the range on mobile no longer needs the
sheet closed. The existing button stays in the controls (loads added places).
- Default date range on both pages is now January six years back → the present. On
the calendar this is an explicit chunked range (the months=24 path is server-capped
at ~2yr).
- Names now include country: reverse_geocode appends it to the place label; revgeo
cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads
repopulate.
- Location names read as a hierarchy: compact chips show just the lead segment (full
name in the title/aria), and each ranked card shows the lead segment bold over a
muted "city · region · country" line.
Frontend + a small backend name/cache change; no schema migration.
Compare gains a time-of-year filter it never had; the calendar's flat 4-season
filter is upgraded to the same model so both pages filter time identically.
- Shared season/month model in shared.js: seasons are the primary, color-coded
selector, the 12 months a nested suboption, backed by one Set of month indices.
seasonFilterDropdown/syncSeasonChecks/applySeasonMonthChange/seasonSummaryText
plus month<->bitmask helpers, reused by both pages.
- Compare: filter gates r.date's month into computeStats and the distribution;
instant re-rank, no refetch. Persisted (cmpMonths bitmask) and carried in the
URL hash (m=, omitted when all 12). Empty-selection hint when a filter excludes
every day.
- Calendar: checkedSeasons -> checkedMonths, applied in the render loop; persisted
as calMonths. Season checkbox is checked/indeterminate by how many of its months
are on.
- Mobile filter sheet (filtersheet.js): a subtle floating pill slides the page's
filter panel up as a bottom sheet, reachable from anywhere on the page, on both
compare and calendar. Desktop keeps the inline panel unchanged. Respects
reduced-motion and the home-indicator inset.
Frontend-only; no backend or payload change.
Make the comfort/tolerance slider self-explanatory and fix its labeling.
- A temperature marker sits above each of the four handles (the band
borders) — the two comfort bounds and the two tolerance bounds — so the
numbers read straight off the track instead of a combined caption.
- The header is now two disparate labels, "Comfort lo–hi" and "Tolerance
tlo–thi", rather than the old "Comfort & tolerance 16–22 · tolerance
4–27" that mislabeled the comfort range.
- Distribution-bar key: on desktop each category label is positioned at its
segment's midpoint (ends pinned to the bar edges) so the labels line up
under the colors they describe; on mobile it stays a plain wrapping row.
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
* Compare: comfort range slicer + per-location climate distribution
Replace the comfort-point + band sliders with a single dual-handle range
slicer: a day hits comfort when its judged temperature lands in [lo, hi];
below lo is colder, above hi is warmer. State is the range in °F, so old
c/t links and stored prefs migrate to a range. Ranking, average miss, and
the typical-miss figure are all computed against the range.
Add a climate-distribution section below the ranked cards, independent of
the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/
Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→
Record-High distribution strip per location, mirroring the calendar.
Extract the calendar's category bucketing and distribution strip into
shared metricBuckets/distStrip so both pages share one implementation;
calendar output is unchanged.
* Carry the calendar's wet/dry scale-group logic into the shared distribution helpers
* Carry the precip strip's per-tier label hook into the shared distribution helper
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
The page scripts each re-declared the shared presentation layer — the tier
color table existed in four places (app.js, calendar.js, day.js, style.css)
and the scale label tables in three (plus legend.html's own drifting copy),
alongside per-page copies of the dryness ramp, formatters, ord, todayISO,
esc, the weather icons/summary, month helpers and the 2-year range chunker.
~240 duplicated lines deleted (net -236 with the new module included).
- frontend/shared.js (IIFE, extends window.Thermograph): tier colors read
from style.css's :root custom properties at load — the CSS is now the
single source of truth; the JS map exists only because inline-SVG work
(chart + PNG export) needs literal values, with hex fallbacks for a
missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord,
todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker,
and the weatherType summary (dsr-aware; the day page just omits dsr).
- nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in
the shared classic-script scope, and a leaked locHash collided with page
destructuring (caught by the browser smoke, not by node --check).
locHash is now exported and used by all 8 former hand-built hash sites.
- mappicker.js: initFindButton/setFindLabel replace the Find-button block
each page rebuilt.
- legend.html renders its scales from the shared tables, so the guide can
no longer drift from what the app shows.
Verified: node --check on all JS; 108 backend tests; headless-Chromium
smoke over all five pages against live data — no console/page errors,
legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle,
calendar grid + key + metric switch, day 7 ladders + weather icon,
compare seeded rank card.
Sharing a Thermograph URL (Discord, Slack, iMessage…) now unfurls into a
card with the site name, page title, description, accent color, and logo.
- All five pages get description/theme-color/og:*/twitter:card meta and a
favicon. og:url/og:image need absolute URLs and crawlers don't run JS, so
pages carry an __ORIGIN__ placeholder the server fills in per request from
X-Forwarded-Proto (Caddy) / scheme + the Host header + base path — correct
on both the LAN dev server and the prod domain without hardcoding either.
- New logo assets: the header's ▚ mark as an app icon (accent quadrants on
the dark surface) — logo.png (512x512, the og:image) and logo.svg (favicon).
- Page routes render the substitution with a weak ETag + 304 revalidation
(replacing plain FileResponse) and now answer HEAD, which preview crawlers
probe with (previously 404).
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.
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.