Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
The distribution-bar key de-collides labels by sweeping each one right to
clear its neighbour, which collapsed a row toward the right edge whenever an
early bucket was tiny — Cool/Comfortable slid far from their large segments
instead of sitting under them. Replace the sweep with standard 1-D label
placement: keep each label at its segment midpoint, merge only labels that
actually overlap into a block, and position each block at the least-squares
mean of its members' ideals. Isolated labels no longer move; clusters centre
on their segments instead of sliding to an edge. The run is clamped inside
the bar, and mobile keeps its plain wrapping row.
* Distribution bars: split the in-bar range into Max/Min rows
The min–max range inside each distribution bar packed onto one line
(e.g. -20–-16°), where the range dash collides with the minus signs and
becomes unreadable for negative temperatures. Split it into two labelled
rows — Max on top, Min below — so each value stands alone. The per-tier
average above each bar is unchanged.
Shared by the Compare distribution and the Calendar totals strip. Bump
the bar-height floor 26→30px so the two-line label never clips.
* Compare: nudge overlapping distribution-bar labels apart on desktop
The desktop key positioned each bucket label at its segment's midpoint, so
two small adjacent buckets (e.g. Warm 1% next to Too hot 2%) rendered their
labels on top of each other. Add a post-layout pass that measures each label
and sweeps the row — push right to clear the previous label, clamp the tail
to the bar's right edge, then pull left — so no two overlap while each stays
as close to its segment as the space allows. Re-runs on resize; mobile keeps
its plain wrapping row.
- 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.
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
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.
The bar showed each segment's share but not what it meant, leaning on the
coloured below/above-comfort text to explain it. Replace that text with a
per-card key directly under the bar: a colour swatch + plain name + share for
every bucket present (Too cold / Cool / Comfortable / Warm / Too hot), in the
same cold→hot order as the bar. Every span is spelled out, so a 1% sliver is
as clear as a 60% block, and small segments no longer need to fit a label
inside them. Bar segments also carry a hover title. The redundant top legend
is removed (each card now names its own colours), and the leftover summary
line keeps just the tolerance share and typical miss.
The diverging comfort bar read as noise — half the track sat empty and the
lighter/saturated tolerance split was unlabeled, so the coloured chunks had
no clear meaning. Replace it with one full-width bar that is 100% of the
place's days, split into five temperature buckets ordered cold → hot in the
site's own temperature colours: too cold, cool (within tolerance),
comfortable, warm (within tolerance), too hot. Wide segments print their
share inline, and a legend above the cards names every colour. More blue =
runs cold, more red = runs hot, with no empty space to decode.
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
compare.js never imported units.js, so the header unit toggle was never
built on the compare page and every temperature was stuck in °F. Import
units.js (which builds the toggle) and route the comfort target, comfort
band, and the colder/warmer/typical-miss figures through the unit
formatters, re-rendering on unit change. State stays in °F (the API's
unit); only the displayed numbers convert.
Add fmtDelta for degree *differences* (band, average miss), which scale
by 5/9 without the 32° offset that absolute temperatures use.
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.
- Widen the content column in steps at 1680/2400/3400px so 1440p-4K monitors
get real layout instead of a 1200px strip; header brand aligns with it.
- Calendar: months flow as a responsive multi-column grid (year-at-a-glance
on desktop, single column on phones); the time-filter panel spans the
content width as one row (Range | Seasons/Years/Weather) on desktop;
category-totals strip widened to 720px.
- Day: metric ladder cards lay out 2-up from 1000px (3-up at 4K).
- Weekly: the calendar CTA is a compact button (sub-line removed); the trend
chart's drawing width now tracks its container (~1.5 CSS px per SVG unit,
redrawn on resize) so wide screens gain plot room instead of magnification.
- Compare: scope the rank-bar segment colors to .cmp-seg — the bare
.cmp-comfort rule was painting a green box behind the comfort slider.
- Calendar/Compare From-To month inputs open the native month picker on
click (input.showPicker, ignored where unsupported).
- CLAUDE.md: require validating design changes at mobile/tablet/1080p/2K/4K.
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
* 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 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.