thermograph/app.py
Emi Griffith 46c90914b3 Warm neighbor cells server-side (/cell?neighbors=1) (#51)
cache.js contained a JavaScript clone of backend/grid.py's snapping math
(its own comment said so) to compute the 8 surrounding cells and fire 8
staggered prefetch requests — grid geometry had two homes, one per
language, plus a client-side guess at Nominatim pacing.

The server now owns it: grid.neighbors(cell) steps one cell width from
the center and re-snaps (adjacent rows have different longitude steps;
poles and the antimeridian handled by snap), and /api/v2/cell grew a
neighbors=1 flag that enqueues those cells for a single background
worker. The warm-only guarantee matches prefetch=1 — a cell with no
cached archive is skipped, so no weather-API quota is ever spent — and
reverse_geocode's own lock paces the at-most-one Nominatim call per
never-labeled cell. Re-enqueues are TTL-deduped; the worker starts from
the lifespan hook, so tests and offline importers never spawn it.

The client now sends its one conditional bundle request with
neighbors=1 (a warm spot costs an empty 304) instead of skipping the
bundle and firing 8 extra requests; the grid-math clone and the
now-unused hasFreshCache are deleted.

Tests (114): grid.neighbors mid-latitude/pole/antimeridian, the
neighbors=1 enqueue + TTL dedupe, _warm_cell materializing the
history-derived store rows, and the never-fetch-upstream guarantee.
Verified with the headless-Chromium smoke across all five pages.
2026-07-11 20:47:31 +00:00

582 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Thermograph API — grade recent local weather against ~45 years of climatology."""
import contextlib
import datetime
import functools
import hashlib
import json
import os
import queue
import threading
import time
import pandas as pd
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
import audit
import climate
import grid
import places
import store
import views
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
# Everything (pages, assets, API) is served under this base path so the app can
# live at https://<host>/thermograph/ behind a shared domain. Override with the
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
# this automatically without hardcoding the prefix.
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
def _weather_fetch_error(e) -> HTTPException:
"""A clean, retryable 503 when weather data is temporarily unavailable; the
raw error as a 502 otherwise. The fetch layer classifies its own failures
(climate.WeatherUnavailable carries the user-facing text) so no message
parsing happens here; the is_rate_limit fallback covers a raw upstream 429
from a fetcher that didn't classify it."""
if isinstance(e, climate.WeatherUnavailable):
return HTTPException(status_code=503, detail=str(e))
if climate.is_rate_limit(e):
return HTTPException(status_code=503, detail=climate.limit_message(False))
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
# --- background neighbor warming ---------------------------------------------
# /cell?neighbors=1 asks the server to warm the 8 grid cells around the request
# (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's
# snapping math in JS). One worker drains the queue so warms never stack; the
# hard guarantee matches prefetch=1 — a cell with no cached archive is skipped,
# so no weather-API quota is ever spent, and reverse_geocode itself paces the
# at-most-one Nominatim call per never-labeled cell (~1/s policy).
_WARM_TTL = 3600.0 # don't re-enqueue a cell within the hour
_WARM_SEEN: dict[str, float] = {} # cell_id -> last enqueue time
_warm_queue: "queue.Queue[dict]" = queue.Queue()
def _warm_cell(cell: dict) -> None:
"""Materialize the history-derived slices for one cell — the same rows the
prefetch=1 bundle serves. Never fetches weather upstream."""
history = climate.load_cached_history(cell)
if history is None or history.empty:
return
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
token = views.history_token(history)
start_ts, end_ts = views.cal_span(history, None, None, 24)
cal_key = views.calendar_key(start_ts, end_ts, 24)
if store.get_payload("calendar", cell["id"], cal_key, token) is None:
store.put_payload("calendar", cell["id"], cal_key, token,
views.build_calendar(cell, history, start_ts, end_ts, 24, place))
last = pd.Timestamp(history["date"].max()).normalize()
day_key = views.day_key(last)
if store.get_payload("day", cell["id"], day_key, token) is None:
store.put_payload("day", cell["id"], day_key, token,
views.build_day(cell, history, last, place))
def _enqueue_neighbor_warming(cell: dict) -> None:
now = time.time()
for n in grid.neighbors(cell):
if now - _WARM_SEEN.get(n["id"], 0.0) < _WARM_TTL:
continue
_WARM_SEEN[n["id"]] = now
_warm_queue.put(n)
def _neighbor_warmer() -> None:
while True:
cell = _warm_queue.get()
try:
_warm_cell(cell)
except Exception: # noqa: BLE001 - warming is best-effort, never fatal
pass
finally:
_warm_queue.task_done()
@contextlib.asynccontextmanager
async def _lifespan(app):
# Warm the local place-name index (/suggest's typo tolerance) in the
# background; the app boots and serves fine without it — suggestions just
# fall back to the upstream geocoder until it's ready. A server-startup
# hook (not import time) so offline importers (tests, the migrate script's
# dependencies) don't kick off a GeoNames download. The neighbor warmer is
# also a server concern: enqueues before startup just wait in the queue.
places.start_loading()
threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start()
yield
app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan)
# Compress every sizeable response (the 2-year calendar JSON shrinks ~6-8×).
# Applies to API JSON and static assets alike; tiny responses are left alone.
app.add_middleware(GZipMiddleware, minimum_size=1024)
@app.middleware("http")
async def revalidate_static(request, call_next):
"""Serve the frontend with no-cache (NOT no-store): browsers may keep a copy
but must revalidate it on every use. Starlette's FileResponse/StaticFiles
already emit ETag + Last-Modified, so an unchanged asset costs one conditional
request answered with an empty 304 instead of a full re-download — page-to-page
navigation stops re-transferring the JS/CSS/HTML while still picking up every
deploy immediately (no "my change isn't showing" bugs)."""
response = await call_next(request)
path = request.url.path
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend")
if path.endswith((".js", ".css", ".html")) or path in pages:
response.headers["Cache-Control"] = "no-cache"
return response
# --- derived-store plumbing --------------------------------------------------
# Every graded payload is cached in SQLite under (kind, cell, key) and validated
# by a token that encodes what it was computed from (see store.py). The freshness
# drivers stay where they were — climate.get_history tops up the archive tail
# hourly and get_recent_forecast refetches hourly — and the tokens are derived
# from what those return, so a cached payload expires exactly when its inputs
# change and never before. The same tokens double as ETags: a client sending
# If-None-Match gets an empty 304 without the payload even being loaded.
# The payloads themselves are assembled in views.py, shared with the offline
# migrate script.
def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str:
"""Deterministic weak ETag from a payload's identity + validity token. Weak
because the same content may be served under different encodings (gzip)."""
h = hashlib.sha1(f"{kind}:{cell_id}:{key}:{token}".encode()).hexdigest()[:20]
return f'W/"{h}"'
def _not_modified(request: Request, etag: str) -> bool:
"""Does the client already hold this exact payload? Answerable from the token
alone — no payload load needed."""
inm = request.headers.get("if-none-match")
if not inm:
return False
tags = {t.strip() for t in inm.split(",")}
return "*" in tags or etag in tags or etag.removeprefix("W/") in tags
def _encode(payload: dict) -> bytes:
"""Compact JSON bytes, matching store.put_payload's encoding (allow_nan=False
mirrors Starlette — a NaN from grading should fail loudly, not reach a client)."""
return json.dumps(payload, default=str, allow_nan=False, separators=(",", ":")).encode()
def _json_response(body: bytes, etag: str | None = None) -> Response:
headers = {"ETag": etag} if etag else {}
return Response(content=body, media_type="application/json", headers=headers)
def _fetch_history(run, cell, recent_too=False, recent_phase="recent"):
"""The shared fetch preamble for every data route: the archive record (and
optionally the hourly recent/forecast bundle) with upstream failures mapped
to clean HTTP errors and an empty record to a 404."""
try:
with run.phase("history"):
history, cache_meta = climate.get_history(cell)
recent = None
if recent_too:
with run.phase(recent_phase):
recent = climate.get_recent_forecast(cell)
except Exception as e: # noqa: BLE001
raise _weather_fetch_error(e)
if history.empty:
raise HTTPException(status_code=404, detail="No historical data for this cell.")
return history, cache_meta, recent
def _cached_response(request, run, kind, cell, key, token, build, cache_placeless=True):
"""The shared derived-store flow behind every per-view endpoint:
If-None-Match -> empty 304; a token-valid store row -> replay its exact
bytes; otherwise resolve the place label, build the payload, persist, serve.
``build(place) -> payload`` does any endpoint-specific audit tagging itself.
``cache_placeless=False`` skips persisting a payload whose place label
failed to resolve (a transient reverse-geocode miss) — otherwise the
bare-coordinates fallback would stick for the life of the token."""
cid = cell["id"]
etag = _etag_for(kind, cid, key, token)
if _not_modified(request, etag):
run.set(run_type="cache", not_modified=True)
return Response(status_code=304, headers={"ETag": etag})
body = store.get_payload(kind, cid, key, token)
if body is not None:
run.set(run_type="cache", history_source="store")
return _json_response(body, etag)
with run.phase("reverse_geocode"):
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
payload = build(place)
return _json_response(
store.put_payload(kind, cid, key, token, payload,
cache=cache_placeless or place is not None), etag)
# --- endpoints ----------------------------------------------------------------
def api_geocode(q: str = Query(..., min_length=1)):
try:
return {"results": climate.geocode(q)}
except Exception as e: # noqa: BLE001 - surface upstream failures to the client
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
_SUGGEST_LIMIT = 5
@functools.lru_cache(maxsize=1024)
def _suggest_upstream(q: str) -> tuple:
"""Open-Meteo lookup for /suggest, memoized per query string — type-ahead
re-asks the same prefixes constantly (backspacing, retyping). Failures
raise and are not cached, so a transient upstream error doesn't stick."""
return tuple(climate.geocode(q, count=_SUGGEST_LIMIT))
def api_suggest(q: str = Query(..., min_length=1)):
"""Type-ahead location suggestions: the top 5 places for a (possibly
typo'd) query prefix; `corrected` reports the respelling that produced the
results ("pest seattle""west seattle"), or null. The ranking/typo policy
lives in places.suggest."""
try:
results, corrected = places.suggest(q, _suggest_upstream, _SUGGEST_LIMIT)
except Exception as e: # noqa: BLE001 - nothing at all to serve
raise HTTPException(status_code=502, detail=f"geocoding failed: {e}")
return {"results": results, "corrected": corrected}
def api_place(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Best-effort neighbourhood/city label for a point — the same string the view
endpoints expose as ``place`` (snapped to the grid cell, reverse-geocoded and
cached). Resolved on its own so the compare page can show a location's name as
soon as it's added, before its full series loads."""
cell = grid.snap(lat, lon)
with audit.RunAudit(endpoint="place", lat=round(lat, 4), lon=round(lon, 4),
cell_id=cell["id"]) as run:
with run.phase("reverse_geocode"):
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
return {"place": place,
"cell": {"center_lat": cell["center_lat"], "center_lon": cell["center_lon"]}}
def api_grade(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
date: str | None = Query(None, description="target date YYYY-MM-DD (default today)"),
days: int = Query(14, ge=1, le=60, description="days of history to grade before the target"),
after: int = Query(14, ge=0, le=14,
description="days to grade after the target (observed, or forecast when future; "
"the forecast reaches ~7 days out so future days cap there)"),
):
target = (
pd.Timestamp(date).normalize()
if date
else pd.Timestamp(datetime.date.today())
)
cell = grid.snap(lat, lon)
with audit.RunAudit(
endpoint="grade",
lat=round(lat, 4),
lon=round(lon, 4),
target_date=target.date().isoformat(),
recent_days=days,
cell_id=cell["id"],
) as run:
history, cache_meta, recent = _fetch_history(run, cell, recent_too=True)
return _cached_response(
request, run, "grade", cell,
views.grade_key(target, days, after), views.recent_token(history, cell["id"]),
lambda place: views.build_grade(cell, target, days, history, recent,
cache_meta, place, run, after=after))
def api_calendar(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
start: str | None = Query(None, description="first date YYYY-MM-DD (overrides months)"),
end: str | None = Query(None, description="last date YYYY-MM-DD (default = latest available)"),
months: int = Query(24, ge=1, le=24, description="months back to grade when no start given"),
):
"""Grade every day over a date range (default: last 2 years) for the calendar view.
A custom [start, end] range is supported and capped at ~2 years per request;
the frontend splits longer spans into successive 2-year chunks. Grades against
the already-cached history record (which reaches to ~6 days ago), so no extra
fetch is needed. The graded payload is cached in the derived store keyed by
the clamped span and validated by the record's end date, so a repeat span is
a database read — across restarts — until new archive days arrive. Returns a
compact per-day shape (see grading.grade_range). New in API v2.
"""
cell = grid.snap(lat, lon)
with audit.RunAudit(
endpoint="calendar",
lat=round(lat, 4),
lon=round(lon, 4),
recent_days=months * 31, # approximate span graded
cell_id=cell["id"],
) as run:
history, cache_meta, _ = _fetch_history(run, cell)
start_ts, end_ts = views.cal_span(history, start, end, months)
def build(place):
payload = views.build_calendar(cell, history, start_ts, end_ts, months, place, run)
full = not cache_meta.get("cached", False)
run.set(run_type="full" if full else "partial",
history_source="fetch" if full else "cache")
return payload
# Compare loads several cells at once, so a transient reverse-geocode miss
# is most likely here; cache_placeless=False keeps that miss un-persisted.
return _cached_response(request, run, "calendar", cell,
views.calendar_key(start_ts, end_ts, months),
views.history_token(history), build,
cache_placeless=False)
def api_day(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
date: str | None = Query(None, description="target date YYYY-MM-DD (default = latest available)"),
):
"""Full percentile breakdown for a single day: the value at every tier boundary
in that day-of-year's ±7-day window, plus where the observed values land.
Powers the single-day detail page. Grades against the cached history record;
for a date newer than the cache it pulls the recent window for the observation
(those payloads expire hourly — the recent bundle's own cadence — while fully
archived days stay valid until the record itself advances). New in API v2.
"""
cell = grid.snap(lat, lon)
with audit.RunAudit(
endpoint="day",
lat=round(lat, 4),
lon=round(lon, 4),
cell_id=cell["id"],
) as run:
history, cache_meta, _ = _fetch_history(run, cell)
last = pd.Timestamp(history["date"].max()).normalize()
target = pd.Timestamp(date).normalize() if date else last
run.set(target_date=target.date().isoformat())
def build(place):
payload = views.build_day(cell, history, target, place, run)
full = not cache_meta.get("cached", False)
run.set(run_type="full" if full else "partial",
history_source="fetch" if full else "cache")
return payload
return _cached_response(request, run, "day", cell,
views.day_key(target), views.day_token(history, target),
build)
def api_forecast(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
days: int = Query(7, ge=1, le=14, description="how many forecast days ahead to grade"),
):
"""Grade the next `days` of forecast weather against local climatology.
Same shape as /grade (so the frontend renders it identically), but `recent`
holds the forward forecast — furthest-out day first. The forecast is fetched
fresh and cached only ~1 hour (see climate.get_recent_forecast) to track
updates; the graded payload's validity is tied to that fetch stamp, so it
expires exactly when a new forecast lands. New in API v2.
"""
cell = grid.snap(lat, lon)
with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4),
recent_days=days, cell_id=cell["id"]) as run:
history, _, fc = _fetch_history(run, cell, recent_too=True, recent_phase="forecast")
today = pd.Timestamp(datetime.date.today())
def build(place):
payload = views.build_forecast(cell, days, history, fc, today, place, run)
run.set(run_type="partial")
return payload
return _cached_response(request, run, "forecast", cell,
views.forecast_key(today, days),
views.recent_token(history, cell["id"]), build)
def api_cell(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
prefetch: int = Query(0, ge=0, le=1,
description="1 = warm-only: never fetch weather upstream; 204 for a cold cell"),
neighbors: int = Query(0, ge=0, le=1,
description="1 = also warm the 8 surrounding cells in the background "
"(warm-only: cold neighbors are skipped, no upstream quota)"),
):
"""One bundle carrying every view's payload for a cell, so the frontend warms
all views with a single request instead of four.
Each slice is the EXACT payload its per-view endpoint returns — built by the
same builders and cached under the same derived-store keys/tokens — paired
with the etag that endpoint would emit. The client seeds its per-view cache
from the slices and later revalidates each view individually with
If-None-Match, so the bundle and the per-view endpoints stay one cache.
prefetch=1 is the neighbor-warming mode with a hard guarantee: it never
spends weather-API quota. A cell with no cached archive answers 204 (no
body), and only the history-derived slices (calendar + latest-day detail)
are built — grading recent/forecast days needs the hourly upstream bundle.
Reverse geocoding makes at most one Nominatim call for a never-labeled cell;
the client staggers neighbor prefetches to respect that service. New in API v2.
"""
cell = grid.snap(lat, lon)
today = pd.Timestamp(datetime.date.today())
with audit.RunAudit(endpoint="cell", lat=round(lat, 4), lon=round(lon, 4),
cell_id=cell["id"], prefetch=bool(prefetch)) as run:
recent = None
if prefetch:
history = climate.load_cached_history(cell)
if history is None or history.empty:
run.set(run_type="cold-skip")
return Response(status_code=204)
cache_meta = {"cached": True}
else:
history, cache_meta, recent = _fetch_history(run, cell, recent_too=True)
cid = cell["id"]
last = pd.Timestamp(history["date"].max()).normalize()
if neighbors:
_enqueue_neighbor_warming(cell)
with run.phase("reverse_geocode"):
place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"])
def slice_for(kind: str, key: str, token: str, build) -> dict:
"""The endpoint's derived row (or build + store it), paired with the
etag that endpoint would emit for the same request."""
etag = _etag_for(kind, cid, key, token)
data = store.get_json(kind, cid, key, token)
if data is None:
data = build()
store.put_payload(kind, cid, key, token, data)
return {"etag": etag, "data": data}
slices = {}
hist_token = views.history_token(history)
# Calendar: the default last-24-months span — what the calendar view (and
# the cross-view prefetch) requests first.
start_ts, end_ts = views.cal_span(history, None, None, 24)
slices["calendar"] = slice_for(
"calendar", views.calendar_key(start_ts, end_ts, 24), hist_token,
lambda: views.build_calendar(cell, history, start_ts, end_ts, 24, place, run))
if prefetch:
# Latest archived day: its observation comes from history alone, so
# it's buildable without the (skipped) recent bundle.
slices["day"] = slice_for(
"day", views.day_key(last), hist_token,
lambda: views.build_day(cell, history, last, place, run))
else:
rf_token = views.recent_token(history, cid)
slices["grade"] = slice_for(
"grade", views.grade_key(today, 14, 14), rf_token,
lambda: views.build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14))
slices["forecast"] = slice_for(
"forecast", views.forecast_key(today, 7), rf_token,
lambda: views.build_forecast(cell, 7, history, recent, today, place, run))
slices["day"] = slice_for(
"day", views.day_key(today), views.day_token(history, today),
lambda: views.build_day(cell, history, today, place, run, recent=recent))
# The bundle's identity is the combination of its slices' identities.
etag = _etag_for("cell", cid, "bundle" + (":p" if prefetch else ""),
"|".join(s["etag"] for s in slices.values()))
if _not_modified(request, etag):
run.set(run_type="cache", not_modified=True)
return Response(status_code=304, headers={"ETag": etag})
run.set(run_type="bundle", slices=sorted(slices))
payload = {
"api_version": "v2",
"cell": cell,
"place": place,
"today": today.date().isoformat(),
"slices": slices,
}
# Not stored as its own derived row — the slices already are.
return _json_response(_encode(payload), etag)
# --- API versioning --------------------------------------------------------
# Non-backward-compatible additions land in a new version; every version stays
# mounted and served simultaneously. v1 is the original contract (grade/geocode);
# v2 adds the calendar. The unversioned /api/* paths are kept as aliases of v1 so
# existing clients keep working.
v1 = APIRouter(tags=["v1"])
v1.add_api_route("/geocode", api_geocode, methods=["GET"])
v1.add_api_route("/grade", api_grade, methods=["GET"])
v2 = APIRouter(tags=["v2"])
v2.add_api_route("/geocode", api_geocode, methods=["GET"])
v2.add_api_route("/suggest", api_suggest, methods=["GET"])
v2.add_api_route("/place", api_place, methods=["GET"])
v2.add_api_route("/grade", api_grade, methods=["GET"])
v2.add_api_route("/calendar", api_calendar, methods=["GET"])
v2.add_api_route("/day", api_day, methods=["GET"])
v2.add_api_route("/forecast", api_forecast, methods=["GET"])
v2.add_api_route("/cell", api_cell, methods=["GET"])
app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible)
app.include_router(v1, prefix=f"{BASE}/api/v1")
app.include_router(v2, prefix=f"{BASE}/api/v2")
# --- static frontend (served under BASE) -----------------------------------
def _page(name):
"""Route handler for one HTML page. Serves the file with its __ORIGIN__
placeholders (the link-preview/Open Graph tags) filled in as the request's
scheme://host + BASE — preview crawlers (Discord, Slack, …) need absolute
URLs, and the host differs between LAN and prod. The scheme comes from
X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
uvicorn; the proxy passes the original Host header through untouched."""
path = os.path.join(FRONTEND_DIR, name)
def route(request: Request):
with open(path, encoding="utf-8") as f:
html = f.read()
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
if _not_modified(request, etag):
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
return route
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
# resolve correctly. The app stays scoped to BASE and never claims "/", leaving
# the domain root free for another app (e.g. a portfolio) to own via the proxy.
# Pages also answer HEAD — link-preview crawlers probe with it before fetching.
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/", _page("index.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
# Registered last so the explicit page routes above win.
app.mount(BASE, StaticFiles(directory=FRONTEND_DIR), name="static")