Repo-split Stage 3: frontend SSR service (in-repo, not yet live) (#13)
This commit is contained in:
parent
4e52a9c418
commit
1738f61c8f
18 changed files with 2115 additions and 0 deletions
84
api_client.py
Normal file
84
api_client.py
Normal 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
36
app.py
Normal 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
402
content.py
Normal 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
69
content_loader.py
Normal 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
173
format.py
Normal 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
32
paths.py
Normal 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
36
templates/about.html.j2
Normal 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 sq 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>±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
113
templates/base.html.j2
Normal 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 & 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 · NASA POWER · 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
111
templates/city.html.j2
Normal 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 more →</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 → 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 %}
|
||||||
19
templates/glossary.html.j2
Normal file
19
templates/glossary.html.j2
Normal 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 & 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 %}
|
||||||
21
templates/glossary_term.html.j2
Normal file
21
templates/glossary_term.html.j2
Normal 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
205
templates/home.html.j2
Normal 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 sq mi cell, builds a ±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 }}&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 }} · {{ r.pct_label }} pct
|
||||||
|
{%- if r.normal is not none %} · normal {{ temp(r.normal) }}{% endif %}
|
||||||
|
{%- if r.date_label %} · {{ 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>
|
||||||
|
· Data: ERA5 via Open-Meteo · NASA POWER · 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
89
templates/hub.html.j2
Normal 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
63
templates/month.html.j2
Normal 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
39
templates/privacy.html.j2
Normal 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 & methodology</a>.</p>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
96
templates/records.html.j2
Normal file
96
templates/records.html.j2
Normal 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
120
tests/conftest.py
Normal 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
407
tests/test_content.py
Normal 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">(Dec–Feb)</span>' in b
|
||||||
|
assert 'Summer <span class="season-span">(Jun–Aug)</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">(Dec–Feb)</span>' in b
|
||||||
|
assert 'Winter <span class="season-span">(Jun–Aug)</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("&", "&")
|
||||||
|
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"
|
||||||
Loading…
Reference in a new issue