Repo-split Stage 3: frontend SSR service (in-repo, not yet live) (#13)

This commit is contained in:
emi 2026-07-21 17:48:55 +00:00
parent 4e52a9c418
commit 1738f61c8f
18 changed files with 2115 additions and 0 deletions

84
api_client.py Normal file
View file

@ -0,0 +1,84 @@
"""HTTP client for the backend's SSR content API (backend/api/content_routes.py).
Fails loud at import if THERMOGRAPH_API_BASE_INTERNAL is unset -- same
philosophy as content_loader.py's fail-loud content validation: a missing
backend URL should break the boot, not silently 500 on the first request.
A short TTL cache sits in front of every call: climate data changes at most
hourly (the archive top-up + hourly recent-forecast refresh), so without this
a page-view burst on one city would cost one backend round trip per request
instead of one per cache window. This is in addition to (not instead of) the
backend's own derived-store/ETag caching -- this cache saves the network hop
entirely; the backend's saves the recompute.
"""
import os
import time
import httpx
API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL")
if not API_BASE:
raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
_TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default
_cache: dict[str, tuple[float, object]] = {}
def _get(path: str, *, origin: str | None = None) -> object:
"""GET {API_BASE}{path}, cached for _TTL seconds. Raises httpx.HTTPStatusError
on a non-2xx response (including 404/503) so callers can map it the same
way the old in-process code raised HTTPException.
`origin` (e.g. "https://thermograph.org") is the ORIGINAL browser-facing
request's origin, not this internal call's -- forwarded as Host/
X-Forwarded-Proto so the backend's own _origin(request) (which already
prefers those headers) builds jsonld "url" fields against the public
origin instead of this internal http://backend:8137-style address. Folded
into the cache key: harmless when there's one canonical origin (the
default same-domain prod topology), and correct when there's more than
one (the genuinely-cross-origin capability Stage 5 proves out)."""
cache_key = f"{origin}|{path}" if origin else path
now = time.time()
hit = _cache.get(cache_key)
if hit and now - hit[0] < _TTL:
return hit[1]
headers = {}
if origin:
proto, _, host = origin.partition("://")
headers = {"host": host, "x-forwarded-proto": proto}
resp = httpx.get(f"{API_BASE}{path}", timeout=10.0, headers=headers)
resp.raise_for_status()
data = resp.json()
_cache[cache_key] = (now, data)
return data
def hub() -> dict:
return _get("/api/v2/content/hub")
def sitemap() -> list[dict]:
return _get("/api/v2/content/sitemap")
def indexnow_key() -> str:
return _get("/api/v2/content/indexnow-key")["key"]
def home() -> dict:
return _get("/api/v2/content/home")
def city(slug: str, *, origin: str | None = None) -> dict:
"""Raises httpx.HTTPStatusError with .response.status_code 404 (unknown
city) or 503 (warming) -- callers translate these to FastAPI HTTPException,
same status codes content.py's _resolve_city raised in-process."""
return _get(f"/api/v2/content/city/{slug}", origin=origin)
def city_month(slug: str, month: str) -> dict:
return _get(f"/api/v2/content/city/{slug}/month/{month}")
def city_records(slug: str, *, origin: str | None = None) -> dict:
return _get(f"/api/v2/content/city/{slug}/records", origin=origin)

36
app.py Normal file
View file

@ -0,0 +1,36 @@
"""SSR frontend service: server-rendered content pages (content.py) plus the
static JS/CSS/PWA asset bundle. All climate data comes from the backend's
content API over HTTP (api_client.py) -- this process holds no data of its
own and does no polars/DB work.
"""
import mimetypes
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
import content
import paths
# The PWA manifest is served as a static file; register its media type so
# StaticFiles labels it correctly (not in Python's default mimetypes map).
mimetypes.add_type("application/manifest+json", ".webmanifest")
app = FastAPI(title="Thermograph SSR", version="0.1.0")
# Compress every sizeable response, same threshold as the backend.
app.add_middleware(GZipMiddleware, minimum_size=1024)
@app.get("/healthz")
def healthz():
"""Liveness probe: the process is up and serving. Deliberately does no I/O
-- no call to the backend -- so it stays cheap and reliable for a tight
healthcheck interval. Readiness (backend reachable) is a separate concern."""
return {"status": "ok"}
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
content.register(app)
app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static")

402
content.py Normal file
View file

@ -0,0 +1,402 @@
"""Server-rendered, crawlable content pages (climate hub / per-city / month /
records / glossary / about) plus robots.txt and sitemap.xml.
Ported from backend/web/content.py (repo-split Stage 3): every route handler
here fetches its data from the backend's SSR content API (api_client.py)
instead of importing backend code and computing in-process. Template shape is
unchanged -- only the data source changed, from a function call to an HTTP
call. page_title/page_description/canonical_path/breadcrumb/jsonld are all
computed by the backend now (backend/api/content_payloads.py), not here.
"""
import datetime
import hashlib
import json
import os
import httpx
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
import api_client
import content_loader
import format as fmt
import paths
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
_env = Environment(
loader=FileSystemLoader(paths.TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
_env.globals["temp_class"] = fmt.temp_class
_env.globals["temp"] = fmt.temp
_env.globals["temp_bare"] = fmt.temp_bare
_env.filters["ordinal"] = fmt.pct_ordinal
# --- helpers -------------------------------------------------------------
def _origin(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}"
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = _origin(request)
ctx.setdefault("unit_default", fmt.current_unit())
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
inm = request.headers.get("if-none-match")
if inm and etag in {t.strip() for t in inm.split(",")}:
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
def _breadcrumb_tuples(breadcrumb: list[dict]) -> list[tuple]:
"""The templates do `{% for label, href in breadcrumb %}` (tuple
unpacking) -- the backend returns [{"name","href"}, ...] (JSON-safe), so
convert once here rather than touching every template."""
return [(c["name"], c["href"]) for c in breadcrumb]
def _raise_api_error(e: httpx.HTTPStatusError):
"""The backend's 404 (unknown city/month) and 503 (warming) map straight
through -- same status codes _resolve_city raised in-process before."""
raise HTTPException(status_code=e.response.status_code, detail=e.response.text) from e
# --- robots.txt & sitemap.xml ---------------------------------------------
def robots_txt(request: Request) -> Response:
base_url = f"{_origin(request)}{BASE}"
body = (
"User-agent: *\n"
"Allow: /\n"
f"Disallow: {BASE}/api/\n"
f"Disallow: {BASE}/alerts\n"
f"Sitemap: {base_url}/sitemap.xml\n"
)
return PlainTextResponse(body)
# A stable per-process "content last built" date -- crawlers should see
# <lastmod> change on a real rebuild, not churn on every request.
_BOOT_DATE = datetime.date.today().isoformat()
def sitemap_xml(request: Request) -> Response:
base_url = f"{_origin(request)}{BASE}"
entries = api_client.sitemap()
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for e in entries:
parts.append(
f"<url><loc>{base_url}{e['path']}</loc><lastmod>{_BOOT_DATE}</lastmod>"
f"<changefreq>{e['changefreq']}</changefreq><priority>{e['priority']}</priority></url>"
)
parts.append("</urlset>")
return Response("\n".join(parts), media_type="application/xml")
def head_verify_html() -> Markup:
"""Search-engine ownership-verification <meta> tags, from env (empty when
unset). No backend dependency -- same env vars, read directly here."""
metas = []
google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
if google:
metas.append(Markup('<meta name="google-site-verification" content="{}">').format(google))
if bing:
metas.append(Markup('<meta name="msvalidate.01" content="{}">').format(bing))
return Markup("\n ").join(metas)
_env.globals["head_verify"] = head_verify_html
# --- per-city climate pages ------------------------------------------------
def _fmt_months(months: list[dict]) -> list[dict]:
out = []
for m in months:
out.append({
**m,
"high": fmt.temp(m["high_f"]), "low": fmt.temp(m["low_f"]),
"precip": fmt.precip(m["precip_f"]),
"bar": fmt.range_bar(m["range_lo_f"], m["range_hi_f"]),
})
return out
def city_page(request: Request, slug: str) -> Response:
try:
j = api_client.city(slug, origin=_origin(request))
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(j["default_unit"]):
months = _fmt_months(j["months"])
warmest = next((m for m in months if m["slug"] == j["warmest_month_slug"]), None)
coldest = next((m for m in months if m["slug"] == j["coldest_month_slug"]), None)
wettest = next((m for m in months if m["slug"] == j["wettest_month_slug"]), None)
records = j["all_time_records"]
today = None
if j["today_vs_normal"]:
today = {
"date": j["today_vs_normal"]["date"],
"cards": [{**c, "value": fmt.fmt(c["metric"], c["value_f"])}
for c in j["today_vs_normal"]["cards"]],
}
ctx = {
"section": "climate",
"city": j["city"], "display": j["display"], "name": j["city"]["name"],
"year_range": j["year_range"], "n_years": j["n_years"],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": today,
"tool_hash": f"{j['city']['lat']:.5f},{j['city']['lon']:.5f}",
"flavor": j["flavor"], "event": j["event"],
"compare_url": f"{BASE}/compare#loc={j['city']['lat']:.4f},{j['city']['lon']:.4f}",
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"canonical_path": j["canonical_path"],
"page_title": j["page_title"], "page_description": j["page_description"],
"jsonld_str": json.dumps(j["jsonld"], ensure_ascii=False, separators=(",", ":")),
}
return _respond_html(request, "city.html.j2", **ctx)
# --- month & records pages --------------------------------------------------
_METRIC_KEY_BY_LABEL = {
"High": "tmax", "Low": "tmin", "Feels-like": "feels",
"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}
def month_page(request: Request, slug: str, month: str) -> Response:
try:
j = api_client.city_month(slug, month)
city = api_client.city(slug)["city"]
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(fmt.unit_for_country(city.get("country_code"))):
stats = []
if j["avg_high_f"] is not None:
stats.append(("Average high", fmt.temp(j["avg_high_f"])))
lo, hi = j["typical_high_range_f"]
stats.append(("Typical high range", fmt.temp(lo) + " to " + fmt.temp(hi)))
if j["avg_low_f"] is not None:
stats.append(("Average low", fmt.temp(j["avg_low_f"])))
lo, hi = j["typical_low_range_f"]
stats.append(("Typical low range", fmt.temp(lo) + " to " + fmt.temp(hi)))
if j["avg_precip_f"] is not None:
stats.append(("Average daily precipitation", fmt.precip(j["avg_precip_f"])))
records = []
for r in j["records"]:
metric = _METRIC_KEY_BY_LABEL[r["label"]]
records.append({
"label": r["label"], "high_tag": r["high_tag"], "low_tag": r["low_tag"],
"high": fmt.fmt(metric, r["high_f"]), "high_date": r["high_date"],
"low": fmt.fmt(metric, r["low_f"]), "low_date": r["low_date"],
})
ctx = {
"section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"],
"month_name": j["month_name"], "month_slug": j["month_slug"],
"year_range": j["year_range"], "n_years": j["n_years"],
"avg_high": fmt.temp(j["avg_high_f"]), "avg_low": fmt.temp(j["avg_low_f"]),
"avg_high_cls": fmt.temp_class(j["avg_high_f"]), "avg_low_cls": fmt.temp_class(j["avg_low_f"]),
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
"prev": j["prev"], "next": j["next"],
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"canonical_path": j["canonical_path"],
"page_title": j["page_title"], "page_description": j["page_description"],
}
return _respond_html(request, "month.html.j2", **ctx)
def _fmt_extreme(e: dict | None) -> dict | None:
if not e:
return None
return {"txt": fmt.temp(e["value_f"]), "cls": fmt.temp_class(e["value_f"]), "date": e["date"]}
def _fmt_period_extremes(side: dict) -> dict:
"""{'warm': {'value_f','date'}|None, 'cold': ...} -> the {'cls','txt','date'}
shape records.html.j2's `extremes()` macro reads."""
return {k: _fmt_extreme(v) for k, v in side.items()}
def records_page(request: Request, slug: str) -> Response:
try:
j = api_client.city_records(slug, origin=_origin(request))
city = api_client.city(slug)["city"]
except httpx.HTTPStatusError as e:
_raise_api_error(e)
with fmt.unit_scope(fmt.unit_for_country(city.get("country_code"))):
rows = []
for r in j["rows"]:
metric = _METRIC_KEY_BY_LABEL[r["label"]]
if r["dry_streak_days"] is not None:
low, low_date = f"{r['dry_streak_days']}-day dry spell", r["low_date"] or ""
else:
low, low_date = fmt.fmt(metric, r["low_f"]), r["low_date"]
is_temp = metric in ("tmax", "tmin", "feels")
rows.append({
"label": r["label"],
"high": fmt.fmt(metric, r["high_f"]), "high_date": r["high_date"],
"high_f": r["high_f"] if is_temp else None,
"low": low, "low_date": low_date,
"low_f": r["low_f"] if is_temp else None,
})
monthly = [{"name": m["name"], "slug": m["slug"],
"high": _fmt_period_extremes(m["high"]), "low": _fmt_period_extremes(m["low"])}
for m in j["monthly"]]
seasonal = [{"name": s["name"], "span": s["span"],
"high": _fmt_period_extremes(s["high"]), "low": _fmt_period_extremes(s["low"])}
for s in j["seasonal"]]
ctx = {
"section": "climate", "city": city, "display": j.get("display", city["name"]), "name": city["name"],
"year_range": j["year_range"], "n_years": j["n_years"],
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": j["hemisphere"], "all_time": j["all_time_records"],
"canonical_path": j["canonical_path"],
"breadcrumb": _breadcrumb_tuples(j["breadcrumb"]),
"page_title": j["page_title"], "page_description": j["page_description"],
"jsonld_str": json.dumps(j["jsonld"], ensure_ascii=False, separators=(",", ":")),
}
return _respond_html(request, "records.html.j2", **ctx)
# --- hub / glossary / about -------------------------------------------------
def hub_page(request: Request) -> Response:
h = api_client.hub()
groups = {c["country"]: c["cities"] for c in h["countries"]}
ctx = {
"section": "climate",
"groups": groups,
"n_cities": h["n_cities"],
"n_countries": h["n_countries"],
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
"page_title": PAGES["hub"]["title"],
"page_description": PAGES["hub"]["description"],
}
return _respond_html(request, "hub.html.j2", **ctx)
GLOSSARY: dict[str, dict] = content_loader.load_glossary()
PAGES: dict[str, dict] = content_loader.load_pages()
def _glossary_body(entry: dict) -> str:
return entry["body"].replace("{base}", BASE)
def glossary_index(request: Request) -> Response:
ctx = {
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
"canonical_path": "/glossary",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
"page_title": PAGES["glossary_index"]["title"],
"page_description": PAGES["glossary_index"]["description"],
}
return _respond_html(request, "glossary.html.j2", **ctx)
def glossary_term(request: Request, term: str) -> Response:
entry = GLOSSARY.get(term)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown term.")
ctx = {
"term": entry["term"], "body": _glossary_body(entry),
"canonical_path": f"/glossary/{term}",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", f"{BASE}/glossary"), (entry["term"], None)],
"page_title": f"{entry['term']}: what it means | Thermograph",
"page_description": entry["short"],
"others": [{"slug": s, "term": e["term"]} for s, e in GLOSSARY.items() if s != term],
}
return _respond_html(request, "glossary_term.html.j2", **ctx)
def about_page(request: Request) -> Response:
ctx = {
"canonical_path": "/about",
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
"page_title": PAGES["about"]["title"],
"page_description": PAGES["about"]["description"],
}
return _respond_html(request, "about.html.j2", **ctx)
def privacy_page(request: Request) -> Response:
ctx = {
"canonical_path": "/privacy",
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
"page_title": PAGES["privacy"]["title"],
"page_description": PAGES["privacy"]["description"],
}
return _respond_html(request, "privacy.html.j2", **ctx)
# --- homepage ----------------------------------------------------------------
_HOME_JSONLD = {
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Thermograph",
"applicationCategory": "WeatherApplication",
"operatingSystem": "Web, iOS, Android",
"isAccessibleForFree": True,
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
"description": ("How unusual is your weather? Any day, anywhere on Earth, graded "
"against 45 years of that place's own history."),
}
def home_page(request: Request) -> Response:
h = api_client.home()
ctx = {
"section": "home",
"brand_tag": "p",
"main_class": "",
"canonical_path": "/",
"unusual": h["unusual"],
"stale": h["stale"],
"ranked": h["ranked"],
"cities": h["cities"],
"jsonld_str": Markup(json.dumps({**_HOME_JSONLD, "url": f"{_origin(request)}{BASE}/"})),
}
return _respond_html(request, "home.html.j2", **ctx)
# --- registration ------------------------------------------------------------
def register(app) -> None:
"""Attach all content routes. Call from app.py BEFORE the StaticFiles mount."""
app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False)
app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False)
_inkey = api_client.indexnow_key()
def _indexnow_key_file() -> Response:
return PlainTextResponse(_inkey + "\n")
app.add_api_route(
f"{BASE}/{_inkey}.txt", _indexnow_key_file,
methods=["GET", "HEAD"], include_in_schema=False,
)
app.add_api_route(f"{BASE}/", home_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/privacy", privacy_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary", glossary_index, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary/{{term}}", glossary_term, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate", hub_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}", city_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/records", records_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/{{month}}", month_page, methods=["GET", "HEAD"], include_in_schema=False)

69
content_loader.py Normal file
View file

@ -0,0 +1,69 @@
"""Loads structured SSR copy (glossary, static-page SEO meta) from content/ —
see docs/README.md. Validated at load time so a malformed content file fails
loudly at import rather than silently rendering a blank/broken page.
Separate from cities_flavor.json (backend/cities_flavor.json, machine-generated
from Wikipedia by gen_flavor.py) and the Jinja templates themselves
(backend/templates/*.html.j2, which still own page structure/markup) this
covers only the two content classes that are pure static data with no embedded
template logic: the glossary, and each static page's SEO title/description.
"""
import os
import yaml
import paths
_GLOSSARY_PATH = os.path.join(paths.CONTENT_DIR, "glossary.yaml")
_PAGES_PATH = os.path.join(paths.CONTENT_DIR, "pages.yaml")
_REQUIRED_GLOSSARY_FIELDS = ("term", "short", "body")
_REQUIRED_PAGE_FIELDS = ("title", "description")
def _load_yaml(path: str) -> dict:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"{path}: expected a top-level mapping, got {type(data).__name__}")
return data
def load_glossary(path: str = _GLOSSARY_PATH) -> "dict[str, dict]":
"""slug -> {term, short, body}, in file order. Raises ValueError if any
entry is missing a required field, has the wrong shape, or repeats a slug
a bad content edit fails loudly (at content.py's module-level import,
normally) rather than rendering a blank glossary card."""
data = _load_yaml(path)
terms = data.get("terms")
if not isinstance(terms, list) or not terms:
raise ValueError(f"{path}: 'terms' must be a non-empty list")
glossary: "dict[str, dict]" = {}
for i, entry in enumerate(terms):
if not isinstance(entry, dict) or "slug" not in entry:
raise ValueError(f"{path}: terms[{i}] must be a mapping with a 'slug' key")
slug = entry["slug"]
missing = [f for f in _REQUIRED_GLOSSARY_FIELDS if not entry.get(f)]
if missing:
raise ValueError(f"{path}: term '{slug}' is missing {missing}")
if slug in glossary:
raise ValueError(f"{path}: duplicate slug '{slug}'")
glossary[slug] = {f: entry[f] for f in _REQUIRED_GLOSSARY_FIELDS}
return glossary
def load_pages(path: str = _PAGES_PATH) -> "dict[str, dict]":
"""page key -> {title, description}. Same fail-loud contract as load_glossary."""
data = _load_yaml(path)
pages = data.get("pages")
if not isinstance(pages, dict) or not pages:
raise ValueError(f"{path}: 'pages' must be a non-empty mapping")
result: "dict[str, dict]" = {}
for key, entry in pages.items():
if not isinstance(entry, dict):
raise ValueError(f"{path}: page '{key}' must be a mapping")
missing = [f for f in _REQUIRED_PAGE_FIELDS if not entry.get(f)]
if missing:
raise ValueError(f"{path}: page '{key}' is missing {missing}")
result[key] = {f: entry[f] for f in _REQUIRED_PAGE_FIELDS}
return result

