Commit graph

556 commits

Author SHA1 Message Date
Emi Griffith
d6a62cc2de Add ops metrics endpoint + SSH/Termux dashboard (#131)
Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.
2026-07-16 20:46:59 +00:00
Emi Griffith
1b4e2ec122 Auto-submit IndexNow on deploy when the URL set changes (#130)
Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.
2026-07-16 13:38:59 -07:00
Emi Griffith
f5d9f33b5b Auto-submit IndexNow on deploy when the URL set changes (#130)
Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.

To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.
2026-07-16 13:38:59 -07:00
Emi Griffith
f4acdafe01 Add Google Search Console site-verification file (#128)
Served at the domain root (frontend/ is mounted at /) so Google can verify
ownership of thermograph.org via the HTML-file method.
2026-07-16 18:56:42 +00:00
Emi Griffith
7492516a04 Add IndexNow, stable sitemap lastmod, and search-verification meta (#127)
Faster search-engine indexing for the ~14k climate/city/record URLs:

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

Tests: key-file route, stable single-date lastmod, IndexNow payload/batching, and
verification meta on both SEO and static pages.
2026-07-16 18:08:09 +00:00
Emi Griffith
23615a1085 Add IndexNow, stable sitemap lastmod, and search-verification meta (#127)
Faster search-engine indexing for the ~14k climate/city/record URLs:

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

Tests: key-file route, stable single-date lastmod, IndexNow payload/batching, and
verification meta on both SEO and static pages.
2026-07-16 18:08:09 +00:00
Emi Griffith
595a3b13d8 Give climate/city/record pages full header parity + working °F/°C (#124)
The server-rendered SEO pages shared base.html.j2's nav but carried no JS, so
they lacked the °F/°C toggle and account/bell, and their temperatures were baked
as dual-unit strings that couldn't respond to a toggle.

- Load units.js + account.js + climate.js on base.html.j2, so every SEO page gets
  the same header as the interactive pages (nav + °F/°C toggle + account + bell).
- Emit each temperature as <span class="temp" data-temp-f> (default °F text, real
  for crawlers/no-JS); climate.js rewrites them to the active unit on load and on
  toggle. Tint classes stay pinned to absolute °F. Drops the inline "(NN°C)".
- Make account.js base-aware: derive the app base from import.meta.url so its
  api/v2 fetches and the alerts links resolve from deep /climate/{slug} URLs
  instead of 404ing. Equivalent on the depth-1 interactive pages.
- Default the unit from the browser locale for first-time visitors (no stored
  pref): en-US → °F, everyone else → °C. A stored choice always wins.
2026-07-16 17:48:30 +00:00
Emi Griffith
90bded90b7 Give climate/city/record pages full header parity + working °F/°C (#124)
The server-rendered SEO pages shared base.html.j2's nav but carried no JS, so
they lacked the °F/°C toggle and account/bell, and their temperatures were baked
as dual-unit strings that couldn't respond to a toggle.

- Load units.js + account.js + climate.js on base.html.j2, so every SEO page gets
  the same header as the interactive pages (nav + °F/°C toggle + account + bell).
- Emit each temperature as <span class="temp" data-temp-f> (default °F text, real
  for crawlers/no-JS); climate.js rewrites them to the active unit on load and on
  toggle. Tint classes stay pinned to absolute °F. Drops the inline "(NN°C)".
- Make account.js base-aware: derive the app base from import.meta.url so its
  api/v2 fetches and the alerts links resolve from deep /climate/{slug} URLs
  instead of 404ing. Equivalent on the depth-1 interactive pages.
- Default the unit from the browser locale for first-time visitors (no stored
  pref): en-US → °F, everyone else → °C. A stored choice always wins.
2026-07-16 17:48:30 +00:00
Emi Griffith
ca4d8035a9 Compact account chip in mobile nav dropdown (#123)
The account button in the hamburger dropdown spanned the full panel width,
so a signed-in user's email rendered as a large centered bar dominating the
menu. Make it a compact chip that hugs its label and centers beside the bell
instead: drop the full-width/flex rules, tighten the button padding, and cut
the name truncation from 16ch to 12ch. The 40px min-height stays for the tap
target.
2026-07-16 15:45:43 +00:00
Emi Griffith
5a0329e90e Shrink in-bar Max/Min distribution text on mobile so 3-figure values fit (#122)
On phones the calendar/compare distribution columns are narrow (~38px), and the
in-bar "Max NNN° / Min NNN°" pill clipped three-figure values — e.g. "Max 103°"
rendered as "Max 10". Add a max-width:640px rule that drops the .ct-range font
from 9px to 8px and tightens the key↔value gap (5→2px) and side padding, so
three-figure highs/lows stay fully visible. Scoped to mobile; desktop unchanged.

Verified with a headless render of the shared .ct-strip at 360/390/430px (no
clipping on any three-figure value) and at 800/1920px (rule doesn't apply).
2026-07-16 12:38:26 +00:00
Emi Griffith
959a077c63 Polish the mobile nav dropdown; rename Day to Day Detail (#121)
- 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.
2026-07-16 12:34:04 +00:00
Emi Griffith
a74702cff7 Polish the mobile nav dropdown; rename Day to Day Detail (#121)
- 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.
2026-07-16 12:34:04 +00:00
Emi Griffith
f86100015d Fold the mobile header into a single hamburger menu (#119)
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.
2026-07-16 12:15:00 +00:00
Emi Griffith
cde03de0f0 Fold the mobile header into a single hamburger menu (#119)
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.
2026-07-16 12:15:00 +00:00
Emi Griffith
68745e16c6 Fix crushed mobile header; add Climate to nav (#118)
- 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.
2026-07-16 06:01:58 +00:00
Emi Griffith
31f68981a0 Mobile hamburger nav; crisp SVG logo; month travel-note callout (#117)
- 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.
2026-07-16 05:42:59 +00:00
Emi Griffith
4f6716283b Mobile hamburger nav; crisp SVG logo; month travel-note callout (#117)
- 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.
2026-07-16 05:42:59 +00:00
Emi Griffith
8260948dd8 Serve stale forecast cache when both Open-Meteo and MET Norway fail (#116)
Complete the forecast fallback chain: Open-Meteo → MET Norway → stale cache. When
both live sources are unavailable, serve the last cached recent/forecast bundle
even if it is past its 1-hour TTL — an hours-old bundle (which still carries the
recent observed days MET Norway lacks) beats a hard 503, mirroring the archive
path's stale-serve in _load_history.

The stale bundle is returned WITHOUT rewriting it, so its mtime stays old and the
next request retries the live sources first rather than treating the stale copy as
fresh. Only when there is no cache at all does the typed WeatherUnavailable surface.
2026-07-16 05:41:51 +00:00
Emi Griffith
6927a53ea0 Add MET Norway forecast fallback when Open-Meteo is unavailable (#115)
The forecast path had no backup — unlike history, which falls back to NASA POWER.
When Open-Meteo's forecast API was rate-limited or down, the recent/forecast
bundle (and every endpoint that grades future days) failed with a 503.

Add MET Norway (yr.no) Locationforecast as a keyless, global forecast backup,
mirroring the NASA POWER role for history:

- _metno_to_frame: aggregates MET Norway's sub-daily timeseries into the daily
  schema, converting units (°C→°F, mm→in, m/s→mph) and deriving feels-like from
  the NWS heat index / wind chill (MET has no gusts or apparent temperature).
  Precip prefers the 1-hour block and falls back to the 6-hour block so the
  hourly→6-hourly resolution switch never double-counts.
- _fetch_forecast_metno: the backup fetch, with the ToS-required identifying
  User-Agent and coordinates rounded to 4 decimals.
- _load_recent_forecast: on any Open-Meteo forecast failure, try MET Norway
  before surfacing the error; a shared rate limit still raises the typed,
  daily-aware WeatherUnavailable.

MET Norway is forecast-only (no recent past days), so it's a degraded-but-working
fallback: the forecast / day-ahead views keep serving during an Open-Meteo outage.

Tests cover the daily aggregation + unit conversion (incl. the no-double-count
precip rule) and the fallback wiring.
2026-07-16 05:30:56 +00:00
Emi Griffith
4db6760ce9 City pages: keep "Read more →" from wrapping mid-phrase (#114)
The Wikipedia "Read more →" link in the notable-weather section could break
between "Read" and "more" at narrow widths. Join it with non-breaking spaces so
it stays on one line.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-16 05:25:58 +00:00
Emi Griffith
59f6e11694 Month pages: show record high and low for every metric (#113)
The per-month records section listed only the warmest and coldest temperature.
Extend it to a card per metric (High, Low, Feels-like, Humidity, Wind, Gust,
Precip), each showing that calendar month's record high and low with the date.

Precip is special-cased: a per-day record low is just zero, and the dry-streak
helper would bridge year boundaries on month-filtered rows, so the wettest and
driest sides use the month's largest and smallest total accumulation, dated to
the year. Partial current months (< 26 days) are excluded so an unfinished month
can't win "driest" on a fraction of its rainfall.

Reuses the records page's card grid, so it's already vertical on mobile.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-16 05:13:51 +00:00
Emi Griffith
59bc40da5c SEO: show both temperature extremes per metric in month/season records (#112)
The month and season records tables showed only the record high (of the daytime
high) and the record low (of the overnight low) — one extreme each. Show both
ends of both metrics: for the daytime high, its record hottest (▲) and the
coldest a day ever stayed (▼, its record-low high); for the overnight low, its
mildest night (▲, its record-high low) and its record low (▼). All four already
came out of all_time_records; they were just being dropped.

Restructure each table to Period · Daytime high · Overnight low, with each metric
cell carrying two tinted, dated chips (▲ warmest / ▼ coldest). Fits mobile in
three columns; the all-time cards already showed both extremes, so this brings
the per-period tables in line.
2026-07-16 04:57:13 +00:00
Emi Griffith
da94b082c5 SEO: show both temperature extremes per metric in month/season records (#112)
The month and season records tables showed only the record high (of the daytime
high) and the record low (of the overnight low) — one extreme each. Show both
ends of both metrics: for the daytime high, its record hottest (▲) and the
coldest a day ever stayed (▼, its record-low high); for the overnight low, its
mildest night (▲, its record-high low) and its record low (▼). All four already
came out of all_time_records; they were just being dropped.

Restructure each table to Period · Daytime high · Overnight low, with each metric
cell carrying two tinted, dated chips (▲ warmest / ▼ coldest). Fits mobile in
three columns; the all-time cards already showed both extremes, so this brings
the per-period tables in line.
2026-07-16 04:57:13 +00:00
Emi Griffith
b3e7f75e50 City page: range strip spans 10th–90th percentile instead of average (#111)
The monthly temperature-range visualization now spans the 10th-percentile daily low
to the 90th-percentile daily high (from climatology's p10/p90) — the band most days
fall within — instead of just the average low to average high, giving a truer sense
of each month's spread. The normals table keeps the average high/low figures.
2026-07-16 04:50:14 +00:00
Emi Griffith
b3faaae717 Climate hub: client-side search + alphabetical results (#110)
Add a search box to /climate that filters to a flat, alphabetical list of matching
cities (by city or country name), shown with their country; clearing it restores the
collapsible directory. The index is built from the already-crawlable links, so it's
a pure progressive enhancement (no SEO impact). Also sort cities alphabetically
within each country in the directory (were population-ordered).
2026-07-16 04:44:18 +00:00
Emi Griffith
807f26262f Climate hub: client-side search + alphabetical results (#110)
Add a search box to /climate that filters to a flat, alphabetical list of matching
cities (by city or country name), shown with their country; clearing it restores the
collapsible directory. The index is built from the already-crawlable links, so it's
a pure progressive enhancement (no SEO impact). Also sort cities alphabetically
within each country in the directory (were population-ordered).
2026-07-16 04:44:18 +00:00
Emi Griffith
39d4b7f725 SEO: fix season-record wrapping + trim range strip on mobile (#109)
Two mobile polish issues on the climate pages:

- The season records table's "(Dec–Feb)" span overflowed its narrow first
  column (nowrap) and slid under the tinted value cell. Stack the span under the
  season name on phones, top-align the record cells, and let the first column
  wrap — so the period label sits cleanly in its own column.
- The monthly temperature-range strip stretched edge-to-edge with the values
  floating far to the right. Cap the strip width so bars + values pack into a
  tight group, shrink the value labels to fit (compact "41°–66°", no reserved
  92px column). The overrides now sit after the base range rules so they win.
2026-07-16 04:23:22 +00:00
Emi Griffith
0d477427c1 SEO: fix season-record wrapping + trim range strip on mobile (#109)
Two mobile polish issues on the climate pages:

- The season records table's "(Dec–Feb)" span overflowed its narrow first
  column (nowrap) and slid under the tinted value cell. Stack the span under the
  season name on phones, top-align the record cells, and let the first column
  wrap — so the period label sits cleanly in its own column.
- The monthly temperature-range strip stretched edge-to-edge with the values
  floating far to the right. Cap the strip width so bars + values pack into a
  tight group, shrink the value labels to fit (compact "41°–66°", no reserved
  92px column). The overrides now sit after the base range rules so they win.
2026-07-16 04:23:22 +00:00
Emi Griffith
a80f5e21e3 SEO: make month/season records tables mobile-friendly (#108)
The all-time-records redesign (#106) removed the min-width floor that let the
wide records tables scroll, so the month and season records tables — still five
columns (value + separate date, twice) — compressed below their content on
phones and overlapped/clipped.

Fold each record's date under its value so those tables become three columns
(period · record high · record low), which fit any width without horizontal
scroll. Also tighten all climate tables under 560px (smaller font + padding) so
a hot city's 3-digit °F values with °C in parens never collide in the city
page's normals table.
2026-07-16 04:14:19 +00:00
Emi Griffith
d1ec114b4a SEO: make month/season records tables mobile-friendly (#108)
The all-time-records redesign (#106) removed the min-width floor that let the
wide records tables scroll, so the month and season records tables — still five
columns (value + separate date, twice) — compressed below their content on
phones and overlapped/clipped.

Fold each record's date under its value so those tables become three columns
(period · record high · record low), which fit any width without horizontal
scroll. Also tighten all climate tables under 560px (smaller font + padding) so
a hot city's 3-digit °F values with °C in parens never collide in the city
page's normals table.
2026-07-16 04:14:19 +00:00
Emi Griffith
536d9541bd SEO: monthly & seasonal records + colour-coded climate pages (#107)
* SEO: monthly & seasonal records on the city records page

The /climate/<city>/records page showed only all-time records per metric.
Expand it into a full records page: record high/low for each of the 12
months (each linking its month page) and for each meteorological season,
alongside the existing all-time table.

Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities
and summer for southern ones (picked from the city's latitude). Records
reuse grading.all_time_records over a month-filtered archive, so no new
data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD
block and richer title/description for the expanded coverage.

* SEO: colour-code climate & records pages (heat-map + range strip)

The city, records and month pages were plain muted tables — generic climate-site
styling. Bring the interactive grader's diverging cold→hot palette onto them so
they read as heat maps in the site's own visual language:

- Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the
  temperature cells across the monthly-normals, monthly/seasonal/all-time records
  tables. The value stays in the ink token; the tint is a wash behind it. Non-temp
  rows (humidity/wind/precip) and date columns stay untinted.
- Add a monthly temperature-range strip on the city page: one gradient bar per
  month spanning the average low→high on a shared −10..115°F axis, so a city's
  whole-year rhythm (and hot-vs-cold character) reads at a glance.
- Tint the month page's hero high/low and its record values inline.

Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix)
and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
Emi Griffith
4e8d1e9e59 SEO: monthly & seasonal records + colour-coded climate pages (#107)
* SEO: monthly & seasonal records on the city records page

The /climate/<city>/records page showed only all-time records per metric.
Expand it into a full records page: record high/low for each of the 12
months (each linking its month page) and for each meteorological season,
alongside the existing all-time table.

Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities
and summer for southern ones (picked from the city's latitude). Records
reuse grading.all_time_records over a month-filtered archive, so no new
data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD
block and richer title/description for the expanded coverage.

* SEO: colour-code climate & records pages (heat-map + range strip)

The city, records and month pages were plain muted tables — generic climate-site
styling. Bring the interactive grader's diverging cold→hot palette onto them so
they read as heat maps in the site's own visual language:

- Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the
  temperature cells across the monthly-normals, monthly/seasonal/all-time records
  tables. The value stays in the ink token; the tint is a wash behind it. Non-temp
  rows (humidity/wind/precip) and date columns stay untinted.
- Add a monthly temperature-range strip on the city page: one gradient bar per
  month spanning the average low→high on a shared −10..115°F axis, so a city's
  whole-year rhythm (and hot-vs-cold character) reads at a glance.
- Tint the month page's hero high/low and its record values inline.

Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix)
and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
Emi Griffith
30608ff6de Records: color-accented cards, vertical on mobile (#106)
Replace the 5-column records table (which forced horizontal scrolling on mobile —
only high OR low visible) with a responsive card grid: one card per metric, each
with a red-accented 'Highest' row and a blue-accented 'Lowest' row (tier colors).
Grids on desktop, stacks to a single column on mobile with high/low stacked
vertically — no horizontal scroll. Precip reads Wettest / longest dry spell.
2026-07-16 03:37:51 +00:00
Emi Griffith
d24049a78d Records: color-accented cards, vertical on mobile (#106)
Replace the 5-column records table (which forced horizontal scrolling on mobile —
only high OR low visible) with a responsive card grid: one card per metric, each
with a red-accented 'Highest' row and a blue-accented 'Lowest' row (tier colors).
Grids on desktop, stacks to a single column on mobile with high/low stacked
vertically — no horizontal scroll. Precip reads Wettest / longest dry spell.
2026-07-16 03:37:51 +00:00
Emi Griffith
0e895d55d8 Records: show longest dry streak instead of record-low precipitation (#105)
Record-low rainfall is meaningless (always ~0), so the precip row's low side now
shows the longest run of consecutive days without measurable rain and the date that
dry spell began (grading.longest_dry_streak), keeping the wettest day on the high
side. A footnote explains the substitution.
2026-07-16 03:28:26 +00:00
Emi Griffith
5f380bcadd Records: show longest dry streak instead of record-low precipitation (#105)
Record-low rainfall is meaningless (always ~0), so the precip row's low side now
shows the longest run of consecutive days without measurable rain and the date that
dry spell began (grading.longest_dry_streak), keeping the wettest day on the high
side. A footnote explains the substitution.
2026-07-16 03:28:26 +00:00
Emi Griffith
0e16ad2a93 SEO: climate hub — collapse all countries by default, order alphabetically (#104)
Drop the open-by-default on the first three countries (every <details> starts
collapsed) and order the country sections alphabetically instead of by largest
city's population.
2026-07-16 03:22:47 +00:00
Emi Griffith
f0f9c73cac SEO: collapsible climate hub + mobile formatting fixes (#103)
- Climate hub (/climate) now uses native <details> per country — collapsible
  expand/shrink sections with a city count and a rotating marker (top 3 countries
  open by default). Links stay in the DOM, so crawling/SEO is unaffected.
- Fix nested F/C parentheses in the city lede (e.g. 'July (71°F (22°C) average
  high)') by rephrasing so the already-parenthesized temperature isn't wrapped in
  another paren.
- Fix the 5-column records table overlapping on mobile: give it a min-width so it
  scrolls inside .table-wrap on narrow screens instead of colliding.
2026-07-16 03:17:00 +00:00
Emi Griffith
586a7b1aa1 SEO: collapsible climate hub + mobile formatting fixes (#103)
- Climate hub (/climate) now uses native <details> per country — collapsible
  expand/shrink sections with a city count and a rotating marker (top 3 countries
  open by default). Links stay in the DOM, so crawling/SEO is unaffected.
- Fix nested F/C parentheses in the city lede (e.g. 'July (71°F (22°C) average
  high)') by rephrasing so the already-parenthesized temperature isn't wrapped in
  another paren.
- Fix the 5-column records table overlapping on mobile: give it a min-width so it
  scrolls inside .table-wrap on narrow screens instead of colliding.
2026-07-16 03:17:00 +00:00
Emi Griffith
235c910417 SEO: values filter — trim cities in anti-LGBTQ countries, keep notable hubs (#102)
Add EXCLUDE_CC + KEEP_SLUGS to gen_cities.py: drop cities in countries that
criminalize LGBTQ+ people (plus China, Pakistan, Indonesia by choice), except a
hand-kept list of notable / tourist / high-English hubs — Lagos & Abuja; Nairobi &
Mombasa; Cairo, Giza, Alexandria; Dubai & Abu Dhabi; Kuala Lumpur/Malacca/Kota
Kinabalu/Kuching; Dar es Salaam/Zanzibar/Arusha; Casablanca/Fes/Rabat; Tehran;
Jeddah; Baghdad; Kabul; 7 Pakistani cities (Karachi, Lahore, Islamabad, Faisalabad,
Rawalpindi, Multan, Hyderabad); Jakarta + Surabaya + Bekasi; and China's 5 most
English-friendly cities (Shanghai, Beijing, Shenzhen, Guangzhou, Chengdu). Removed
slots backfill from the next-ranked non-excluded cities, so cities.json stays 1000.
Flavor regenerated (prunes removed, fetches backfilled).
2026-07-16 02:20:41 +00:00
Emi Griffith
273ecd0d60 SEO: broaden curated weather events beyond the US/Anglosphere (#101)
Common-sense coverage pass on city_events.py: the list was 10/31 US and heavily
Anglosphere with no Latin America, no Middle East, and only London/Paris for all of
continental Europe. Add 9 verified events filling those gaps — Los Angeles (2025
wildfires), Moscow (2010 heat/wildfires), Madrid (2021 Storm Filomena), Athens (2018
Attica fires), Seoul (2022 floods), Dubai (2024 floods), Jeddah (2009 floods), Sao
Paulo (2014-17 drought), Rio de Janeiro (2010 floods). Every Wikipedia link
verified to resolve. 40 events total; nothing removed (no factual errors found).
2026-07-16 01:42:49 +00:00
Emi Griffith
05a7e3165e SEO: hand-curated notable weather event per city (falls back to blurb) (#100)
Auto-sourcing a notable weather event from Wikipedia search proved unreliable
(wrong matches, low coverage), so city_events.py is a small, fact-checked list of
one iconic event per city (Katrina/New Orleans, Harvey/Houston, the 1952 Great
Smog/London, the 2021 heat dome/Seattle, ...) — 31 cities, each with a Wikipedia
link. The city page shows a 'Notable weather in {city}' callout when one exists and
otherwise just the flavor blurb. Tests check the curated slugs are all valid and the
callout renders (and is absent for uncurated cities).
2026-07-16 01:23:45 +00:00
Emi Griffith
a705c154cd SEO: hand-curated notable weather event per city (falls back to blurb) (#100)
Auto-sourcing a notable weather event from Wikipedia search proved unreliable
(wrong matches, low coverage), so city_events.py is a small, fact-checked list of
one iconic event per city (Katrina/New Orleans, Harvey/Houston, the 1952 Great
Smog/London, the 2021 heat dome/Seattle, ...) — 31 cities, each with a Wikipedia
link. The city page shows a 'Notable weather in {city}' callout when one exists and
otherwise just the flavor blurb. Tests check the curated slugs are all valid and the
callout renders (and is absent for uncurated cities).
2026-07-16 01:23:45 +00:00
Emi Griffith
7adcb3f77b SEO: expand city set to 1000 (extended English-speaking / high-proficiency) (#99)
Add a third tier to gen_cities.py: the top-population cities from the remaining
English-official countries plus countries where >35% speak English (Eurobarometer/
EF), that weren't already chosen. cities.json grows to 1000 (500 global + 250
core-English + 250 extended), pulling in the biggest cities of India, Nigeria,
Pakistan, the Philippines, plus Amsterdam/Stockholm/Nairobi/Tel Aviv, etc.

gen_flavor.py is now incremental (fetches only cities missing a blurb, prunes stale
ones; --full to rebuild); cities_flavor.json refreshed to cover 942/1000.
2026-07-16 01:14:30 +00:00
Emi Griffith
732657b25b SEO: per-city Wikipedia blurbs + travel/compare CTA on city pages (#98)
Add unique editorial content so the programmatic pages don't read as templated:
- gen_flavor.py seeds backend/cities_flavor.json with a short descriptive blurb per
  city from Wikipedia's free REST summary API (no key), validating each match by
  comparing article coordinates to the city's lat/lon so the wrong 'Springfield'
  never attaches. Retries + modest concurrency; ~700/750 cities get a blurb, the
  rest render without one. Text is CC BY-SA, attributed with a 'via Wikipedia' link.
- City pages show the blurb under the intro and a travel callout that deep-links to
  the compare page with the city pre-loaded (/compare#loc=lat,lon) — the visitor
  just adds their own city. Month pages get the same seasonal 'visiting in {month}?'
  compare link. Both add unique per-page text and internal links.
- cities.py gains flavor(slug); tests cover the blurb + attribution + compare CTA.
2026-07-16 00:39:47 +00:00
Emi Griffith
c3a11ee994 SEO: per-city Wikipedia blurbs + travel/compare CTA on city pages (#98)
Add unique editorial content so the programmatic pages don't read as templated:
- gen_flavor.py seeds backend/cities_flavor.json with a short descriptive blurb per
  city from Wikipedia's free REST summary API (no key), validating each match by
  comparing article coordinates to the city's lat/lon so the wrong 'Springfield'
  never attaches. Retries + modest concurrency; ~700/750 cities get a blurb, the
  rest render without one. Text is CC BY-SA, attributed with a 'via Wikipedia' link.
- City pages show the blurb under the intro and a travel callout that deep-links to
  the compare page with the city pre-loaded (/compare#loc=lat,lon) — the visitor
  just adds their own city. Month pages get the same seasonal 'visiting in {month}?'
  compare link. Both add unique per-page text and internal links.
- cities.py gains flavor(slug); tests cover the blurb + attribution + compare CTA.
2026-07-16 00:39:47 +00:00
Emi Griffith
906c0fd8c7 SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.

Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
Emi Griffith
3bbd819d1d SEO: add 250 English-market city pages; auto-warm archives on deploy (#97)
The population-ranked global top-500 skewed to Asian megacities and missed
high-English-search-demand cities. gen_cities.py now tops up with the top ~250
cities from English-speaking countries (US/GB/CA/AU/NZ/IE/ZA) not already in the
global set, so US coverage goes 13->146, GB 2->42, CA 3->29, etc. (Seattle, Boston,
Manchester, Melbourne, Auckland, Dublin, ...). cities.json regenerated to 750.

Both deploy scripts now launch warm_cities.py automatically after the health check,
detached (dev: a systemd --user transient unit; prod: setsid/nohup), so the city
pages serve from cache without a manual step; idempotent, so only the first deploy
does the full warm. DEPLOY.md updated.
2026-07-16 00:11:14 +00:00
Emi Griffith
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
58ac3120b2 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