2026-07-11 00:29:47 +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: 2-year calendar< / title >
< meta name = "description" content = "A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local 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: 2-year calendar" / >
< meta property = "og:description" content = "A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." / >
2026-07-11 15:59:14 +00:00
< meta property = "og:url" content = "__ORIGIN__/calendar" / >
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__/calendar" / >
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 00:29:47 +00:00
< link rel = "stylesheet" href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" / >
< link rel = "stylesheet" href = "style.css" / >
< / head >
< body >
2026-07-11 07:20:13 +00:00
< header class = "compact" >
2026-07-11 00:29:47 +00:00
< 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 00:29:47 +00:00
< / 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 00:29:47 +00:00
< a href = "./" data-view = "map" > Weekly< / a >
< a href = "calendar" data-view = "calendar" class = "active" > 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" > 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 00:29:47 +00:00
< / nav >
2026-07-16 12:15:00 +00:00
< / div >
2026-07-16 05:42:59 +00:00
< / details >
2026-07-11 00:29:47 +00:00
< / div >
< / header >
< main >
< section class = "controls" >
< div class = "find-bar" >
< button type = "button" id = "find-btn" class = "find-btn" > < / button >
< span id = "loc-label" class = "loc-label" hidden > < / span >
< / div >
< / section >
< div id = "cal-head" class = "cal-head" hidden > < / div >
<!-- Time filters: month/year range + season + year, each a compact dropdown. -->
< div id = "cal-filters" class = "cal-filters" hidden >
< div class = "cal-range" id = "cal-range" >
< div class = "cal-range-head" >
< span class = "cal-filter-label" > Range< / span >
< span class = "hint" > longer spans load 2 years at a time< / span >
< / div >
< label > From < input id = "range-start" type = "month" / > < / label >
< label > To < input id = "range-end" type = "month" / > < / label >
< button type = "button" id = "range-refresh" class = "range-refresh" hidden > Refresh dates ↻< / button >
< / div >
< div id = "cal-seasonyear" class = "cal-seasonyear" > < / div >
2026-07-12 07:45:12 +00:00
<!-- A synced copy of the metric selector for the phone sheet; buttons are cloned
from the inline #metric-toggle in JS. Hidden on desktop. -->
< div class = "cal-metric-sheet" >
< span class = "cal-filter-label" > Color the calendar by< / span >
< div class = "metric-toggle" id = "metric-toggle-sheet" role = "group" aria-label = "Color the calendar by (in filters)" > < / div >
< / div >
2026-07-11 00:29:47 +00:00
< / div >
<!-- Metric selector sits below the time filters, right above the grid it colors. -->
< div id = "metric-block" class = "metric-block" hidden >
< span class = "cal-filter-label" > Color the calendar by< / span >
< div class = "metric-toggle" id = "metric-toggle" role = "group" aria-label = "Color the calendar by" >
< 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 >
2026-07-22 19:10:48 +00:00
<!-- Plain - language climatology insights for the selected month(s): typical
highs, rainy-day count, sunshine, and a cross-month rain comparison. -->
< div id = "cal-insights" class = "cal-insights" hidden > < / div >
2026-07-11 00:29:47 +00:00
<!-- Category totals for the current metric across the shown (and weather -
filtered) days: a stacked share bar + per-category percentages. -->
< div id = "cal-totals" class = "cal-totals" hidden > < / div >
< div class = "placeholder" id = "cal-placeholder" >
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
< p > Search a place, or open this from a graded location on the map, to see how
2026-07-11 00:29:47 +00:00
every day of the last two years graded against that spot's own climate history.< / p >
< / div >
< div id = "calendar" class = "calendar" > < / div >
< div id = "cal-key" > < / div >
< div id = "cal-tip" class = "chart-tip" hidden > < / div >
< / main >
< script src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.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 = "calendar.js" > < / script >
2026-07-20 00:40:04 +00:00
< script type = "module" src = "ios-install.js" > < / script >
2026-07-11 00:29:47 +00:00
< / body >
< / html >