thermograph/static/calendar.html
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

106 lines
5.1 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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." />
<meta name="theme-color" content="#f0803c" />
<!-- 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" />
<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." />
<meta property="og:url" content="__ORIGIN__/calendar" />
<meta property="og:image" content="__ORIGIN__/logo.png" />
<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" />
<link rel="canonical" href="__ORIGIN__/calendar" />
<link rel="icon" href="logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="compact">
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Calendar</h1>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar" class="active">Calendar</a>
<a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare">Compare</a>
</nav>
</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>
<!-- 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>
</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>
<!-- 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">
<p>Search a place — or open this from a graded location on the map — to see how
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>
<script type="module" src="calendar.js"></script>
</body>
</html>