2026-07-11 02:58:56 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
< title > Thermograph: compare comfort< / title >
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
< meta name = "description" content = "Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." / >
2026-07-11 15:59:14 +00:00
< meta name = "theme-color" content = "#f0803c" / >
2026-07-20 00:40:04 +00:00
<!-- iOS/Android home - screen install: run standalone (no browser chrome) when
added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
< meta name = "apple-mobile-web-app-capable" content = "yes" / >
< meta name = "mobile-web-app-capable" content = "yes" / >
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" / >
< meta name = "apple-mobile-web-app-title" content = "Thermograph" / >
2026-07-11 15:59:14 +00:00
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
og:image origins in from this request's scheme://host + base path —
crawlers need absolute URLs and don't run JS. -->
< meta property = "og:type" content = "website" / >
< meta property = "og:site_name" content = "Thermograph" / >
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
< meta property = "og:title" content = "Thermograph: compare comfort" / >
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
< meta property = "og:description" content = "Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." / >
2026-07-11 15:59:14 +00:00
< meta property = "og:url" content = "__ORIGIN__/compare" / >
Replace the brand mark with the grid-cell logo everywhere (#199)
Swaps the stepped-path mark for the "C1 v2" design: an 8x8 grid of graded
day-cells tracing the same climb (blue Low -> green Normal -> a hot loop -> a
stepped ascent to the near-record red cell). It keeps the exact geometry of the
old mark — same 512 viewBox, same 32/448/rx96 rounded badge, same band palette —
so it is a drop-in that needs no alignment changes.
Everywhere the mark appears:
- The inline header SVG in all six lockups (the five standalone frontend pages
and backend/templates/base.html.j2), kept at width/height 28 with aria-hidden.
Because the box and viewBox are unchanged, the .logo/.brand alignment CSS
(the -2px cap-height nudge, the 28->22px compact size, the 10px gap) is
untouched and the mark stays centred on the "Thermograph" wordmark.
- favicon.svg, regenerated from the new art.
- Every raster: favicon-16/32/48, apple-touch-icon (180), logo-192, logo (512),
and the two maskable icons. The maskable pair keeps the established recipe —
the badge scaled into the central safe zone on a full-bleed opaque square — so
an OS shape mask never clips the art.
Bumps every icon reference to ?v=3 (previously a mix of ?v=2 and unversioned) so
browsers and installed PWAs refetch the new icons.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 23:45:12 +00:00
< meta property = "og:image" content = "__ORIGIN__/logo.png?v=3" / >
2026-07-11 15:59:14 +00:00
< meta property = "og:image:width" content = "512" / >
< meta property = "og:image:height" content = "512" / >
< meta property = "og:image:alt" content = "Thermograph logo" / >
< meta name = "twitter:card" content = "summary" / >
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
< link rel = "canonical" href = "__ORIGIN__/compare" / >
Replace the brand mark with the grid-cell logo everywhere (#199)
Swaps the stepped-path mark for the "C1 v2" design: an 8x8 grid of graded
day-cells tracing the same climb (blue Low -> green Normal -> a hot loop -> a
stepped ascent to the near-record red cell). It keeps the exact geometry of the
old mark — same 512 viewBox, same 32/448/rx96 rounded badge, same band palette —
so it is a drop-in that needs no alignment changes.
Everywhere the mark appears:
- The inline header SVG in all six lockups (the five standalone frontend pages
and backend/templates/base.html.j2), kept at width/height 28 with aria-hidden.
Because the box and viewBox are unchanged, the .logo/.brand alignment CSS
(the -2px cap-height nudge, the 28->22px compact size, the 10px gap) is
untouched and the mark stays centred on the "Thermograph" wordmark.
- favicon.svg, regenerated from the new art.
- Every raster: favicon-16/32/48, apple-touch-icon (180), logo-192, logo (512),
and the two maskable icons. The maskable pair keeps the established recipe —
the badge scaled into the central safe zone on a full-bleed opaque square — so
an OS shape mask never clips the art.
Bumps every icon reference to ?v=3 (previously a mix of ?v=2 and unversioned) so
browsers and installed PWAs refetch the new icons.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 23:45:12 +00:00
< link rel = "icon" href = "favicon.svg?v=3" type = "image/svg+xml" / >
< link rel = "icon" href = "favicon-48.png?v=3" type = "image/png" sizes = "48x48" / >
< link rel = "icon" href = "favicon-32.png?v=3" type = "image/png" sizes = "32x32" / >
< link rel = "icon" href = "favicon-16.png?v=3" type = "image/png" sizes = "16x16" / >
< link rel = "apple-touch-icon" href = "apple-touch-icon.png?v=3" / >
2026-07-15 23:21:06 +00:00
< link rel = "manifest" href = "manifest.webmanifest" / >
2026-07-11 02:58:56 +00:00
< link rel = "stylesheet" href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" / >
< link rel = "stylesheet" href = "style.css" / >
< / head >
< body >
< header >
< div class = "brand" >
< div >
Replace the brand mark with the grid-cell logo everywhere (#199)
Swaps the stepped-path mark for the "C1 v2" design: an 8x8 grid of graded
day-cells tracing the same climb (blue Low -> green Normal -> a hot loop -> a
stepped ascent to the near-record red cell). It keeps the exact geometry of the
old mark — same 512 viewBox, same 32/448/rx96 rounded badge, same band palette —
so it is a drop-in that needs no alignment changes.
Everywhere the mark appears:
- The inline header SVG in all six lockups (the five standalone frontend pages
and backend/templates/base.html.j2), kept at width/height 28 with aria-hidden.
Because the box and viewBox are unchanged, the .logo/.brand alignment CSS
(the -2px cap-height nudge, the 28->22px compact size, the 10px gap) is
untouched and the mark stays centred on the "Thermograph" wordmark.
- favicon.svg, regenerated from the new art.
- Every raster: favicon-16/32/48, apple-touch-icon (180), logo-192, logo (512),
and the two maskable icons. The maskable pair keeps the established recipe —
the badge scaled into the central safe zone on a full-bleed opaque square — so
an OS shape mask never clips the art.
Bumps every icon reference to ?v=3 (previously a mix of ?v=2 and unversioned) so
browsers and installed PWAs refetch the new icons.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 23:45:12 +00:00
< h1 > < a href = "./" > < span class = "logo" > < svg viewBox = "0 0 512 512" width = "28" height = "28" aria-hidden = "true" > < rect x = "32" y = "32" width = "448" height = "448" rx = "96" fill = "#10141B" stroke = "#FFFFFF" stroke-opacity = "0.08" stroke-width = "4" / > < rect x = "115" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "163" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "211" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "259" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "307" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "355" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "403" y = "403" width = "42" height = "42" rx = "10" fill = "#111924" / > < rect x = "163" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "211" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "259" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "307" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "355" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "403" y = "355" width = "42" height = "42" rx = "10" fill = "#131C25" / > < rect x = "67" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "163" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "211" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "259" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "307" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "355" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "403" y = "307" width = "42" height = "42" rx = "10" fill = "#181F27" / > < rect x = "67" y = "259" width = "42" height = "42" rx = "10" fill = "#131C1F" / > < rect x = "211" y = "259" width = "42" height = "42" rx = "10" fill = "#131C1F" / > < rect x = "403" y = "259" width = "42" height = "42" rx = "10" fill = "#131C1F" / > < rect x = "67" y = "211" width = "42" height = "42" rx = "10" fill = "#1E1E22" / > < rect x = "115" y = "211" width = "42" height = "42" rx = "10" fill = "#1E1E22" / > < rect x = "211" y = "211" width = "42" height = "42" rx = "10" fill = "#1E1E22" / > < rect x = "307" y = "211" width = "42" height = "42" rx = "10" fill = "#1E1E22" / > < rect x = "67" y = "163" width = "42" height = "42" rx = "10" fill = "#1D1C1E" / > < rect x = "115" y = "163" width = "42" height = "42" rx = "10" fill = "#1D1C1E" / > < rect x = "307" y = "163" width = "42" height = "42" rx = "10" fill = "#1D1C1E" / > < rect x = "355" y = "163" width = "42" height = "42" rx = "10" fill = "#1D1C1E" / > < rect x = "67" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "115" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "163" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "211" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "259" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "307" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "355" y = "115" width = "42" height = "42" rx = "10" fill = "#1C191E" / > < rect x = "67" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "115" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "163" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "211" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "259" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "307" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "355" y = "67" width = "42" height = "42" rx = "10" fill = "#18141B" / > < rect x = "67" y = "403" width = "42" height = "42" rx = "10" fill = "#2166ac" / > < rect x = "67" y = "355" width = "42" height = "42" rx = "10" fill = "#4393c3" / > < rect x = "115" y = "355" width = "42" height = "42" rx = "10" fill = "#4393c3" / > < rect x = "115" y = "307" width = "42" height = "42" rx = "10" fill = "#92c5de" / > < rect x = "115" y = "259" width = "42" height = "42" rx = "10" fill = "#4a9d5b" / > < rect x = "163" y = "259" width = "42" height = "42" rx = "10" fill = "#4a9d5b" / > < rect x = "163" y = "211" width = "42" height = "42" rx = "10" fill = "#f6c18a" / > < rect x = "163" y = "163" width = "42" height = "42" rx = "10" fill = "#ef9351" / > < rect x = "211" y = "163" width = "42" height = "42" rx = "10" fill = "#ef9351" / > < rect x = "259" y = "163" width = "42" height = "42" rx = "10" fill = "#ef9351" / > < rect x = "259" y = "211" width = "42" height = "42" rx = "10" fill = "#f6c18a" / > < rect x =
2026-07-11 02:58:56 +00:00
< p class = "tag" > Which place best hits your comfort temperature over a date range< / p >
< / div >
2026-07-16 05:42:59 +00:00
< details class = "nav-menu" >
< summary class = "nav-toggle" aria-label = "Menu" > < svg viewBox = "0 0 24 24" width = "22" height = "22" aria-hidden = "true" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M4 7h16M4 12h16M4 17h16" / > < / svg > < / summary >
2026-07-16 12:15:00 +00:00
< div class = "nav-panel" >
2026-07-16 05:42:59 +00:00
< nav class = "view-nav" aria-label = "Views" >
2026-07-11 02:58:56 +00:00
< a href = "./" data-view = "map" > Weekly< / a >
< a href = "calendar" data-view = "calendar" > Calendar< / a >
2026-07-16 12:34:04 +00:00
< a href = "day" data-view = "day" > Day Detail< / a >
2026-07-11 02:58:56 +00:00
< a href = "compare" data-view = "compare" class = "active" > Compare< / a >
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
< a href = "score" data-view = "score" > Score< / a >
2026-07-16 06:01:58 +00:00
< a href = "climate" > Climate< / a >
2026-07-16 23:41:24 +00:00
< a href = "alerts" > Alerts< / a >
2026-07-11 02:58:56 +00:00
< / nav >
2026-07-16 12:15:00 +00:00
< / div >
2026-07-16 05:42:59 +00:00
< / details >
2026-07-11 02:58:56 +00:00
< / div >
< / header >
< main >
<!-- Locations to line up against each other. -->
< section class = "controls" >
< div class = "cmp-locs" >
< span class = "cal-filter-label" > Locations< / span >
< ul id = "cmp-loc-list" class = "cmp-loc-list" > < / ul >
2026-07-11 03:17:17 +00:00
< div class = "cmp-loc-actions" >
< button type = "button" id = "cmp-add" class = "find-btn" > < / button >
< button type = "button" id = "cmp-refresh" class = "cmp-refresh-btn" hidden > Refresh comparison ↻< / button >
< / div >
2026-07-11 02:58:56 +00:00
< / div >
< / section >
<!-- The comfort target, what temperature to judge by, the tolerance band, and
the date range. Comfort/band/basis recompute instantly (no refetch); only
the date range or the location set trigger a data load. -->
2026-07-12 07:11:21 +00:00
<!-- Comfort/tolerance target — the primary knob, kept inline above the results
(never inside the mobile filter sheet). Recomputes the ranking instantly. -->
< div id = "cmp-comfort" class = "cmp-comfort-wrap" hidden >
< div class = "cmp-range-labels" >
< span class = "cmp-rl cmp-rl-comfort" > Comfort < b id = "cmp-comfort-val" > < / b > < / span >
< span class = "cmp-rl cmp-rl-tol" > Tolerance < b id = "cmp-tol-val" > < / b > < / span >
< / div >
< div class = "cmp-ticks" id = "cmp-ticks" aria-hidden = "true" >
< span class = "cmp-tick tol" id = "cmp-tick-tlo" > < / span >
< span class = "cmp-tick" id = "cmp-tick-lo" > < / span >
< span class = "cmp-tick" id = "cmp-tick-hi" > < / span >
< span class = "cmp-tick tol" id = "cmp-tick-thi" > < / span >
< / div >
< div class = "dual-range quad-range" id = "cmp-slicer" >
< div class = "dual-track" >
< span class = "dual-fill dual-fill-tol" id = "cmp-fill-tol" > < / span >
< span class = "dual-fill" id = "cmp-fill" > < / span >
2026-07-11 22:33:38 +00:00
< / div >
2026-07-12 07:11:21 +00:00
< input id = "cmp-tlo" type = "range" min = "30" max = "100" step = "1" class = "thumb-tol" aria-label = "Tolerance range lower bound" / >
< input id = "cmp-lo" type = "range" min = "30" max = "100" step = "1" aria-label = "Comfort range lower bound" / >
< input id = "cmp-hi" type = "range" min = "30" max = "100" step = "1" aria-label = "Comfort range upper bound" / >
< input id = "cmp-thi" type = "range" min = "30" max = "100" step = "1" class = "thumb-tol" aria-label = "Tolerance range upper bound" / >
2026-07-11 02:58:56 +00:00
< / div >
2026-07-12 07:11:21 +00:00
< span class = "hint" > Inner handles set your comfort range; outer handles set the tolerance range. Days in tolerance still count toward the score, scaled by how far past comfort they fall.< / span >
2026-07-12 07:45:12 +00:00
<!-- Which temperature to judge each day by — a scoring control, so it sits with
the comfort slider above the results, not in the filter sheet. -->
< div class = "cmp-basis-block" >
2026-07-11 02:58:56 +00:00
< span class = "cal-filter-label" > Judge each day by its< / span >
< div class = "metric-toggle" id = "cmp-basis" role = "group" aria-label = "Temperature to compare" >
< button type = "button" data-basis = "tmax" class = "active" > High< / button >
< button type = "button" data-basis = "mean" > Mean< / button >
< button type = "button" data-basis = "tmin" > Low< / button >
< button type = "button" data-basis = "feels" > Feels< / button >
< / div >
< / div >
2026-07-12 07:45:12 +00:00
< / div >
2026-07-11 02:58:56 +00:00
2026-07-12 07:45:12 +00:00
< div id = "cmp-params" class = "cmp-params" hidden >
2026-07-12 05:33:09 +00:00
< div class = "cmp-param cmp-season" id = "cmp-season" >
< span class = "cal-filter-label" > Time of year< / span >
< div class = "cmp-season-dd" id = "cmp-season-dd" > < / div >
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
< span class = "hint" > Count only days in these seasons/months; re-ranks instantly.< / span >
2026-07-12 05:33:09 +00:00
< / div >
2026-07-11 02:58:56 +00:00
< div class = "cmp-param cmp-range cal-range" id = "cmp-range" >
< div class = "cal-range-head" >
< span class = "cal-filter-label" > Date range< / span >
< span class = "hint" > any span; longer loads take a moment< / span >
< / div >
< label > From < input id = "cmp-start" type = "month" / > < / label >
< label > To < input id = "cmp-end" type = "month" / > < / label >
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
< span class = "hint" > Editing the range arms Load; tap it to reload.< / span >
2026-07-12 06:32:10 +00:00
< button type = "button" id = "cmp-refresh-sheet" class = "cmp-refresh-btn" hidden > Load / Refresh ↻< / button >
2026-07-11 02:58:56 +00:00
< / div >
2026-07-12 07:45:12 +00:00
<!-- A synced copy of the "Distribution by" selector (below), for the phone
sheet; buttons are cloned from the inline one in JS. Hidden on desktop. -->
< div class = "cmp-param cmp-dist-metric-block" >
< span class = "cal-filter-label" > Distribution by< / span >
< div class = "metric-toggle" id = "cmp-dist-metric-sheet" role = "group" aria-label = "Distribution metric (in filters)" > < / div >
< / div >
2026-07-11 02:58:56 +00:00
< / div >
< div id = "cmp-head" class = "cal-head" hidden > < / div >
< div class = "placeholder" id = "cmp-placeholder" >
< p > Add two or more places to see which one best matches your comfort temperature
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
across a date range: the share of days that land in your comfort band, how
2026-07-11 02:58:56 +00:00
often it runs colder or warmer, and by how much.< / p >
< / div >
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
<!-- Baseline: every place's typical temperature on one shared axis, so their
baselines line up against each other and the shaded comfort range. -->
< section id = "cmp-baseline" class = "cmp-baseline" hidden > < / section >
2026-07-11 02:58:56 +00:00
< div id = "cmp-results" class = "cmp-results" > < / div >
2026-07-11 22:33:38 +00:00
<!-- Climate distribution, independent of the comfort filter: how each place's
days spread across a metric's Record-Low→Record-High categories, mirroring
the calendar's totals strip. Its own metric + share/count toggles. -->
< section id = "cmp-dist" class = "cmp-dist" hidden >
< div class = "ct-head" >
< div class = "cmp-dist-controls" >
< span class = "cal-filter-label" > Distribution by< / span >
< div class = "metric-toggle" id = "cmp-dist-metric" role = "group" aria-label = "Distribution metric" >
< button type = "button" data-metric = "tmax" class = "active" > High< / button >
< button type = "button" data-metric = "tmin" > Low< / button >
< button type = "button" data-metric = "feels" > Feels< / button >
< button type = "button" data-metric = "humid" > Humid< / button >
< button type = "button" data-metric = "wind" > Wind< / button >
< button type = "button" data-metric = "gust" > Gust< / button >
< button type = "button" data-metric = "precip" > Precip< / button >
< button type = "button" data-metric = "dsr" > Dry streak< / button >
< / div >
< / div >
< label class = "ct-count-toggle" > < input type = "checkbox" id = "cmp-dist-count" / > Show count< / label >
< / div >
< div id = "cmp-dist-body" class = "cmp-dist-body" > < / div >
< / section >
2026-07-11 02:58:56 +00:00
< / main >
< script src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" > < / script >
UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.
Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.
Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.
Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 22:49:55 +00:00
<!-- Product - event beacon. Inert unless the server flagged this page
on the root element; see frontend/static/track.js. -->
< script type = "module" src = "track.js" > < / script >
Convert the frontend to ES modules; split nav.js by concern (#48)
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).
2026-07-11 20:28:33 +00:00
< script type = "module" src = "compare.js" > < / script >
2026-07-20 00:40:04 +00:00
< script type = "module" src = "ios-install.js" > < / script >
2026-07-11 02:58:56 +00:00
< / body >
< / html >