173
format.py Normal file
View file

@ -0,0 +1,173 @@
"""Unit-aware formatting for the SSR templates — ported from
backend/web/content.py, unchanged in behavior (still Markup-wrapped spans for
the templates), but operating on raw floats the content API returns instead of
live polars frames. The ContextVar-based active-unit scoping is unchanged: the
templates and context builders both need it (see content.py's `_unit_scope`),
and threading a `unit` parameter through every call site risks a silently
wrong unit at the one site someone forgets, same reasoning as the original.
"""
import contextlib
import contextvars
import math
from markupsafe import Markup
# Countries that actually use Fahrenheit. Mirrors backend/api/content_payloads.py's
# F_COUNTRIES / frontend/units.js's F_REGIONS -- a test asserts all three stay
# identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
MM_PER_IN = 25.4
KMH_PER_MPH = 1.609344
# Absolute-temperature colour tiers (°F upper bounds), the same 9 tiers the
# interactive grader uses.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
_AXIS_LO, _AXIS_HI = -10.0, 115.0
_UNIT: contextvars.ContextVar[str | None] = contextvars.ContextVar("display_unit", default=None)
@contextlib.contextmanager
def unit_scope(unit: str | None):
"""Render temperatures in `unit` for the duration of the block. The reset
is not optional -- see content.py's page handlers (sync def -> a recycled
threadpool thread) for why a leaked value would bleed into the next
request."""
token = _UNIT.set(unit)
try:
yield
finally:
_UNIT.reset(token)
def unit_for_country(code: str | None) -> str:
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def current_unit() -> str | None:
return _UNIT.get()
def _round_half_up(x: float) -> int:
"""Round like JS's Math.round, not Python's round() (which rounds halves
to even) -- so the server and climate.js's repaint never visibly disagree."""
return math.floor(x + 0.5)
def _c(f: float) -> int:
return _round_half_up((f - 32) * 5 / 9)
def _shown(f: float) -> int:
return _c(f) if _UNIT.get() == "C" else _round_half_up(f)
def _letter() -> str:
return "C" if _UNIT.get() == "C" else "F"
def temp(f) -> Markup | str:
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°{}</span>').format(
f, _shown(f), _letter())
def temp_bare(f) -> Markup | str:
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(
f, _shown(f))
def temp_text(f) -> str:
if f is None:
return ""
return f"{_shown(f)}°{_letter()}"
def precip(v) -> Markup | str:
if v is None:
return ""
return Markup('<span class="precip" data-precip-in="{:.3f}">{}</span>').format(
v, precip_text(v))
def precip_text(v) -> str:
if v is None:
return ""
return (f"{_round_half_up(v * MM_PER_IN)} mm" if _UNIT.get() == "C"
else f"{v:.2f} in")
def wind(v) -> Markup | str:
if v is None:
return ""
return Markup('<span class="wind" data-wind-mph="{:.1f}">{}</span>').format(
v, wind_text(v))
def wind_text(v) -> str:
if v is None:
return ""
return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C"
else f"{_round_half_up(v)} mph")
_TEMP_METRICS = {"tmax", "tmin", "feels"}
def fmt(metric: str, v) -> Markup | str:
if v is None:
return ""
if metric in _TEMP_METRICS:
return temp(v)
if metric == "precip":
return precip(v)
if metric == "humid":
return f"{v:.1f} g/m³"
return wind(v) # wind, gust
def temp_class(f) -> str:
"""Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none')."""
if f is None:
return "none"
for upper, cls in _TEMP_TIERS:
if f < upper:
return cls
return "rec-hot"
def pct_ordinal(pct) -> str:
"""A percentile as a display ordinal: 66.4 -> '66th', 99.6 -> '99th'.
Floored into 1..99 -- an empirical percentile is a rank against the sample,
so rounding can land on 100 (or 0). Ported from backend/data/grading.py's
pct_ordinal, which stays canonical for the in-process (Day page, calendar,
chart) surfaces; this is the SSR-side copy, same formula."""
try:
n = min(99, max(1, _round_half_up(float(pct))))
except (TypeError, ValueError):
return ""
suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low->high bar on the shared axis: left offset +
width as percentages, and the tier colour at each end. None when missing."""
if low_f is None or high_f is None:
return None
lo = max(_AXIS_LO, min(_AXIS_HI, low_f))
hi = max(_AXIS_LO, min(_AXIS_HI, high_f))
span = _AXIS_HI - _AXIS_LO
return {
"left": round((lo - _AXIS_LO) / span * 100, 1),
"width": round(max(2.0, (hi - lo) / span * 100), 1),
"c1": temp_class(low_f), "c2": temp_class(high_f),
}

32
paths.py Normal file
View file

@ -0,0 +1,32 @@
"""Canonical filesystem locations for the SSR service, resolved from the repo
root. Stage-3-transitional: this package lives at `frontend_ssr/`, a sibling
of the static asset tree `frontend/` (not nested inside it -- nesting would
let StaticFiles serve this package's own source/templates as downloadable
files once app.py mounts frontend/). Still walks up to find `content/` as a
sibling too, same as backend/paths.py does today, since both still live in
one repo. Once frontend is its own repo (Stage 7), content/ is vendored at
build time instead and this goes back to a no-walk resolution, same as
backend's.
"""
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
def _repo_root() -> str:
d = _HERE
while True:
if os.path.isdir(os.path.join(d, "frontend")) and os.path.isdir(os.path.join(d, "content")):
return d
parent = os.path.dirname(d)
if parent == d:
raise RuntimeError(
"cannot locate the repo root (no ancestor has sibling frontend/ and content/)"
)
d = parent
REPO_DIR = _repo_root()
STATIC_DIR = os.path.join(REPO_DIR, "frontend")
TEMPLATES_DIR = os.path.join(_HERE, "templates")
CONTENT_DIR = os.path.join(REPO_DIR, "content")

36
templates/about.html.j2 Normal file
View file

@ -0,0 +1,36 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>About Thermograph</h1>
<p class="lede">Thermograph answers one question for any point on Earth: <b>how unusual is this
weather?</b> Instead of an absolute temperature, it shows where a day falls in that exact
location's own climate history.</p>
<h2>Where the data comes from</h2>
<p>The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation)
comes from the <b>ERA5 reanalysis</b> (ECMWF), served via <a href="https://open-meteo.com/" rel="nofollow">Open-Meteo</a>.
Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's
no weather station, anywhere on the ~4&nbsp;sq&nbsp;mi grid.</p>
<h2>How a day is graded</h2>
<p>For each metric and date, Thermograph builds a reference distribution from every historical day
within <b>&plusmn;7 days of that day-of-year</b>, a 15-day seasonal window across all years. The
observed value is placed on that distribution as an <a href="{{ base }}/glossary/percentile">empirical
percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p>
<p>Everything is <b>relative to the location</b>: a {{ temp(60) }} day can be “Above Normal” in one place and
“Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p>
<h2>Freshness</h2>
<p>The historical archive updates as new days are added; recent and forecast conditions refresh hourly.
City climate pages show the latest recorded day graded against the local normal.</p>
<p><a class="cta" href="{{ base }}/">Open the weather grader →</a></p>
</article>
{% endblock %}

113
templates/base.html.j2 Normal file
View file

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ head_verify() }}
<title>{% block title %}Thermograph{% endblock %}</title>
<meta name="description" content="{% block description %}{% endblock %}" />
<meta name="theme-color" content="#f0803c" />
<!-- 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" />
<link rel="canonical" href="{{ base_url }}{% block canonical_path %}/{% endblock %}" />
<meta property="og:type" content="{% block og_type %}website{% endblock %}" />
<meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="{{ self.title() }}" />
<meta property="og:description" content="{{ self.description() }}" />
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
<meta property="og:image" content="{{ base_url }}/logo.png?v=3" />
<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="icon" href="{{ base }}/favicon.svg?v=3" type="image/svg+xml" />
<link rel="icon" href="{{ base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
<link rel="icon" href="{{ base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
<link rel="icon" href="{{ base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="{{ base }}/apple-touch-icon.png?v=3" />
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
{% block head_extra %}{% endblock %}
<link rel="stylesheet" href="{{ base }}/style.css" />
{% block jsonld %}{% endblock %}
</head>
<body{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
<header>
<div class="brand">
<div>
{# The brand is the page's h1 everywhere EXCEPT the homepage, where the
hero headline owns the sole h1 and the brand degrades to a <p>. The
.brand h1, .brand .site-name selector pair in style.css keeps the
lockup looking identical either way. #}
<{{ brand_tag|default('h1') }} class="site-name"><a href="{{ base }}/"><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="259" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="307" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="403" y="115" width="42" height="42" rx="10" fill="#dd6b52"/><rect x="403" y="67" width="42" height="42" rx="10" fill="#8f0e20"/></svg></span>Thermograph</a></{{ brand_tag|default('h1') }}>
<p class="tag">How unusual is the weather? Graded against ~45 years of local climate history.</p>
</div>
<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>
<div class="nav-panel">
{# data-view marks the links nav.js rewrites to carry the current
location hash, so moving between views keeps the place you picked.
Climate and Alerts deliberately opt out (they aren't cell-scoped).
Inert on the SEO pages, which never load nav.js. #}
<nav class="view-nav" aria-label="Views">
<a href="{{ base }}/" data-view="map"{% if section == 'home' %} class="active"{% endif %}>Weekly</a>
<a href="{{ base }}/calendar" data-view="calendar">Calendar</a>
<a href="{{ base }}/day" data-view="day">Day Detail</a>
<a href="{{ base }}/compare" data-view="compare">Compare</a>
<a href="{{ base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
<a href="{{ base }}/alerts">Alerts</a>
</nav>
</div>
</details>
</div>
</header>
<main{% if main_class|default('content') %} class="{{ main_class|default('content') }}"{% endif %}>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
{# The compact digest signup is the site-wide footer pattern, not a
homepage-only surface: every page is a possible entry point.
Hidden for now; restore by uncommenting the form block below. #}
{#
<form class="digest-form digest-compact" method="post" action="{{ base }}/digest"
data-digest aria-labelledby="footer-digest-label">
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
<div class="digest-row">
<input id="footer-digest-email" name="email" type="email" required
autocomplete="email" placeholder="you@example.com" />
<button type="submit">Get the digest</button>
</div>
<p class="digest-note muted">No spam, no sharing your address, unsubscribe anytime.</p>
<p class="digest-status" role="status" aria-live="polite" hidden></p>
</form>
#}
<nav aria-label="Footer">
<a href="{{ base }}/climate">City climates</a>
<a href="{{ base }}/glossary">Weather glossary</a>
<a href="{{ base }}/about">About &amp; methodology</a>
<a href="{{ base }}/privacy">Privacy</a>
<a href="{{ base }}/">Open the tool</a>
</nav>
<p class="muted">Free. No ads. No tracking. No account needed. Built by one person in the open.</p>
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
climate history. Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway.</p>
<p class="muted">Made by <a href="https://emigriffith.dev">emigriffith.dev</a>
<a href="mailto:emerytgriffith@gmail.com" aria-label="Email" title="emerytgriffith@gmail.com">@</a></p>
</footer>
{% block body_scripts %}
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
and the client-side temperature converter. All self-contained modules. -->
<script type="module" src="{{ base }}/units.js"></script>
<script type="module" src="{{ base }}/account.js"></script>
<script type="module" src="{{ base }}/climate.js"></script>
{% endblock %}
<script type="module" src="{{ base }}/digest.js"></script>
<script type="module" src="{{ base }}/ios-install.js"></script>
</body>
</html>

111
templates/city.html.j2 Normal file
View file

@ -0,0 +1,111 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} climate</h1>
<p class="lede">Average temperatures, precipitation and all-time records for <b>{{ display }}</b>,
with every day graded against ~{{ n_years }} years of local climate history
({{ year_range[0] }}{{ year_range[1] }}).{% if warmest and coldest %} Its warmest month is
<b>{{ warmest.name }}</b>, averaging a high of {{ warmest.high }}, and its coldest is
<b>{{ coldest.name }}</b>, with an average low of {{ coldest.low }}.{% endif %}</p>
{% if flavor %}
<p class="city-blurb">{{ flavor.extract }}{% if flavor.url %}
<a class="blurb-src" href="{{ flavor.url }}" rel="nofollow">via Wikipedia</a>{% endif %}</p>
{% endif %}
{% if today %}
<section class="climate-today">
<h2>How today compares</h2>
<p class="muted">Latest recorded day: {{ today.date }}. Each reading placed on {{ display }}'s
own ±7-day seasonal distribution.</p>
<div class="today-cards">
{% for c in today.cards %}
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
<span class="tc-label">{{ c.label }}</span>
<span class="tc-value">{{ c.value }}</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile|ordinal }} pct</span>
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
</div>
{% endfor %}
</div>
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">Open {{ name }} in the live weather grader →</a></p>
</section>
{% endif %}
{% if event %}
<section class="city-event">
<h2>Notable weather in {{ name }}</h2>
<p>{{ event.text }}{% if event.url %} <a href="{{ event.url }}" rel="nofollow">Read&nbsp;more&nbsp;→</a>{% endif %}</p>
</section>
{% endif %}
<section class="climate-normals">
<h2>{{ name }} average temperatures by month</h2>
<p>Typical daily high and low, and average precipitation, for each month in {{ display }},
based on {{ year_range[0] }}{{ year_range[1] }} records.
{% if wettest %}The wettest month is <b>{{ wettest.name }}</b> ({{ wettest.precip }} on an average day).{% endif %}</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Month</th><th>Avg high</th><th>Avg low</th><th>Avg precip</th></tr></thead>
<tbody>
{% for m in months %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td class="t-cell t-{{ temp_class(m.high_f) }}">{{ m.high }}</td>
<td class="t-cell t-{{ temp_class(m.low_f) }}">{{ m.low }}</td>
<td>{{ m.precip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="range-strip" aria-hidden="true">
{% for m in months %}
<div class="range-row">
<span class="range-month">{{ m.name[:3] }}</span>
<span class="range-track">
{% if m.bar %}<span class="range-fill" style="left:{{ m.bar.left }}%;width:{{ m.bar.width }}%;--c1:var(--{{ m.bar.c1 }});--c2:var(--{{ m.bar.c2 }})"></span>{% endif %}
</span>
{% if m.range_lo_f is not none and m.range_hi_f is not none %}
<span class="range-vals">{{ temp_bare(m.range_lo_f) }}{{ temp_bare(m.range_hi_f) }}</span>
{% else %}<span class="range-vals">—</span>{% endif %}
</div>
{% endfor %}
</div>
<p class="range-note muted">Each bar spans the 10th-percentile daily low to the 90th-percentile daily
high (the range most days fall within), coloured cold&nbsp;→&nbsp;hot on a shared
{{ temp(-10) }} to {{ temp(115) }} scale, so the whole year's rhythm reads at a glance.</p>
</section>
<section class="city-travel">
<h2>Thinking of visiting {{ name }}?</h2>
<p>Trips live and die by the weather. See how {{ name }}'s comfort stacks up against where
you live. {{ name }} is pre-loaded, just add your city.</p>
<p><a class="cta" href="{{ compare_url }}">Compare {{ name }}'s comfort with your city →</a></p>
</section>
{% if records.tmax or records.tmin %}
<section class="climate-records">
<h2>Record extremes</h2>
<ul>
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ temp(records.tmax.max) }} on {{ records.tmax.max_date }}</li>{% endif %}
{% if records.tmin %}<li><b>Coldest day on record:</b> {{ temp(records.tmin.min) }} on {{ records.tmin.min_date }}</li>{% endif %}
</ul>
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
</section>
{% endif %}
<p class="climate-foot muted">Percentiles compare each day to {{ display }}'s own history, so grades are
relative to this location: a “Near Record” day here isn't the same temperature as elsewhere.
<a href="{{ base }}/legend">How the grades work →</a></p>
</article>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather &amp; climate glossary</h1>
<p class="lede">Plain-language definitions of the terms Thermograph uses to grade the weather.</p>
{% for t in terms %}
<div class="glossary-term">
<h2><a href="{{ base }}/glossary/{{ t.slug }}">{{ t.term }}</a></h2>
<p>{{ t.short }}</p>
</div>
{% endfor %}
</article>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"DefinedTerm","name":{{ term | tojson }},"description":{{ page_description | tojson }},"inDefinedTermSet":"{{ base_url }}/glossary"}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ term }}</h1>
<p class="lede">{{ body | safe }}</p>
<p><a class="cta" href="{{ base }}/">See it live: grade any location's weather →</a></p>
<div class="climate-foot">
<h2>More terms</h2>
<ul class="city-links">
{% for o in others %}<li><a href="{{ base }}/glossary/{{ o.slug }}">{{ o.term }}</a></li>{% endfor %}
</ul>
</div>
</article>
{% endblock %}

205
templates/home.html.j2 Normal file
View file

@ -0,0 +1,205 @@
{% extends "base.html.j2" %}
{# The Weekly view — the product itself — with the distribution surfaces built
around it. Everything structural is server-rendered so crawlers and no-JS
readers get a complete page; app.js hydrates the live numbers in place.
The interactive controls below (#find-btn, #date-input, #panel, #results, …)
are app.js's DOM contract, carried over verbatim from the old static
frontend/index.html. Do not rename these ids.
section / brand_tag / main_class come from the route's render context
(content.home_page), not a child-template {% set %} — the SEO pages already
pass `section` that way. #}
{% block title %}Thermograph: how unusual is your weather?{% endblock %}
{% block description %}See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile.{% endblock %}
{% block canonical_path %}/{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endblock %}
{% block jsonld %}
<script type="application/ld+json">
{{ jsonld_str }}
</script>
{% endblock %}
{% block content %}
{# --- Hero ------------------------------------------------------------
The grade card is the LCP element: its frame is server-rendered and its
slots are sized here, so hydrating the live numbers causes no layout
shift. Band colour is always paired with the band word — colour is never
the only signal. #}
<section id="hero" class="hero">
<div class="hero-copy">
<h1>How unusual is your weather?</h1>
<p class="hero-sub">Any day, anywhere on Earth, graded against 45 years of that place's own history.</p>
</div>
<div class="hero-card">
{# --cat carries the band colour, the same convention .normal-card uses,
so the band token stays the single source of truth. #}
<div class="grade-card" id="hero-grade" aria-live="polite"
{% if unusual %}style="--cat: var(--{{ unusual.cls }})"{% endif %}>
<p class="grade-where" id="hero-where">
{%- if unusual -%}
Today in {{ unusual.display }}
{%- else -%}
Today
{%- endif -%}
</p>
<p class="grade-band" id="hero-band">{{ unusual.grade if unusual else '—' }}</p>
<p class="grade-detail" id="hero-detail">
{%- if unusual -%}
{{ unusual.metric_label }} in the {{ unusual.percentile|ordinal }} percentile for {{ unusual.window_label }}
{%- else -%}
Pick a place to see how unusual its weather is today.
{%- endif -%}
</p>
{% if unusual and unusual.is_default %}
<p class="grade-why muted">the most unusual weather we're tracking{% if stale %} · as of {{ unusual.date }}{% endif %}</p>
{% endif %}
</div>
<div class="hero-actions">
{# app.js hides the locate button outside a secure context, where the
browser refuses geolocation and it could only ever fail. #}
<button type="button" id="hero-locate" class="btn-primary">Use my location</button>
<button type="button" id="hero-pick" class="btn-ghost">Pick on the map</button>
</div>
<p class="hero-locate-msg" id="hero-locate-msg" role="status" aria-live="polite" hidden></p>
<p class="hero-jump"><a href="#panel">See your week ↓</a></p>
</div>
</section>
<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>
<div class="date-row">
<label>Target date
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint"><a href="{{ base }}/legend">What do the grades mean? →</a></span>
</div>
</section>
<section id="panel" class="panel panel-full">
<div class="placeholder" id="placeholder">
<p>Find a location to begin.</p>
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
for your ~4&nbsp;sq&nbsp;mi cell, builds a &plusmn;7-day seasonal distribution for each
day of the year, and grades recent weather by where it falls on that distribution.</p>
</div>
<div id="results" hidden></div>
</section>
{# Sits outside #panel on purpose: app.js rewrites results.innerHTML
wholesale on every grade, so anything inside it would be destroyed. #}
<p class="stance-line muted">
Free. No ads. No tracking. No account needed.
<a href="{{ base }}/about">How we work →</a>
</p>
{# --- Unusual right now ----------------------------------------------
Scroll-snap row, no JS carousel. Both tails are represented whenever a
cold-tail city qualifies — two-sided honesty is product identity. #}
{% if ranked %}
<section class="unusual-strip" aria-labelledby="records-title">
<h2 id="records-title">Unusual right now</h2>
<ul class="unusual-row">
{% for r in ranked %}
<li class="unusual-card" style="--cat: var(--{{ r.cls }})">
<a href="{{ base }}/day#lat={{ r.lat }}&amp;lon={{ r.lon }}"
data-event="home.records_click">
<span class="unusual-city">{{ r.display }}</span>
{# The reading itself, then what it is and what it should be —
a percentile means little without the value it describes. #}
<span class="unusual-value">{{ temp(r.value) }}</span>
<span class="unusual-band">{{ r.grade_label }}</span>
<span class="unusual-meta">
{{ r.metric_label }} &middot; {{ r.pct_label }} pct
{%- if r.normal is not none %} &middot; normal {{ temp(r.normal) }}{% endif %}
{%- if r.date_label %} &middot; {{ r.date_label }}{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
<p class="unusual-more"><a href="{{ base }}/climate">See all records →</a></p>
</section>
{% endif %}
{# --- How it works ---------------------------------------------------- #}
<section class="how-it-works" aria-labelledby="how-title">
<h2 id="how-title">How it works</h2>
<ol class="how-steps">
<li>We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.</li>
<li>Today gets compared to the same two weeks of the year, every year back to 1980.</li>
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
</ol>
<p class="muted">
<a href="{{ base }}/legend">Full methodology →</a>
&middot; Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway
</p>
</section>
{# --- Explore --------------------------------------------------------- #}
<section class="explore" aria-labelledby="explore-title">
<h2 id="explore-title">Explore</h2>
{# Each card takes a colour from the grade ramp rather than a decorative
palette, so the section reads as part of the product's own scale.
Calendar carries the ramp itself — it IS the heatmap. #}
<div class="explore-cards">
<a class="explore-card" href="{{ base }}/calendar" data-event="home.nav_calendar"
style="--cat: var(--warm)">
<span class="explore-name">Calendar</span>
<span class="explore-ramp" aria-hidden="true">
{% for t in ('rec-cold','very-cold','cold','cool','normal','warm','hot','very-hot','rec-hot') %}
<i style="background: var(--{{ t }})"></i>
{% endfor %}
</span>
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
</a>
<a class="explore-card" href="{{ base }}/compare" data-event="home.nav_compare"
style="--cat: var(--normal)">
<span class="explore-name">Compare</span>
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
</a>
<a class="explore-card" href="{{ base }}/day" data-event="home.nav_day"
style="--cat: var(--cool)">
<span class="explore-name">Day</span>
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
</a>
<a class="explore-card" href="{{ base }}/alerts" data-event="home.nav_alerts"
style="--cat: var(--rec-hot)">
<span class="explore-name">Alerts</span>
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
</a>
</div>
</section>
{# --- City hub links --------------------------------------------------
Real HTML links funnelling homepage authority into the ~1000-page
/climate surface. #}
<section class="city-hub" aria-labelledby="cities-title">
<h2 id="cities-title">Climate context for 1,000 cities</h2>
<ul class="city-chips">
{% for c in cities %}
<li><a href="{{ base }}/climate/{{ c.slug }}" data-event="home.nav_city">{{ c.name }}</a></li>
{% endfor %}
</ul>
<p><a href="{{ base }}/climate" data-event="home.nav_cities">Browse all cities →</a></p>
</section>
{% endblock %}
{% block body_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{ base }}/app.js"></script>
{# The records strip renders real temperatures server-side as .temp spans, so
it needs the same converter the SEO pages use or they'd stay in °F while
the toggle says °C. Both import units.js, which the module loader dedupes. #}
<script type="module" src="{{ base }}/climate.js"></script>
{% endblock %}

89
templates/hub.html.j2 Normal file
View file

@ -0,0 +1,89 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>City climates</h1>
<p class="lede">Average temperatures by month, all-time records, and how today compares, for
{{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that
location's own ~45 years of climate history.</p>
<div class="hub-search">
<input type="search" id="hub-q" placeholder="Search a city or country…" autocomplete="off"
aria-label="Search cities" />
</div>
<ul class="hub-results" id="hub-results" hidden></ul>
<div id="hub-directory">
{% for country, cs in groups.items() %}
<details class="hub-country">
<summary>{{ country }} <span class="hub-count">{{ cs | length }}</span></summary>
<ul class="city-links">
{% for c in cs %}<li><a href="{{ base }}/climate/{{ c.slug }}">{{ c.name }}</a></li>{% endfor %}
</ul>
</details>
{% endfor %}
</div>
</article>
{% raw %}
<script>
(function () {
var q = document.getElementById("hub-q");
var results = document.getElementById("hub-results");
var directory = document.getElementById("hub-directory");
if (!q || !results || !directory) return;
// Build a flat, alphabetical index from the crawlable directory links.
var index = [];
directory.querySelectorAll("details.hub-country").forEach(function (d) {
var country = (d.querySelector("summary").firstChild.textContent || "").trim();
d.querySelectorAll(".city-links a").forEach(function (a) {
index.push({ name: a.textContent, country: country, href: a.getAttribute("href") });
});
});
index.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
function esc(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function render() {
var term = q.value.trim().toLowerCase();
if (!term) {
results.hidden = true;
results.innerHTML = "";
directory.hidden = false;
return;
}
directory.hidden = true;
results.hidden = false;
var matches = index.filter(function (e) {
return e.name.toLowerCase().indexOf(term) >= 0 ||
e.country.toLowerCase().indexOf(term) >= 0;
});
if (!matches.length) {
results.innerHTML = '<li class="hub-empty muted">No cities match “' + esc(q.value.trim()) + "”.</li>";
return;
}
results.innerHTML = matches.slice(0, 300).map(function (e) {
return '<li><a href="' + e.href + '">' + esc(e.name) + "</a>" +
'<span class="hub-r-country">' + esc(e.country) + "</span></li>";
}).join("") + (matches.length > 300
? '<li class="hub-empty muted">…and ' + (matches.length - 300) + " more. Keep typing to narrow.</li>"
: "");
}
q.addEventListener("input", render);
})();
</script>
{% endraw %}
{% endblock %}

63
templates/month.html.j2 Normal file
View file

@ -0,0 +1,63 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather in {{ display }} in {{ month_name }}</h1>
<p class="lede">In an average {{ month_name }}, {{ name }} sees a daily high around
<b class="t-inline t-{{ avg_high_cls }}">{{ avg_high }}</b> and a low around
<b class="t-inline t-{{ avg_low_cls }}">{{ avg_low }}</b>, based on
{{ year_range[0] }}{{ year_range[1] }} of local climate records.</p>
<table class="normals-table" style="max-width:520px">
<tbody>
{% for label, value in stats %}
<tr><th style="width:55%">{{ label }}</th><td>{{ value }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if records %}
<h2>{{ month_name }} records</h2>
<p class="muted">The most extreme {{ month_name }} on record for each metric, across
{{ year_range[0] }}{{ year_range[1] }}. For rainfall, the wettest and driest are by
total {{ month_name }} accumulation.</p>
<div class="records-grid">
{% for r in records %}
<div class="record-card">
<h3 class="rc-metric">{{ r.label }}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{{ r.high_tag }}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{{ r.low_tag }}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">See {{ name }}'s live weather grade →</a></p>
<div class="travel-note">
<p>Visiting <b>{{ name }}</b> in {{ month_name }}? See how its comfort stacks up against
where you live.</p>
<a class="cta" href="{{ compare_url }}">Compare {{ name }} with your city →</a>
</div>
<p class="climate-foot muted">
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}"> {{ prev.name }}</a> ·
<a href="{{ base }}/climate/{{ city.slug }}">{{ name }} climate overview</a> ·
<a href="{{ base }}/climate/{{ city.slug }}/{{ next.slug }}">{{ next.name }} </a>
</p>
</article>
{% endblock %}

39
templates/privacy.html.j2 Normal file
View file

@ -0,0 +1,39 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Privacy</h1>
<p class="lede">Thermograph is free, has no ads, and needs no account. It is built so that
there is very little about you to collect in the first place.</p>
<h2>Your location</h2>
<p>Thermograph never looks up your location from your IP address. The only way the site
learns where you are is if you press <b>Use my location</b>, which asks your browser for
permission. You can decline, and picking a place on the map works just as well.</p>
<p>Whichever way you choose a place, the coordinates are sent to the server only as part of
the ordinary request for that grid square's climate data, and are not logged beyond the
normal web-server request handling every site does. The place you picked is remembered in
your own browser's local storage, not on the server, so clearing your browser data
forgets it.</p>
<h2>Analytics</h2>
<p>There is no analytics library, no cookies for tracking, and no per-visitor identifier.
The server keeps simple aggregate counts (how many people opened a page or tapped a
button, and which site referred them) with nothing tying any of it to an individual.</p>
<h2>Email</h2>
<p>If you sign up for the monthly digest, your address is used for that digest and nothing
else. It is never sold or shared, and every message can unsubscribe you.</p>
<h2>Accounts</h2>
<p>An account is optional and only exists to hold weather alerts you have asked for. It
stores your email address and the places you are watching.</p>
<p class="muted">Questions: see <a href="{{ base }}/about">About &amp; methodology</a>.</p>
</article>
{% endblock %}

96
templates/records.html.j2 Normal file
View file

@ -0,0 +1,96 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
{# A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted
chips with the date each occurred. Used for the daytime-high and overnight-low
columns of the month and season tables. #}
{% macro extremes(metric) %}
{% if metric and metric.warm and metric.cold %}
<div class="rec-ext"><span class="rec-arrow up" aria-hidden="true">▲</span><span class="t-inline t-{{ metric.warm.cls }}">{{ metric.warm.txt }}</span><span class="rec-on">{{ metric.warm.date }}</span></div>
<div class="rec-ext"><span class="rec-arrow down" aria-hidden="true">▼</span><span class="t-inline t-{{ metric.cold.cls }}">{{ metric.cold.txt }}</span><span class="rec-on">{{ metric.cold.date }}</span></div>
{% else %}—{% endif %}
{% endmacro %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} weather records</h1>
<p class="lede">Record high and low temperatures for <b>{{ display }}</b>, by month, by season, and
all-time, with the dates they occurred, across {{ year_range[0] }}{{ year_range[1] }} of daily
climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached
{{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
{{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p>
<section class="climate-records-section">
<h2>Records by month</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in
each calendar month, so each column shows its record high <i>and</i> its record low. Tap a
month for its full averages and typical range.</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Month</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{% for m in monthly %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td>{{ extremes(m.high) }}</td>
<td>{{ extremes(m.low) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>Records by season</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each
meteorological season ({{ hemisphere }} Hemisphere, each a three-month span).</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Season</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{% for s in seasonal %}
<tr>
<td>{{ s.name }} <span class="season-span">({{ s.span }})</span></td>
<td>{{ extremes(s.high) }}</td>
<td>{{ extremes(s.low) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>All-time records</h2>
<p>The most extreme value {{ name }} has recorded for each metric across its entire history.</p>
<div class="records-grid">
{% for r in rows %}
<div class="record-card">
<h3 class="rc-metric">{{ r.label }}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
</div>
{% endfor %}
</div>
<p class="records-note muted">For rainfall, the “driest” figure is the <b>longest run of consecutive
days without measurable rain</b>, dated to when that dry spell began.</p>
</section>
<p><a class="cta" href="{{ base }}/#{{ '%.5f,%.5f' | format(city.lat, city.lon) }}">Grade {{ name }}'s weather now →</a></p>
<p class="climate-foot muted"><a href="{{ base }}/climate/{{ city.slug }}"> Back to {{ name }} climate</a></p>
</article>
{% endblock %}

120
tests/conftest.py Normal file
View file

@ -0,0 +1,120 @@
"""Shared test setup for the SSR service.
Stage-3-transitional: this package still lives in the same repo as the
backend, so a `client` fixture can fake api_client by calling the REAL
backend payload builders (api.content_payloads) against the same synthetic
history/recent fixtures backend/tests/conftest.py uses, instead of hand-
maintaining a parallel set of literal JSON fixtures. This means a shape drift
between content_payloads.py and how content.py consumes it fails here, not
just in backend/tests/web/test_content.py -- and the two suites assert on
identical numbers, so they can never quietly diverge.
"""
import importlib.util
import os
import sys
import httpx
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
FRONTEND_SSR = os.path.dirname(_HERE)
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
BACKEND = os.path.join(REPO_ROOT, "backend")
sys.path.insert(0, FRONTEND_SSR)
sys.path.insert(0, BACKEND)
os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid")
os.environ.setdefault("THERMOGRAPH_BASE", "/thermograph")
def _load_module(name: str, path: str):
"""importlib rather than a bare `import conftest` -- pytest registers
THIS file's own module under the plain name "conftest" too, so a bare
import here could self-collide instead of resolving to backend's file."""
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# Reuse the exact synthetic weather fixtures backend/tests/conftest.py
# defines (its module-level code also sandboxes data/store/audit paths into a
# tmp dir), so a page rendered here and the same page rendered by the
# backend's own test_content.py agree on every number.
_backend_conftest = _load_module("_backend_test_conftest", os.path.join(BACKEND, "tests", "conftest.py"))
make_history = _backend_conftest.make_history
make_recent = _backend_conftest.make_recent
from api import content_payloads # noqa: E402
from api import sitemap as sitemap_mod # noqa: E402
from data import cities # noqa: E402
import api_client # noqa: E402
import content # noqa: E402
B = content.BASE
SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"
@pytest.fixture(scope="session")
def history():
return make_history()
@pytest.fixture(scope="session")
def recent(history):
return make_recent(history)
def _not_found(detail: str) -> httpx.HTTPStatusError:
req = httpx.Request("GET", "http://backend.invalid/x")
resp = httpx.Response(404, request=req, json={"detail": detail})
return httpx.HTTPStatusError(detail, request=req, response=resp)
@pytest.fixture
def client(monkeypatch, history, recent):
"""A TestClient over the SSR app, with api_client's HTTP calls replaced by
direct calls into the real backend payload builders -- see module
docstring. Origin defaults to the TestClient's own base URL, matching what
a real request through content.py's _origin(request) would forward."""
def _city(slug, *, origin=None):
c = cities.get(slug)
if c is None:
raise _not_found("Unknown city.")
return content_payloads.city_payload(origin or "http://testserver", B, c, history, recent)
def _city_month(slug, month):
c = cities.get(slug)
if c is None:
raise _not_found("Unknown city.")
if month not in content_payloads.MONTH_INDEX:
raise _not_found("Unknown month.")
return content_payloads.month_payload(B, c, history, content_payloads.MONTH_INDEX[month])
def _city_records(slug, *, origin=None):
c = cities.get(slug)
if c is None:
raise _not_found("Unknown city.")
return content_payloads.records_payload(origin or "http://testserver", B, c, history)
def _sitemap():
return [{"path": p, "changefreq": cf, "priority": pr} for p, cf, pr in sitemap_mod.sitemap_entries()]
monkeypatch.setattr(api_client, "city", _city)
monkeypatch.setattr(api_client, "city_month", _city_month)
monkeypatch.setattr(api_client, "city_records", _city_records)
monkeypatch.setattr(api_client, "hub", content_payloads.hub_payload)
monkeypatch.setattr(api_client, "home", content_payloads.home_payload)
monkeypatch.setattr(api_client, "sitemap", _sitemap)
monkeypatch.setattr(api_client, "indexnow_key", lambda: FAKE_INDEXNOW_KEY)
# api_client's TTL cache is keyed by path/origin, not by test -- stale
# entries from an earlier test would otherwise leak real-looking but
# wrong data into this one.
api_client._cache.clear()
import app as appmod
from fastapi.testclient import TestClient
return TestClient(appmod.app)

