Commit graph

44 commits

Author SHA1 Message Date
Emi Griffith
a5fbd70d50 SEO: crawlable programmatic climate pages + technical hygiene (#96)
* 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.
2026-07-15 23:53:11 +00:00
Emi Griffith
f34ab7ef96 Add PWA + Web Push delivery for weather alerts (#95)
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.
2026-07-15 23:21:06 +00:00
Emi Griffith
7567822522 Fix mobile alerts dropdown overflow; add account API tests (#93)
The notification dropdown is anchored to the bell button, which on mobile sits
left of the account button rather than at the screen edge, so the wide panel
overflowed off the left of the viewport (title/text cut off). On narrow screens,
drop .notif's positioning context so the dropdown anchors to the .acct cluster at
the header's right edge, keeping it fully on-screen.

Also add route-level tests for the accounts feature (auth flow, subscription CRUD,
duplicate/validation, ownership 404s, notifications), plus a
THERMOGRAPH_ACCOUNTS_DB override so the suite writes to a throwaway DB and stays
hermetic.
2026-07-15 22:00:47 +00:00
Emi Griffith
d742cfe11f Account system with weather-notification subscriptions (#89)
* 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.
2026-07-15 18:46:46 +00:00
Emi Griffith
cd55b63e48 Compare labels: place with least-squares blocks so isolated labels stay put (#87)
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.
2026-07-12 21:05:01 +00:00
Emi Griffith
3fbaffd876 Distribution bars: split the in-bar range into Max/Min rows (#84)
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.
2026-07-12 18:32:38 +00:00
Emi Griffith
de02ebcf19 Compare basis inline; synced inline + sheet metric selector (both pages) (#82)
- 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.
2026-07-12 07:45:12 +00:00
Emi Griffith
ee9c9b3045 Season full-year option; metric select into mobile sheet; comfort inline on compare (#81)
- 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.
2026-07-12 07:11:21 +00:00
Emi Griffith
f3ba056738 Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)
- 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.
2026-07-12 06:32:10 +00:00
Emi Griffith
d38bf0192c Add a season/month time filter + mobile filter sheet (compare & calendar) (#79)
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.
2026-07-12 05:33:09 +00:00
Emi Griffith
e11fc73f46 Category distribution: connected bar chart with per-tier avg + range (#76)
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.
2026-07-12 04:31:29 +00:00
Emi Griffith
bc1aac3424 Compare: temperature markers over slider handles; split range labels (#75)
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.
2026-07-12 02:23:28 +00:00
Emi Griffith
9f3c2caabc Compare: label every bar segment with a key; drop the colored caption (#73)
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.
2026-07-12 01:49:22 +00:00
Emi Griffith
46c5006ce0 Compare: replace diverging bar with a labeled day-distribution bar (#71)
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.
2026-07-12 00:49:06 +00:00
Emi Griffith
428dfebe83 Compare: match-score ranking, tolerance range, fix Feels basis (#69)
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.
2026-07-12 00:25:36 +00:00
Emi Griffith
b49489e730 Compare: diverging comfort bars, baseline strip, real loading state (#68)
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.
2026-07-11 23:49:00 +00:00
Emi Griffith
2662fa38e5 Compare: comfort range slicer + per-location climate distribution (#62)
* 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
2026-07-11 22:33:38 +00:00
Emi Griffith
c65ee15c0d Raise the precip "Moderate" label further (#65)
-0.85em still grazed the neighbouring Light–Mod/Mod–Heavy labels on mobile;
lift to -1.4em so it sits clearly above the cluster.
2026-07-11 22:33:26 +00:00
Emi Griffith
cfc8cd0b92 Lift crowded "Moderate" label in the precip strip (#61)
On narrow screens the single-word "Moderate" label sits between the two-line
"Light–Mod" and "Mod–Heavy" labels; its centered box is wider than the column
and spilled over their first line. Tag each rain column with its tier key and
nudge the "Moderate" (wet-5) label up into the empty gap above to clear it.
2026-07-11 22:26:29 +00:00
Emi Griffith
94dde1ebc6 Merge pull request #52 from griffemi/fix/day-mobile-overflow
Day page: stop metric cards overflowing the viewport on phones
2026-07-11 14:21:16 -07:00
a05809f39d Fix mobile map picker footer cut off by iOS Safari chrome (#53)
On phones the location-picker modal is a full-screen sheet sized with
height: 100vh. On iOS Safari 100vh is the large-viewport height (measured
as if the browser toolbars were retracted), so while the toolbars are
showing the bottom of the sheet — the footer with the 'Use this location'
button — falls behind Safari's chrome and is cut off.

Size the sheet with 100dvh (dynamic viewport height), which tracks the
currently-visible height. The flex map yields the space so the footer
stays on screen. 100vh is kept as a fallback for older engines, and the
footer gains safe-area-inset-bottom padding to clear the home indicator
once the bottom toolbar retracts.
2026-07-11 21:17:04 +00:00
Emi Griffith
e5b1bb0fc2 Day page: stop metric cards overflowing the viewport on phones
The #day-body card grid used bare 1fr columns, whose minimum track size is
the widest card's min-content. The humidity ladder's nowrap value ranges
("17.3 g/m³–19.9 g/m³") pushed that minimum past the viewport on phones —
the page scrolled sideways 21px at 390px wide and 51px at 360px — so the
whole Day view sat off-center.

- Size #day-body columns with minmax(0, 1fr) so card content can never
  widen the page canvas.
- Collapse the unit repeated on both range bounds ("17.3–19.9 g/m³") and
  drop it from the observed ◀ marker; the unit still reads from the card
  summary, median, and historical range.
- Let .ladder-val wrap as a last resort instead of nowrap-widening the row.
- Rebalance the mobile ladder tracks (pct 54→46, marker 48→42) so the
  longest range stays on one line down to ~375px screens; narrower than
  that it wraps cleanly at the unit.
2026-07-11 14:13:05 -07:00
b7bad50e96 CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
  auditing every style.css class selector against all class usage in
  HTML/JS, including dynamically-built names): the pre-modal inline
  search/suggestions, the old two-column .layout + inline #map, the
  .panel fixed-height scroller (+ its now-pointless .panel-full
  neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
  filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
  bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
  of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
  (4 copies) are each one grouped rule; components keep only their
  deltas. The iOS zoom-on-focus rule now has a single documented home.
  (The consolidation also restored the .date-row button rule that the
  dead-search deletion would otherwise have taken with it.)

993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
efd43dcbb5 Center the metric selectors and category-totals strip on desktop (#38)
- Calendar: the 'Color the calendar by' selector shrinks to its tabs and sits
  centered over the month grid beyond phone widths (scoped under .metric-block
  so the Compare basis toggle keeps filling its param cell).
- Calendar: the category-totals strip widens to 960px and centers in the
  content column.
- Weekly: the chart metric switch likewise shrinks to its tabs and centers
  over the trend chart. Phone layouts (full-width 4-per-row toggles) are
  untouched.
2026-07-11 19:04:30 +00:00
c12b5953c0 Large-monitor layouts + compact CTA, comfort-slider fix, month-picker on click (#37)
- 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.
2026-07-11 18:47:52 +00:00
e3088720a6 Typo-tolerant location search suggestions (#33)
Add /api/v2/suggest and wire the location picker's search box to it as a
debounced type-ahead: top-5 place suggestions that tolerate a single-letter
typo (substituted, missing, or extra letter, or two adjacent letters swapped)
anywhere in the query, including the first character.

- backend/places.py: local place index built from a GeoNames cities dump
  (downloaded once into data/geonames/, loaded in a background thread; the
  app boots and serves without it). Exact-prefix matches rank first by
  population, then names one edit away; a token vocabulary respells one
  mistyped word against known place-name tokens ("pest seattle" ->
  "west seattle") for retry against the upstream geocoder, which covers
  neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the
  dump (default cities1000).
- /suggest blends local and upstream results by population with an exactness
  boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the
  query is one edit from the city, while "munchen" still surfaces Munich via
  upstream (the index only knows English names). Upstream lookups are
  memoized and skipped entirely when the index answers convincingly.
- mappicker.js: debounced (250ms) live suggestions with abort + sequence
  guards against stale responses, arrow-key navigation, Enter-picks-highlight,
  Escape dismissing the list before closing the overlay. Submit goes through
  the same typo-tolerant endpoint.
- climate.geocode results now carry population (used for ranking).
2026-07-11 15:36:14 +00:00
2f38fb8b51 Weekly view: center the daily-graded window on the target day (#28)
* Pin the °F/°C toggle to the header's top-right on all widths

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.

* Weekly view: center the daily-graded window on the target day

Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:

- Show two weeks of history before the target, the target itself, then up
  to 7 days after it. Days after the target are real observations when they
  are already in the past and the forward forecast when they run into the
  future.
- Mark the target day with a solid orange line on the chart (and an accent
  edge on its table column); draw the dashed "forecast →" divider only where
  the window actually crosses into the future and it is separated from the
  target marker.

Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.

Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.

* Weekly view: extend the after-target window to 14 days

Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
052b0a3e26 Picker map: smaller labels, darker roads; location label coords→place (#27)
* 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>
2026-07-11 08:35:43 +00:00
20a4108397 Pin the °F/°C toggle to the header's top-right on all widths (#26)
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.
2026-07-11 08:23:43 +00:00
Emi Griffith
52bfeef8bd Merge remote-tracking branch 'origin/dev' into map-color-boost
# Conflicts:
#	frontend/mappicker.js
#	frontend/style.css
2026-07-11 01:21:50 -07:00
Emi Griffith
acaacd492e Picker map: bigger roads/labels, more color (Voyager, upscaled + saturated)
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
2026-07-11 01:19:52 -07:00
34dd577545 Revert Orchid Sunset palette; restyle location-picker map (#24)
* 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>
2026-07-11 08:11:15 +00:00
01e5f48257 Add a °F/°C unit toggle to the header (#23)
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.
2026-07-11 08:10:40 +00:00
Emi Griffith
dade6c3cb4 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
2026-07-11 01:10:15 -07:00
Emi Griffith
4b23fda4ec Revert "Merge pull request #22 from griffemi/fem-orchid-palette"
This reverts commit f7d16238f2, reversing
changes made to 34e4fed1f0.
2026-07-11 01:06:30 -07:00
Emi Griffith
6f6870bbf0 Recolor heatmap palette: "Orchid Sunset" (violet → blush → magenta)
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
2026-07-11 00:45:46 -07:00
8715a7b464 Metric cards: status to top-right, normal under the value; bold head context (#20)
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.
2026-07-11 07:20:13 +00:00
c09ec958d6 Stack the location head context top-down; drop sq-mi-cell wording (#19)
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.
2026-07-11 07:10:46 +00:00
5faa6b4281 Compare: put full state in the URL and gate reloads behind Refresh (#12)
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.
2026-07-11 03:17:17 +00:00
13bc4cf0ac 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.
2026-07-10 19:58:56 -07:00
Emi Griffith
8ff352e236 Calendar totals: label each bar, share/count toggle, plot Normal
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>
2026-07-10 19:00:41 -07:00
27a6508b72 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>
2026-07-10 18:38:34 -07:00
Emi Griffith
d4272e57da 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>
2026-07-10 18:28:24 -07:00
Emi Griffith
3dce838800 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00