diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..ac29271 --- /dev/null +++ b/api_client.py @@ -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) diff --git a/app.py b/app.py new file mode 100644 index 0000000..789373a --- /dev/null +++ b/app.py @@ -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") diff --git a/content.py b/content.py new file mode 100644 index 0000000..e1b5ce1 --- /dev/null +++ b/content.py @@ -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 +# 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 = ['', + ''] + for e in entries: + parts.append( + f"{base_url}{e['path']}{_BOOT_DATE}" + f"{e['changefreq']}{e['priority']}" + ) + parts.append("") + return Response("\n".join(parts), media_type="application/xml") + + +def head_verify_html() -> Markup: + """Search-engine ownership-verification 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('').format(google)) + if bing: + metas.append(Markup('').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) diff --git a/content_loader.py b/content_loader.py new file mode 100644 index 0000000..b49ce0c --- /dev/null +++ b/content_loader.py @@ -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 diff --git a/format.py b/format.py new file mode 100644 index 0000000..341f375 --- /dev/null +++ b/format.py @@ -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('{}°{}').format( + f, _shown(f), _letter()) + + +def temp_bare(f) -> Markup | str: + if f is None: + return "—" + return Markup('{}°').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('{}').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('{}').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), + } diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..547edfe --- /dev/null +++ b/paths.py @@ -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") diff --git a/templates/about.html.j2 b/templates/about.html.j2 new file mode 100644 index 0000000..1c38215 --- /dev/null +++ b/templates/about.html.j2 @@ -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 %} + +
+

About Thermograph

+

Thermograph answers one question for any point on Earth: how unusual is this + weather? Instead of an absolute temperature, it shows where a day falls in that exact + location's own climate history.

+ +

Where the data comes from

+

The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation) + comes from the ERA5 reanalysis (ECMWF), served via Open-Meteo. + Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's + no weather station, anywhere on the ~4 sq mi grid.

+ +

How a day is graded

+

For each metric and date, Thermograph builds a reference distribution from every historical day + within ±7 days of that day-of-year, a 15-day seasonal window across all years. The + observed value is placed on that distribution as an empirical + percentile, then mapped to a grade from “Below Normal” through “High” to “Near Record”.

+

Everything is relative to the location: 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 here. See the grade guide for the full scale.

+ +

Freshness

+

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.

+ +

Open the weather grader →

