Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
"""Tests for the homepage: the cache-only "unusual right now" precompute, the
|
|
|
|
|
server-rendered surfaces, and the digest signup.
|
|
|
|
|
|
|
|
|
|
The precompute must never touch the network — a sweep over ~1000 cities that
|
|
|
|
|
fetched would burst the archive quota — so the tests here assert that the
|
|
|
|
|
fetching climate helpers are not called at all.
|
|
|
|
|
"""
|
|
|
|
|
import datetime
|
|
|
|
|
import json
|
2026-07-18 07:55:43 +00:00
|
|
|
import pathlib
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
2026-07-21 16:09:35 +00:00
|
|
|
from api import homepage
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
|
|
|
|
from data import cities
|
|
|
|
|
from data import climate
|
2026-07-21 00:24:17 +00:00
|
|
|
from data import store
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import content
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
|
|
|
|
B = "/thermograph"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def client():
|
|
|
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def no_feed(monkeypatch):
|
|
|
|
|
"""The homepage with no precomputed feed at all."""
|
|
|
|
|
monkeypatch.setattr(homepage, "load", lambda: None)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pick(name, pct, cls, grade, tail, slug=None):
|
|
|
|
|
return {
|
|
|
|
|
"slug": slug or (name.lower().replace(" ", "-") + "-xx"),
|
|
|
|
|
"name": name, "display": f"{name}, Somewhere",
|
|
|
|
|
"lat": 1.0, "lon": 2.0, "cell_id": "1_2",
|
|
|
|
|
"date": datetime.date.today().isoformat(),
|
|
|
|
|
"metric": "tmax", "metric_label": "High temp", "window_label": "mid-July",
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
"value": 99.0, "normal": 88.0, "percentile": pct,
|
|
|
|
|
"pct_label": homepage._pct_label(pct),
|
|
|
|
|
"grade": grade, "grade_label": homepage._grade_label(grade, tail), "cls": cls,
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
"departure": abs(pct - 50), "tail": tail,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the precompute ----------------------------------------------------------
|
|
|
|
|
def test_build_never_fetches_upstream(monkeypatch):
|
|
|
|
|
"""The sweep uses the cache-only readers, never the fetching ones."""
|
|
|
|
|
def boom(*a, **k):
|
|
|
|
|
raise AssertionError("the homepage sweep must not fetch upstream")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
|
|
|
feed = homepage.build(limit=20)
|
|
|
|
|
assert set(feed) >= {"generated_at", "date", "considered", "picks", "ranked"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_grades_cached_cities(monkeypatch, history, recent):
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: recent.clone())
|
|
|
|
|
feed = homepage.build(limit=5)
|
|
|
|
|
assert feed["considered"] == 5
|
|
|
|
|
for row in feed["ranked"]:
|
|
|
|
|
assert row["percentile"] is not None
|
|
|
|
|
assert row["cls"] and not row["cls"].startswith("--") # token name, no prefix
|
|
|
|
|
assert row["tail"] in ("warm", "cold")
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
# The card needs the reading itself and something to compare it against —
|
|
|
|
|
# a percentile alone says nothing about what the weather actually is.
|
|
|
|
|
assert row["value"] is not None
|
|
|
|
|
assert row["normal"] is not None, "the ±7-day median must reach the card"
|
|
|
|
|
assert row["metric_label"] in ("High temp", "Low temp")
|
|
|
|
|
assert row["pct_label"].endswith(("st", "nd", "rd", "th"))
|
|
|
|
|
# "Tehran, Iran", not "Dar es Salaam, Dar es Salaam Region, Tanzania".
|
|
|
|
|
assert row["display"].count(",") <= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_percentile_label_never_reads_100th_or_0th():
|
|
|
|
|
"""An empirical percentile can round to 100 (or 0). "100th percentile" reads
|
|
|
|
|
as a bug rather than "as hot as it has ever been", so the label floors into
|
|
|
|
|
the real 1-99 range."""
|
|
|
|
|
assert homepage._pct_label(99.6) == "99th"
|
|
|
|
|
assert homepage._pct_label(100.0) == "99th"
|
|
|
|
|
assert homepage._pct_label(0.4) == "1st"
|
|
|
|
|
assert homepage._pct_label(0.0) == "1st"
|
|
|
|
|
# The ordinary teens/ones rules still apply in between.
|
|
|
|
|
assert homepage._pct_label(11) == "11th"
|
|
|
|
|
assert homepage._pct_label(22) == "22nd"
|
|
|
|
|
assert homepage._pct_label(3) == "3rd"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_near_record_is_disambiguated_by_tail():
|
|
|
|
|
""""Near Record" is the band label at BOTH ends of the scale. Unqualified, a
|
|
|
|
|
cold extreme and a hot one render identically and colour becomes the only
|
|
|
|
|
signal — which the design rules forbid."""
|
|
|
|
|
assert homepage._grade_label("Near Record", "warm") == "Near Record hot"
|
|
|
|
|
assert homepage._grade_label("Near Record", "cold") == "Near Record cold"
|
|
|
|
|
# Tiers that already read directionally are left alone.
|
|
|
|
|
assert homepage._grade_label("Very High", "warm") == "Very High"
|
|
|
|
|
assert homepage._grade_label("Low", "cold") == "Low"
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_cell_cache_is_skipped_not_fatal(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: None)
|
|
|
|
|
feed = homepage.build(limit=10)
|
|
|
|
|
assert feed["considered"] == 0 and feed["ranked"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_survives_missing_and_corrupt_file(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "nope.json"))
|
|
|
|
|
assert homepage.load() is None
|
|
|
|
|
|
|
|
|
|
bad = tmp_path / "homepage.json"
|
|
|
|
|
bad.write_text("{not json")
|
|
|
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(bad))
|
|
|
|
|
assert homepage.load() is None
|
|
|
|
|
|
|
|
|
|
bad.write_text('{"unexpected": true}')
|
|
|
|
|
assert homepage.load() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_refresh_writes_atomically(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
Stop the records strip showing stale readings as "right now" (#185)
A card read "Shanghai · 30°C · Near Record hot · Low temp · 99th pct" while the
Day page for the same cell said 26°C, 66th pct, Above Normal. Both were right
about the climatology — the normals matched exactly — but the strip was grading
a reading from several days earlier and presenting it as current.
Two causes, both mine:
- load_cached_recent_forecast deliberately ignores how stale the file is, and
_grade_city took the newest row <= today with no age check at all.
- Nothing keeps that cache current for the tracked cities. warm_cities.py skips
any city whose archive is already cached, so it never re-fetches their forecast
bundle, and the notifier only touches subscribed cells. So "zero upstream
calls" was satisfied by reading a cache nothing refreshes.
Fixes:
- Drop readings older than MAX_OBSERVATION_AGE_DAYS (1) instead of ranking them.
Cities span a day of timezones, so a current observation can still be dated
yesterday in UTC — hence 1 rather than 0.
- Top up the stalest cells' recent/forecast each pass, oldest first and capped
(THERMOGRAPH_HOMEPAGE_REFRESH, default 40), the same shape as the notifier's
archive-fetch budget. This is a real change: the sweep is no longer zero-cost
upstream, because it cannot be and still say "right now". Documented in
DEPLOY.md, and refresh(fetch=0) keeps a strictly cache-only rebuild.
- Rank over the top THERMOGRAPH_HOMEPAGE_CITIES (250) rather than all ~1000. The
strip shows ~12 cards; a smaller genuinely-fresh set beats a large stale one.
- Show the observation date on a card when it isn't today's, so a yesterday
reading can't silently pass for current.
2026-07-18 08:58:34 +00:00
|
|
|
homepage.refresh(limit=3, fetch=0)
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
written = json.loads((tmp_path / "homepage.json").read_text())
|
|
|
|
|
assert written["ranked"] == []
|
|
|
|
|
# No temp files left behind.
|
|
|
|
|
assert [p.name for p in tmp_path.iterdir()] == ["homepage.json"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 00:24:17 +00:00
|
|
|
def test_refresh_persists_via_store_on_postgres_not_the_file(monkeypatch, tmp_path):
|
|
|
|
|
"""On Postgres every web replica has its own disk, so the feed must go through
|
|
|
|
|
store.py's shared table instead — and must NOT also write the (per-replica,
|
|
|
|
|
stale-to-everyone-else) local file."""
|
|
|
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
|
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
|
|
|
calls = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
store, "put_payload",
|
|
|
|
|
lambda kind, cell_id, key, token, payload, **kw: calls.append(
|
|
|
|
|
(kind, cell_id, key, token, payload)))
|
|
|
|
|
feed = homepage.refresh(limit=3, fetch=0)
|
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
kind, cell_id, key, token, payload = calls[0]
|
|
|
|
|
assert (kind, cell_id, key) == ("homepage", "_global", "feed")
|
|
|
|
|
assert token and payload is feed
|
|
|
|
|
assert not (tmp_path / "homepage.json").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_reads_via_store_on_postgres(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
|
|
|
sentinel = {"ranked": [], "date": "2026-01-01"}
|
|
|
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: sentinel)
|
|
|
|
|
assert homepage.load() is sentinel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_on_postgres_handles_absent_or_malformed(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(store, "IS_POSTGRES", True)
|
|
|
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: None)
|
|
|
|
|
assert homepage.load() is None
|
|
|
|
|
monkeypatch.setattr(store, "get_json", lambda *a, **k: {"no_ranked_key": True})
|
|
|
|
|
assert homepage.load() is None
|
|
|
|
|
|
|
|
|
|
|
Stop the records strip showing stale readings as "right now" (#185)
A card read "Shanghai · 30°C · Near Record hot · Low temp · 99th pct" while the
Day page for the same cell said 26°C, 66th pct, Above Normal. Both were right
about the climatology — the normals matched exactly — but the strip was grading
a reading from several days earlier and presenting it as current.
Two causes, both mine:
- load_cached_recent_forecast deliberately ignores how stale the file is, and
_grade_city took the newest row <= today with no age check at all.
- Nothing keeps that cache current for the tracked cities. warm_cities.py skips
any city whose archive is already cached, so it never re-fetches their forecast
bundle, and the notifier only touches subscribed cells. So "zero upstream
calls" was satisfied by reading a cache nothing refreshes.
Fixes:
- Drop readings older than MAX_OBSERVATION_AGE_DAYS (1) instead of ranking them.
Cities span a day of timezones, so a current observation can still be dated
yesterday in UTC — hence 1 rather than 0.
- Top up the stalest cells' recent/forecast each pass, oldest first and capped
(THERMOGRAPH_HOMEPAGE_REFRESH, default 40), the same shape as the notifier's
archive-fetch budget. This is a real change: the sweep is no longer zero-cost
upstream, because it cannot be and still say "right now". Documented in
DEPLOY.md, and refresh(fetch=0) keeps a strictly cache-only rebuild.
- Rank over the top THERMOGRAPH_HOMEPAGE_CITIES (250) rather than all ~1000. The
strip shows ~12 cards; a smaller genuinely-fresh set beats a large stale one.
- Show the observation date on a card when it isn't today's, so a yesterday
reading can't silently pass for current.
2026-07-18 08:58:34 +00:00
|
|
|
def test_refresh_can_be_strictly_cache_only(monkeypatch, tmp_path):
|
|
|
|
|
"""fetch=0 must spend no upstream request at all — that is the contract the
|
|
|
|
|
test suite and any offline rebuild rely on."""
|
|
|
|
|
monkeypatch.setattr(homepage, "FEED_PATH", str(tmp_path / "homepage.json"))
|
|
|
|
|
|
|
|
|
|
def boom(*a, **k):
|
|
|
|
|
raise AssertionError("refresh(fetch=0) must not fetch")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
|
|
|
feed = homepage.refresh(limit=3, fetch=0)
|
|
|
|
|
assert feed["refreshed"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stale_observations_are_not_ranked(monkeypatch, history, recent):
|
|
|
|
|
"""A reading older than a day is not "right now". The recent/forecast cache
|
|
|
|
|
goes stale for any city nobody visits, and grading it anyway is what showed a
|
|
|
|
|
days-old reading under the "Unusual right now" heading, disagreeing with the
|
|
|
|
|
Day page for the same cell."""
|
|
|
|
|
import polars as pl
|
|
|
|
|
|
|
|
|
|
stale = recent.clone()
|
|
|
|
|
old = datetime.date.today() - datetime.timedelta(days=5)
|
|
|
|
|
stale = stale.with_columns(
|
|
|
|
|
(pl.col("date") - pl.duration(days=(stale["date"].max() - old).days)).alias("date")
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_recent_forecast", lambda cell: stale.clone())
|
|
|
|
|
feed = homepage.build(limit=5)
|
|
|
|
|
assert feed["ranked"] == [], "stale readings must not be presented as current"
|
|
|
|
|
assert feed["considered"] == 0
|
|
|
|
|
|
|
|
|
|
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
def test_staleness(monkeypatch):
|
|
|
|
|
today = datetime.date.today().isoformat()
|
|
|
|
|
assert not homepage.is_stale({"date": today, "generated_at": time.time()})
|
|
|
|
|
assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600})
|
|
|
|
|
assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the rendered page -------------------------------------------------------
|
|
|
|
|
def test_homepage_is_complete_without_js(client, no_feed):
|
|
|
|
|
"""A crawler (or a reader with JS off) gets the whole page as real HTML."""
|
|
|
|
|
r = client.get(f"{B}/")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
html = r.text
|
|
|
|
|
assert "How unusual is your weather?" in html
|
|
|
|
|
assert "How it works" in html
|
|
|
|
|
assert "Climate context for 1,000 cities" in html
|
|
|
|
|
assert "Free. No ads. No tracking." in html
|
|
|
|
|
# The interactive tool's DOM contract survives the move to Jinja.
|
|
|
|
|
for dom_id in ('id="find-btn"', 'id="date-input"', 'id="panel"',
|
|
|
|
|
'id="placeholder"', 'id="results"'):
|
|
|
|
|
assert dom_id in html
|
|
|
|
|
# Exactly one h1: the hero headline. The brand degrades to a <p>.
|
|
|
|
|
assert html.count("<h1") == 1
|
|
|
|
|
assert 'p class="site-name"' in html
|
|
|
|
|
|
|
|
|
|
|
Fix the hero's "Use my location" doing nothing (#179)
The button was dead on plain HTTP and failed silently everywhere.
Browsers only expose geolocation on secure origins, but `navigator.geolocation`
still EXISTS on http:// — getCurrentPosition just fails with a permission error
("Only secure origins are allowed"). The guard was `if (!navigator.geolocation)
return;`, which passes on http://, so the call went ahead, errored, and landed
in an empty error callback. Nothing happened at all.
`make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the
button could never work in the normal dev-and-phone workflow, and gave no hint
why. It would have worked on thermograph.org, which is HTTPS.
- Gate on `window.isSecureContext`, not just the API's presence. Where the
browser will refuse, hide the locate button and promote "Pick on the map" to
primary rather than offering a control that cannot work.
- Report failures: a declined prompt, a timeout, and an unavailable fix each get
their own message in an aria-live slot. A silent failure is indistinguishable
from a broken button, which is exactly how this went unnoticed.
- Show a "Locating…" state while the fix is pending; it can take seconds.
To exercise geolocation against the LAN dev server, serve it over TLS:
`make lan-run TLS=1` (self-signed cert into certs/).
Verified by driving the button in a real browser across all three cases:
insecure origin (button hidden, map promoted), permission granted (hero
re-points at the located place), permission denied (message shown, button
restored).
2026-07-18 07:48:17 +00:00
|
|
|
def test_hero_locate_has_a_status_slot(client, no_feed):
|
|
|
|
|
"""The locate button needs somewhere to report a declined prompt or a
|
|
|
|
|
timeout. Without it the control fails silently, which is how it shipped
|
|
|
|
|
broken: navigator.geolocation exists on plain http:// but always errors."""
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
assert 'id="hero-locate"' in html
|
|
|
|
|
assert 'id="hero-locate-msg"' in html
|
|
|
|
|
assert 'aria-live="polite"' in html
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 07:55:43 +00:00
|
|
|
def test_homepage_brand_keeps_the_lockup_styling(client, no_feed):
|
|
|
|
|
"""The homepage renders the brand as <p class="site-name"> so the hero owns
|
|
|
|
|
the page's only h1. The lockup styling is written against `.brand h1`, so
|
|
|
|
|
the class MUST be matched there too — otherwise the brand silently falls
|
|
|
|
|
back to block layout, which baseline-aligns the mark instead of centring it
|
|
|
|
|
and drops the monospace wordmark. Nothing else catches this: the page still
|
|
|
|
|
renders, and every other test still passes, while the header looks wrong.
|
|
|
|
|
"""
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
assert 'class="site-name"' in html
|
|
|
|
|
assert "<h1 class=\"site-name\"" not in html # the hero owns the h1
|
|
|
|
|
|
Group regression tests by domain (#215)
Move the flat backend/tests/*.py into domain subfolders so the suite
mirrors the code's concerns:
data/ climate, grading, scoring, grid, places, store
web/ api, content, homepage, views
notifications/ notify, digest, discord (+ dm/interactions/link)
accounts/ api_accounts
core/ metrics, singleton, dashboard
conftest.py stays at the tests/ root, so its sys.path setup and shared
fixtures still apply to every subfolder. The four tests that derive repo
paths from __file__ get their depth bumped one level to match their new
location. Pytest discovers the subfolders recursively; the CI command
(python -m pytest backend/tests) is unchanged.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 04:50:01 +00:00
|
|
|
css = (pathlib.Path(__file__).parents[3] / "frontend" / "style.css").read_text()
|
2026-07-18 07:55:43 +00:00
|
|
|
lockup = css.split(".brand h1", 1)[1].split("}", 1)[0]
|
|
|
|
|
assert ".brand .site-name" in lockup, (
|
|
|
|
|
"the .brand h1 lockup rule must also match .brand .site-name"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
def test_city_chips_all_resolve(client, no_feed):
|
|
|
|
|
"""Every homepage chip must be a real, routable city — a stale slug would
|
|
|
|
|
render an empty chip list and leak a 404 link."""
|
|
|
|
|
resolved = content._home_cities()
|
|
|
|
|
assert len(resolved) == len(content.HOME_CITY_SLUGS)
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
for city in resolved:
|
|
|
|
|
assert f"/climate/{city['slug']}" in html
|
|
|
|
|
assert cities.get(city["slug"]) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_records_strip_shows_both_tails(client, monkeypatch):
|
|
|
|
|
"""Two-sided honesty: a qualifying cold-tail city appears alongside the heat."""
|
|
|
|
|
feed = {
|
|
|
|
|
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
|
|
|
|
"considered": 800,
|
|
|
|
|
"picks": {"extreme": _pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm")},
|
|
|
|
|
"ranked": [_pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm"),
|
|
|
|
|
_pick("Ushuaia", 2.1, "very-cold", "Very Low", "cold")],
|
|
|
|
|
}
|
|
|
|
|
monkeypatch.setattr(homepage, "load", lambda: feed)
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
assert "Unusual right now" in html
|
|
|
|
|
assert "Phoenix" in html and "Ushuaia" in html
|
|
|
|
|
# Band colour comes from the shared tokens, and the band word is always
|
|
|
|
|
# present too — colour is never the only signal.
|
|
|
|
|
assert "var(--rec-hot)" in html and "var(--very-cold)" in html
|
|
|
|
|
assert "Near Record" in html and "Very Low" in html
|
|
|
|
|
|
|
|
|
|
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
def test_strip_cards_carry_value_metric_and_normal(client, monkeypatch):
|
|
|
|
|
feed = {
|
|
|
|
|
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
|
|
|
|
"considered": 800, "picks": {},
|
|
|
|
|
"ranked": [_pick("Tehran", 99.6, "rec-hot", "Near Record", "warm"),
|
|
|
|
|
_pick("Ushuaia", 0.4, "rec-cold", "Near Record", "cold")],
|
|
|
|
|
}
|
|
|
|
|
monkeypatch.setattr(homepage, "load", lambda: feed)
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
# Both tails must be tellable apart in WORDS, not just by colour.
|
|
|
|
|
assert "Near Record hot" in html and "Near Record cold" in html
|
|
|
|
|
assert "normal" in html and "High temp" in html
|
|
|
|
|
assert "100th" not in html and "0th" not in html
|
|
|
|
|
# Temperatures render as convertible spans, and the converter is loaded, or
|
|
|
|
|
# the strip would stay in °F while the °F/°C toggle says °C.
|
|
|
|
|
assert 'class="temp" data-temp-f' in html
|
|
|
|
|
assert "climate.js" in html
|
|
|
|
|
|
|
|
|
|
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
def test_hero_uses_most_unusual_default(client, monkeypatch):
|
|
|
|
|
feed = {
|
|
|
|
|
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
|
|
|
|
|
"considered": 800,
|
|
|
|
|
"picks": {"extreme": _pick("Phoenix", 96.0, "very-hot", "Very High", "warm")},
|
|
|
|
|
"ranked": [],
|
|
|
|
|
}
|
|
|
|
|
monkeypatch.setattr(homepage, "load", lambda: feed)
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
assert "Today in Phoenix, Somewhere" in html
|
|
|
|
|
assert "96th percentile" in html
|
|
|
|
|
assert "the most unusual weather we're tracking" in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_homepage_renders_without_feed(client, no_feed):
|
|
|
|
|
"""A cold checkout with no precomputed feed still serves a complete page."""
|
|
|
|
|
html = client.get(f"{B}/").text
|
|
|
|
|
assert "How unusual is your weather?" in html
|
|
|
|
|
assert "Unusual right now" not in html # strip is omitted, not broken
|
|
|
|
|
assert 'id="hero-grade"' in html # the frame is still there
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_homepage_etag_revalidates(client, no_feed):
|
|
|
|
|
r = client.get(f"{B}/")
|
|
|
|
|
etag = r.headers["etag"]
|
|
|
|
|
again = client.get(f"{B}/", headers={"If-None-Match": etag})
|
|
|
|
|
assert again.status_code == 304
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_old_static_index_is_gone(client):
|
|
|
|
|
"""index.html must not linger behind the static mount as indexable duplicate
|
|
|
|
|
content now that / is server-rendered."""
|
|
|
|
|
assert client.get(f"{B}/index.html").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_privacy_page(client):
|
|
|
|
|
r = client.get(f"{B}/privacy")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert "no analytics library" in r.text.lower() or "no analytics" in r.text.lower()
|
|
|
|
|
assert "Use my location" in r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_privacy_is_linked_but_not_in_sitemap(client):
|
|
|
|
|
assert f"{B}/privacy" in client.get(f"{B}/").text
|
|
|
|
|
# It's a policy page, not a crawl target we want ranked.
|
|
|
|
|
assert "/privacy" not in client.get(f"{B}/sitemap.xml").text
|