407
tests/test_content.py Normal file
View file

@ -0,0 +1,407 @@
"""Tests for the SSR content pages, ported from backend/tests/web/test_content.py
(repo-split Stage 3). The `client` fixture (conftest.py) fakes api_client by
calling the real backend payload builders against the same synthetic history
fixture backend/tests uses, so assertions here and there agree on the same
numbers. Tests that exercise backend-only concerns (indexnow submission, the
/api/v2/content/* JSON endpoints themselves, data-layer helpers like
cities.title_name) stay in backend/tests -- this file covers only what the SSR
rendering layer (content.py + format.py) does with that data.
"""
import math
import os
import re
import pytest
import content
import format as fmt
from conftest import B, SLUG
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert "Disallow: /thermograph/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/july</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
def test_city_page_renders_stats_in_html(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b
assert "average temperatures by month" in b.lower()
assert re.search(r"-?\d+°C</span>", b)
def _jsonld_breadcrumbs(html: str) -> list[dict]:
import json
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
assert m, "no JSON-LD block on page"
graph = json.loads(m.group(1))["@graph"]
return [n for n in graph if n["@type"] == "BreadcrumbList"]
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
els = bc["itemListElement"]
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
for e in els[:-1]:
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
def test_jsonld_url_uses_the_ssr_requests_own_origin(client):
# A regression guard for a real bug caught during the port: the jsonld
# "url" field must reflect the browser-facing request the SSR app
# received, not the internal backend-call's own origin.
for path in (f"/climate/{SLUG}", f"/climate/{SLUG}/records"):
html = client.get(B + path).text
m = re.search(r'"url":"(http://testserver[^"]*)"', html)
assert m, f"{path}: jsonld url missing or not on the request's own origin"
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_curated_event_renders(client):
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
assert "Notable weather in New Orleans" in b
assert "Hurricane Katrina" in b
def test_uncurated_city_has_no_event_section(client):
b = client.get(f"{B}/climate/shanghai-cn").text
assert "city-event" not in b
def test_city_travel_cta_prefills_compare(client):
b = client.get(f"{B}/climate/{SLUG}").text
assert "Thinking of visiting" in b
assert "/compare#loc=" in b
def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
def test_month_records_cover_every_metric_high_and_low(client):
b = client.get(f"{B}/climate/{SLUG}/july").text
assert "July records" in b
for label in ("High", "Low", "Precip"):
assert f'class="rc-metric">{label}<' in b
n = b.count('rc-row rc-high')
assert n >= 3 and b.count('rc-row rc-low') == n
assert "Highest" in b and "Lowest" in b
assert "Wettest" in b and "Driest" in b
def test_records_page_has_monthly_and_seasonal(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Records by month" in b
for month in ("January", "July", "December"):
assert month in b
assert f"/climate/{SLUG}/january" in b
assert "Records by season" in b
assert "Northern Hemisphere" in b
assert 'Winter <span class="season-span">(DecFeb)</span>' in b
assert 'Summer <span class="season-span">(JunAug)</span>' in b
assert "All-time records" in b
assert '"@type":"Dataset"' in b
assert re.search(r"-?\d+°C</span>", b)
assert "monthly" in b.lower() and "seasonal" in b.lower()
def test_records_seasons_flip_in_southern_hemisphere(client):
b = client.get(f"{B}/climate/sao-paulo-br/records").text
assert "Southern Hemisphere" in b
assert 'Summer <span class="season-span">(DecFeb)</span>' in b
assert 'Winter <span class="season-span">(JunAug)</span>' in b
def test_climate_pages_are_colour_coded(client):
city = client.get(f"{B}/climate/{SLUG}").text
assert "t-cell t-" in city
assert "range-fill" in city and "--c1:var(--" in city
recs = client.get(f"{B}/climate/{SLUG}/records").text
assert "t-inline t-" in recs
month = client.get(f"{B}/climate/{SLUG}/july").text
assert "t-inline t-" in month
def test_records_show_both_extremes_per_metric(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Daytime high" in b and "Overnight low" in b
assert "" in b and "" in b
assert b.count('class="rec-ext"') >= 48
def test_hub_glossary_about(client):
assert client.get(f"{B}/climate").status_code == 200
assert client.get(f"{B}/glossary").status_code == 200
assert client.get(f"{B}/glossary/percentile").status_code == 200
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
assert client.get(f"{B}/about").status_code == 200
def test_static_page_titles_come_from_content_yaml(client):
from markupsafe import escape
for path, key in ((f"{B}/climate", "hub"), (f"{B}/glossary", "glossary_index"),
(f"{B}/about", "about"), (f"{B}/privacy", "privacy")):
html = client.get(path).text
assert f"<title>{escape(content.PAGES[key]['title'])}</title>" in html, path
def test_glossary_terms_come_from_content_yaml(client):
html = client.get(f"{B}/glossary").text
for entry in content.GLOSSARY.values():
assert entry["term"] in html
term_html = client.get(f"{B}/glossary/percentile").text
assert content.GLOSSARY["percentile"]["short"] in term_html
def test_footer_has_no_leaked_jinja_comment(client):
for path in (f"{B}/about", f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
footer = re.search(r"<footer.*?</footer>", b, re.S)
assert footer, f"no footer on {path}"
assert "#}" not in footer.group(0) and "{#" not in footer.group(0), path
def test_page_titles_lead_with_city_and_payload(client):
for path, must_start, payload in (
(f"{B}/climate/{SLUG}", "London, GB climate", "how unusual it is now"),
(f"{B}/climate/{SLUG}/july", "London, GB in July", "normal weather"),
(f"{B}/climate/{SLUG}/records", "London, GB weather records", "hottest & coldest"),
):
b = client.get(path).text
title = re.search(r"<title>(.*?)</title>", b, re.S).group(1)
title = title.replace("&amp;", "&")
assert title.startswith(must_start), title
assert payload in title, title
assert "England, United Kingdom" not in title
assert len(must_start) <= 40
og = re.search(r'<meta property="og:title" content="(.*?)"', b, re.S).group(1)
assert og == re.search(r"<title>(.*?)</title>", b, re.S).group(1)
def test_meta_descriptions_lead_with_real_numbers(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july",
f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
assert len(desc) <= 155, f"{len(desc)}: {desc}"
assert re.search(r"-?\d+°C", desc), desc
assert desc.endswith("years of local history."), desc
rec = client.get(f"{B}/climate/{SLUG}/records").text
desc = re.search(r'<meta name="description" content="(.*?)"', rec, re.S).group(1)
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
_TEMP_SPAN = re.compile(r'<span class="temp" data-temp-f="(-?[\d.]+)"([^>]*)>(-?\d+)°([CF]?)</span>')
def _temp_spans(html):
return [(float(f), int(n), letter) for f, _attrs, n, letter in _TEMP_SPAN.findall(html)]
def test_climate_pages_carry_convertible_temps(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
spans = _temp_spans(b)
assert spans, f"no temperature spans on {path}"
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None
def test_units_default_to_the_citys_country(client):
gb = client.get(f"{B}/climate/{SLUG}").text
us = client.get(f"{B}/climate/seattle-washington-us").text
gb_spans, us_spans = _temp_spans(gb), _temp_spans(us)
assert gb_spans and us_spans
assert {letter for _f, _n, letter in gb_spans if letter} == {"C"}
assert {letter for _f, _n, letter in us_spans if letter} == {"F"}
assert any(math.floor(f + 0.5) != n for f, n, _l in gb_spans), "data-temp-f was converted"
assert all(math.floor(f + 0.5) == n for f, n, _l in us_spans)
assert 'data-unit-default="C"' in gb
assert 'data-unit-default="F"' in us
_PRECIP_SPAN = re.compile(r'<span class="precip" data-precip-in="([\d.]+)">([\d.]+) (in|mm)</span>')
def test_precip_and_wind_follow_the_citys_unit(client):
gb = client.get(f"{B}/climate/{SLUG}/records").text
us = client.get(f"{B}/climate/seattle-washington-us/records").text
gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us)
assert gb_p and us_p
assert {u for _raw, _shown, u in gb_p} == {"mm"}
assert {u for _raw, _shown, u in us_p} == {"in"}
for raw, shown, _u in gb_p:
assert abs(float(shown) - float(raw) * 25.4) < 0.51, (raw, shown)
for raw, shown, _u in us_p:
assert abs(float(shown) - float(raw)) < 0.005, (raw, shown)
def test_wind_and_humidity_render_per_unit_system():
# The synthetic history carries only tmax/tmin/precip, so wind and
# humidity never reach a rendered page in these tests -- exercise
# format.py's renderers directly, same as the backend's equivalent test.
with fmt.unit_scope("F"):
assert fmt.wind_text(10) == "10 mph"
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
with fmt.unit_scope("C"):
assert fmt.wind_text(10) == "16 km/h"
assert 'data-wind-mph="10.0"' in fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
def test_precip_precision_differs_by_unit():
with fmt.unit_scope("F"):
assert fmt.precip_text(0.04) == "0.04 in"
with fmt.unit_scope("C"):
assert fmt.precip_text(0.04) == "1 mm"
assert fmt.precip_text(1.81) == "46 mm"
def test_measure_conversion_constants_match_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1))
kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1))
assert mm == fmt.MM_PER_IN
assert kmh == fmt.KMH_PER_MPH
def test_unit_default_absent_without_a_city(client):
for path in (f"{B}/", f"{B}/climate", f"{B}/about", f"{B}/glossary"):
assert "data-unit-default" not in client.get(path).text, path
def test_unit_does_not_leak_between_requests(client):
assert 'data-unit-default="C"' in client.get(f"{B}/climate/{SLUG}").text
about = client.get(f"{B}/about").text
assert "data-unit-default" not in about
assert {l for _f, _n, l in _temp_spans(about) if l} == {"F"}
us = client.get(f"{B}/climate/seattle-washington-us").text
assert {l for _f, _n, l in _temp_spans(us) if l} == {"F"}
gb_again = client.get(f"{B}/climate/{SLUG}").text
assert {l for _f, _n, l in _temp_spans(gb_again) if l} == {"C"}
def test_f_country_list_matches_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
literal = re.search(r"F_REGIONS = new Set\(\[(.*?)\]\)", src, re.S).group(1)
assert set(re.findall(r'"([A-Z]{2})"', literal)) == set(fmt.F_COUNTRIES)
def test_celsius_rounding_matches_js():
assert fmt._round_half_up(0.5) == 1
assert fmt._round_half_up(1.5) == 2
assert fmt._round_half_up(2.5) == 3
assert fmt._round_half_up(-0.5) == 0
assert fmt._c(31.1) == 0
def test_pct_ordinal_matches_backend_grading():
# format.pct_ordinal is a ported copy of backend/data/grading.py's
# pct_ordinal (the frontend can no longer import backend code) -- pin the
# two against the same cases so a future edit to either can't drift silently.
assert fmt.pct_ordinal(66.4) == "66th"
assert fmt.pct_ordinal(1.2) == "1st"
assert fmt.pct_ordinal(22) == "22nd"
assert fmt.pct_ordinal(3) == "3rd"
assert fmt.pct_ordinal(11) == "11th"
assert fmt.pct_ordinal(99.6) == "99th"
assert fmt.pct_ordinal(100) == "99th"
assert fmt.pct_ordinal(0) == "1st"
assert fmt.pct_ordinal(None) == ""
def test_city_page_percentiles_are_real_ordinals(client):
html = client.get(f"{B}/climate/{SLUG}").text
assert "th pct" in html or "st pct" in html or "nd pct" in html or "rd pct" in html
for bad in re.findall(r"\d+\.\d+(?:st|nd|rd|th)|100th|\b0th|\b\d*[123]th", html):
raise AssertionError(f"malformed percentile ordinal: {bad!r}")
def test_city_page_etag_is_stable(client):
r1 = client.get(f"{B}/climate/{SLUG}")
r2 = client.get(f"{B}/climate/{SLUG}")
assert r1.headers["etag"] == r2.headers["etag"]
r3 = client.get(f"{B}/climate/{SLUG}", headers={"If-None-Match": r1.headers["etag"]})
assert r3.status_code == 304
def test_seo_pages_load_unit_and_account_scripts(client):
b = client.get(f"{B}/climate/{SLUG}").text
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
assert f'src="{src}"' in b
def test_indexnow_key_file_served(client):
from conftest import FAKE_INDEXNOW_KEY
r = client.get(f"{B}/{FAKE_INDEXNOW_KEY}.txt")
assert r.status_code == 200
assert r.text.strip() == FAKE_INDEXNOW_KEY
assert r.headers["content-type"].startswith("text/plain")
def test_sitemap_lastmod_is_stable(client):
import datetime
body = client.get(f"{B}/sitemap.xml").text
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
assert len(mods) == 1
datetime.date.fromisoformat(next(iter(mods)))
def test_search_verification_meta(client, monkeypatch):
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
seo = client.get(f"{B}/climate/{SLUG}").text
home = client.get(f"{B}/").text
for b in (seo, home):
assert '<meta name="google-site-verification" content="gtok123">' in b
assert '<meta name="msvalidate.01" content="btok456">' in b
BRAND_PAGES = [B + p for p in ("/", "/climate", "/glossary", "/about", "/privacy")]
@pytest.mark.parametrize("path", BRAND_PAGES)
def test_brand_lockup_links_home(client, path):
html = client.get(path).text
brand = html.split('<div class="brand">', 1)[1].split("</header>", 1)[0]
head = brand.split("</h1>")[0] if "<h1" in brand else brand.split("</p>")[0]
assert "<a href=" in head, f"{path}: brand is not a link"
href = head.split('<a href="', 1)[1].split('"', 1)[0]
assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
f"{path}: the mark sits outside the home link"