+
+{% endblock %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 new file mode 100644 index 0000000..c5ed3b4 --- /dev/null +++ b/templates/base.html.j2 @@ -0,0 +1,113 @@ + + + + + + {{ head_verify() }} + {% block title %}Thermograph{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + {% block head_extra %}{% endblock %} + + {% block jsonld %}{% endblock %} + + +
+
+
+ {# 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

. 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">Thermograph +

How unusual is the weather? Graded against ~45 years of local climate history.

+
+ +
+
+ + + {% block content %}{% endblock %} + + +
+ {# 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. #} + {# +
+ +
+ + +
+

No spam, no sharing your address, unsubscribe anytime.

+ +
+ #} + +

Free. No ads. No tracking. No account needed. Built by one person in the open.

+

Thermograph grades weather by its percentile against ~45 years of local + climate history. Data: ERA5 via Open-Meteo · NASA POWER · MET Norway.

+

Made by emigriffith.dev + @

+
+ {% block body_scripts %} + + + + + {% endblock %} + + + + diff --git a/templates/city.html.j2 b/templates/city.html.j2 new file mode 100644 index 0000000..969ae93 --- /dev/null +++ b/templates/city.html.j2 @@ -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 %}{% endblock %} +{% block content %} + + +
+

{{ display }} climate

+

Average temperatures, precipitation and all-time records for {{ display }}, + 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 + {{ warmest.name }}, averaging a high of {{ warmest.high }}, and its coldest is + {{ coldest.name }}, with an average low of {{ coldest.low }}.{% endif %}

+ + {% if flavor %} +

{{ flavor.extract }}{% if flavor.url %} + via Wikipedia{% endif %}

+ {% endif %} + + {% if today %} +
+

How today compares

+

Latest recorded day: {{ today.date }}. Each reading placed on {{ display }}'s + own ±7-day seasonal distribution.

+
+ {% for c in today.cards %} +
+ {{ c.label }} + {{ c.value }} + {% if c.percentile is not none %}{{ c.grade }} · {{ c.percentile|ordinal }} pct + {% else %}{{ c.grade }}{% endif %} +
+ {% endfor %} +
+

Open {{ name }} in the live weather grader →

+
+ {% endif %} + + {% if event %} +
+

Notable weather in {{ name }}

+

{{ event.text }}{% if event.url %} Read more →{% endif %}

+
+ {% endif %} + +
+

{{ name }} average temperatures by month

+

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 {{ wettest.name }} ({{ wettest.precip }} on an average day).{% endif %}

+
+ + + + {% for m in months %} + + + + + + + {% endfor %} + +
MonthAvg highAvg lowAvg precip
{{ m.name }}{{ m.high }}{{ m.low }}{{ m.precip }}
+
+ + +

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.

+
+ +
+

Thinking of visiting {{ name }}?

+

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.

+

Compare {{ name }}'s comfort with your city →

+
+ + {% if records.tmax or records.tmin %} +
+

Record extremes

+
    + {% if records.tmax %}
  • Hottest day on record: {{ temp(records.tmax.max) }} on {{ records.tmax.max_date }}
  • {% endif %} + {% if records.tmin %}
  • Coldest day on record: {{ temp(records.tmin.min) }} on {{ records.tmin.min_date }}
  • {% endif %} +
+

All {{ name }} weather records →

+
+ {% endif %} + +

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. + How the grades work →

+
+{% endblock %} diff --git a/templates/glossary.html.j2 b/templates/glossary.html.j2 new file mode 100644 index 0000000..65a7c71 --- /dev/null +++ b/templates/glossary.html.j2 @@ -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 %} + +
+

Weather & climate glossary

+

Plain-language definitions of the terms Thermograph uses to grade the weather.

+ {% for t in terms %} +
+

{{ t.term }}

+

{{ t.short }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/glossary_term.html.j2 b/templates/glossary_term.html.j2 new file mode 100644 index 0000000..e140efe --- /dev/null +++ b/templates/glossary_term.html.j2 @@ -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 %}{% endblock %} +{% block content %} + + +{% endblock %} diff --git a/templates/home.html.j2 b/templates/home.html.j2 new file mode 100644 index 0000000..7d7f88e --- /dev/null +++ b/templates/home.html.j2 @@ -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 %} + +{% endblock %} + +{% block jsonld %} + +{% 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. #} +
+
+

How unusual is your weather?

+

Any day, anywhere on Earth, graded against 45 years of that place's own history.

+
+ +
+ {# --cat carries the band colour, the same convention .normal-card uses, + so the band token stays the single source of truth. #} +
+

+ {%- if unusual -%} + Today in {{ unusual.display }} + {%- else -%} + Today + {%- endif -%} +

+

{{ unusual.grade if unusual else '—' }}

+

+ {%- 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 -%} +

+ {% if unusual and unusual.is_default %} +

the most unusual weather we're tracking{% if stale %} · as of {{ unusual.date }}{% endif %}

+ {% endif %} +
+
+ {# app.js hides the locate button outside a secure context, where the + browser refuses geolocation and it could only ever fail. #} + + +
+ +

See your week ↓

+
+
+ +
+
+ + +
+
+ + + What do the grades mean? → +
+
+ +
+
+

Find a location to begin.

+

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.

+
+ +
+ + {# Sits outside #panel on purpose: app.js rewrites results.innerHTML + wholesale on every grade, so anything inside it would be destroyed. #} +

+ Free. No ads. No tracking. No account needed. + How we work → +

+ + {# --- 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 %} +
+

Unusual right now

+ +

See all records →

+
+ {% endif %} + + {# --- How it works ---------------------------------------------------- #} +
+

How it works

+
    +
  1. We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.
  2. +
  3. Today gets compared to the same two weeks of the year, every year back to 1980.
  4. +
  5. The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.
  6. +
+

+ Full methodology → + · Data: ERA5 via Open-Meteo · NASA POWER · MET Norway +

+
+ + {# --- Explore --------------------------------------------------------- #} +
+

Explore

+ {# 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. #} + +
+ + {# --- City hub links -------------------------------------------------- + Real HTML links funnelling homepage authority into the ~1000-page + /climate surface. #} +
+

Climate context for 1,000 cities

+ +

Browse all cities →

+
+{% endblock %} + +{% block body_scripts %} + + + {# 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. #} + +{% endblock %} diff --git a/templates/hub.html.j2 b/templates/hub.html.j2 new file mode 100644 index 0000000..9352b66 --- /dev/null +++ b/templates/hub.html.j2 @@ -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 %} + +
+

City climates

+

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.

+ + + + +
+ {% for country, cs in groups.items() %} +
+ {{ country }} {{ cs | length }} + +
+ {% endfor %} +
+
+ +{% raw %} + +{% endraw %} +{% endblock %} diff --git a/templates/month.html.j2 b/templates/month.html.j2 new file mode 100644 index 0000000..6af2118 --- /dev/null +++ b/templates/month.html.j2 @@ -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 %} + + +
+

Weather in {{ display }} in {{ month_name }}

+

In an average {{ month_name }}, {{ name }} sees a daily high around + {{ avg_high }} and a low around + {{ avg_low }}, based on + {{ year_range[0] }}–{{ year_range[1] }} of local climate records.

+ + + + {% for label, value in stats %} + + {% endfor %} + +
{{ label }}{{ value }}
+ + {% if records %} +

{{ month_name }} records

+

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.

+
+ {% for r in records %} +
+

{{ r.label }}

+
+ {{ r.high_tag }} + {{ r.high }} + {{ r.high_date }} +
+
+ {{ r.low_tag }} + {{ r.low }} + {{ r.low_date }} +
+
+ {% endfor %} +
+ {% endif %} + +

See {{ name }}'s live weather grade →

+ +
+

Visiting {{ name }} in {{ month_name }}? See how its comfort stacks up against + where you live.

+ Compare {{ name }} with your city → +
+ +

+ ‹ {{ prev.name }} · + {{ name }} climate overview · + {{ next.name }} › +

+
+{% endblock %} diff --git a/templates/privacy.html.j2 b/templates/privacy.html.j2 new file mode 100644 index 0000000..82a6e91 --- /dev/null +++ b/templates/privacy.html.j2 @@ -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 %} + +
+

Privacy

+

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.

+ +

Your location

+

Thermograph never looks up your location from your IP address. The only way the site + learns where you are is if you press Use my location, which asks your browser for + permission. You can decline, and picking a place on the map works just as well.

+

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.

+ +

Analytics

+

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.

+ +

Email

+

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.

+ +

Accounts

+

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.

+ +

Questions: see About & methodology.

+
+{% endblock %} diff --git a/templates/records.html.j2 b/templates/records.html.j2 new file mode 100644 index 0000000..eb53b61 --- /dev/null +++ b/templates/records.html.j2 @@ -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 %}{% 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 %} +
{{ metric.warm.txt }}{{ metric.warm.date }}
+
{{ metric.cold.txt }}{{ metric.cold.date }}
+{% else %}—{% endif %} +{% endmacro %} + + +
+

{{ display }} weather records

+

Record high and low temperatures for {{ display }}, 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 %}

+ +
+

Records by month

+

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 and its record low. Tap a + month for its full averages and typical range.

+
+ + + + {% for m in monthly %} + + + + + + {% endfor %} + +
MonthDaytime highOvernight low
{{ m.name }}{{ extremes(m.high) }}{{ extremes(m.low) }}
+
+
+ +
+

Records by season

+

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).

+
+ + + + {% for s in seasonal %} + + + + + + {% endfor %} + +
SeasonDaytime highOvernight low
{{ s.name }} ({{ s.span }}){{ extremes(s.high) }}{{ extremes(s.low) }}
+
+
+ +
+

All-time records

+

The most extreme value {{ name }} has recorded for each metric across its entire history.

+
+ {% for r in rows %} +
+

{{ r.label }}

+
+ {% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %} + {{ r.high }} + {{ r.high_date }} +
+
+ {% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %} + {{ r.low }} + {{ r.low_date }} +
+
+ {% endfor %} +
+

For rainfall, the “driest” figure is the longest run of consecutive + days without measurable rain, dated to when that dry spell began.

+
+ +

Grade {{ name }}'s weather now →

+

‹ Back to {{ name }} climate

+
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d212709 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_content.py b/tests/test_content.py new file mode 100644 index 0000000..d476a28 --- /dev/null +++ b/tests/test_content.py @@ -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 "" in r.text + assert f"/climate/{SLUG}/july" in r.text + assert f"/climate/{SLUG}/records" 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", b) + + +def _jsonld_breadcrumbs(html: str) -> list[dict]: + import json + m = re.search(r'', 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 (Dec–Feb)' in b + assert 'Summer (Jun–Aug)' in b + assert "All-time records" in b + assert '"@type":"Dataset"' in b + assert re.search(r"-?\d+°C", 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 (Dec–Feb)' in b + assert 'Winter (Jun–Aug)' 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"{escape(content.PAGES[key]['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"", 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"(.*?)", 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'(.*?)", 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']*)>(-?\d+)°([CF]?)') + + +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'([\d.]+) (in|mm)') + + +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 '10 mph' == 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"([^<]+)", 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 '' in b + assert '' 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('
', 1)[1].split("", 1)[0] + head = brand.split("")[0] if "")[0] + assert "