thermograph/web/homepage.py

368 lines
14 KiB
Python
Raw Normal View History

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
"""Homepage "unusual right now" feed.
The homepage wants to answer "where is the weather most unusual today?" across
every city we track. That question has no cheap answer at request time: the
derived store keeps graded payloads as zlib-compressed JSON blobs keyed by
(kind, cell, key), with no percentile column to sort on, so finding today's
extremes means grading every cached city from scratch.
So we precompute it. A sweep grades every city whose parquet cache is already
warm and writes the ranked result to data/homepage.json; the homepage template
reads that file. The sweep is deliberately **cache-only** it calls
climate.load_cached_history and climate.load_cached_recent_forecast, never their
fetching counterparts, so a sweep over ~1000 cities costs zero upstream requests
and cannot burst the archive quota. Cities without a warm cache are skipped; the
deploy-time warm_cities.py run is what makes them eligible.
Runs from the notifier daemon (hourly guard) and at the tail of warm_cities, so
it refreshes both on a live server and right after a deploy.
"""
from __future__ import annotations
import datetime
import json
import os
import tempfile
import time
import polars as pl
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 data import cities
from data import climate
from data import grading
from data import grid
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
import paths
from web.views import OBS_COLS
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
# data/ is the only writable path under the hardened systemd unit
# (ReadWritePaths=/opt/thermograph/data …), so the feed lives there when it's a
# file (see the store.IS_POSTGRES split in refresh/load below).
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
FEED_PATH = os.path.join(paths.DATA_DIR, "homepage.json")
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
# On Postgres, persist the feed via store.py's existing derived-payload table
# instead of the local file: web replicas each have their own disk under Swarm,
# so a file only that replica's own notifier writes would leave every OTHER
# replica reading a stale (or missing) feed forever. The feed isn't cell-scoped,
# so it's stored under a fixed sentinel key rather than a real cell id; the token
# is a constant because this cache is never invalidated by content, only ever
# overwritten by the next refresh (there's nothing else to compare it against).
# dev/tests keep the plain file (store.IS_POSTGRES is False without
# THERMOGRAPH_DATABASE_URL), so today's behavior there is unchanged.
_FEED_STORE_KIND = "homepage"
_FEED_STORE_CELL = "_global"
_FEED_STORE_KEY = "feed"
_FEED_STORE_TOKEN = "v1"
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
# How many cards the "Unusual right now" strip can show.
RANK_LIMIT = 12
# A city must be at least this far from the median to be worth calling unusual.
MIN_DEPARTURE = 15.0
# A cold-tail card is force-included when one is at or below this percentile,
# even if every warmer city outranks it. Both tails or it isn't honest.
COLD_TAIL_MAX_PCT = 10.0
# The feed is stale (label it "as of <date>" rather than claiming today) beyond this.
STALE_AFTER_S = 6 * 3600
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
# A reading older than this is not "right now" and is dropped rather than ranked.
# Cities span a day's worth of timezones, so a genuinely current observation can
# still be dated yesterday in UTC — hence 1 rather than 0.
MAX_OBSERVATION_AGE_DAYS = 1
# How many cities the strip ranks over. The strip shows ~12 cards; ranking over a
# smaller set that is actually FRESH beats ranking over every cached city when
# most of those are stale (see _refresh_recent below).
CANDIDATE_LIMIT = int(os.environ.get("THERMOGRAPH_HOMEPAGE_CITIES", "250") or 250)
# Recent/forecast fetches one refresh pass may spend, oldest cache first.
#
# This is the one place the homepage costs upstream quota, and it is not
# optional: nothing else keeps the recent/forecast 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 cells
# somebody has subscribed to. Reading that cache without refreshing it is what
# made the strip present days-old readings as "right now".
#
# One fetch covers a whole cell's recent+forecast window. Set to 0 to disable
# (the strip then only ranks cities something else happened to refresh).
MAX_RF_REFRESH_PER_PASS = int(os.environ.get("THERMOGRAPH_HOMEPAGE_REFRESH", "40") or 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
# Ranking looks at the two metrics whose percentile people read as "how unusual
# was today" — the same pair grade_day's own `departure` score uses.
RANK_METRICS = ("tmax", "tmin")
_METRIC_LABEL = {"tmax": "High temp", "tmin": "Low temp"}
def _seasonal_window_label(d: datetime.date) -> str:
"""'mid-July' — the ±7-day seasonal window a percentile is measured against,
said the way a person would say it."""
part = "early" if d.day <= 10 else ("mid" if d.day <= 20 else "late")
return f"{part}-{d.strftime('%B')}"
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 _short_date(date_s: str) -> str:
"""'2026-07-17' -> 'Jul 17'."""
try:
return datetime.date.fromisoformat(date_s).strftime("%b %-d")
except ValueError:
return date_s
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 _pct_label(pct: float) -> str:
"""Kept as a name the feed builder reads; the rule itself lives in grading."""
return grading.pct_ordinal(pct)
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 _grade_label(grade: str | None, tail: str) -> str:
"""The band label, disambiguated when it is the same at both ends."""
if not grade:
return ""
if grade == "Near Record":
return f"Near Record {'cold' if tail == 'cold' else 'hot'}"
return grade
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 _grade_city(city: dict, today: datetime.date) -> dict | None:
"""Grade one city's latest observed day from cache alone. None when the city
has no warm cache, no observed row, or nothing gradeable."""
cell = grid.snap(city["lat"], city["lon"])
history = climate.load_cached_history(cell)
if history is None:
return None
recent = climate.load_cached_recent_forecast(cell)
if recent is None or recent.is_empty() or "date" not in recent.columns:
return None
observed = recent.filter(pl.col("date") <= today).sort("date")
if observed.is_empty():
return None
row = observed.row(observed.height - 1, named=True)
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
# The newest cached row can be days old — the cache is never refreshed for a
# city nobody visits. Grading it anyway is what put a reading from three days
# ago under a heading that says "right now", disagreeing with the Day page
# for the very same cell.
row_date = row["date"]
if hasattr(row_date, "toordinal"):
if (today.toordinal() - row_date.toordinal()) > MAX_OBSERVATION_AGE_DAYS:
return None
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
obs = {k: row[k] for k in OBS_COLS if k in row}
try:
graded = grading.grade_day(history, row["date"], obs)
except Exception: # noqa: BLE001 - one bad cell must not abort the sweep
return None
# Pick the metric that strayed furthest from the median; that is the one the
# card should lead with.
best = None
for metric in RANK_METRICS:
g = graded.get(metric)
if not g or g.get("percentile") is None:
continue
departure = abs(g["percentile"] - 50)
if best is None or departure > best[0]:
best = (departure, metric, g)
if best is None:
return None
departure, metric, g = best
date = row["date"]
date_s = date.isoformat() if hasattr(date, "isoformat") else str(date)
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
pct = g["percentile"]
tail = "cold" if pct < 50 else "warm"
# The median for this metric's ±7-day window — "104°F, normal 97°F" is the
# line that makes a percentile mean something.
normals = (graded.get("normals") or {}).get(metric) or {}
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
return {
"slug": city["slug"],
"name": city["name"],
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
# "Tehran, Iran", not "Dar es Salaam, Dar es Salaam Region, Tanzania":
# the card is one line wide and the region almost always restates the
# city on the metros we track.
"display": f"{city['name']}, {city['country']}" if city.get("country") else city["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
"lat": round(city["lat"], 4),
"lon": round(city["lon"], 4),
"cell_id": cell["id"],
"date": date_s,
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
# Shown on the card when the reading isn't today's, so a yesterday-in-UTC
# observation never silently passes for "right now".
"date_label": None if date_s == today.isoformat() else _short_date(date_s),
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
"metric": metric,
"metric_label": _METRIC_LABEL.get(metric, metric),
"window_label": _seasonal_window_label(
date if hasattr(date, "timetuple") else today
),
"value": g.get("value"),
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
"normal": normals.get("p50"),
"percentile": pct,
"pct_label": _pct_label(pct),
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
"grade": g.get("grade"),
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
# "Near Record" is the band label at BOTH ends of the scale, so on its own
# a card can't say whether it's a hot or a cold extreme — colour would be
# the only signal. Qualify it; the other tiers already read directionally
# ("Very High", "Low"), so they're left alone.
"grade_label": _grade_label(g.get("grade"), tail),
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
# grading's css class is already the style.css token name minus the
# leading '--', so the template emits it with no mapping table.
"cls": g.get("class"),
"departure": round(departure, 1),
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
"tail": tail,
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 build(limit: int | None = None) -> dict:
"""Sweep the warm cache and rank today's most unusual cities. Pure — does no
I/O beyond reading the parquet cache, and never touches the network."""
today = datetime.date.today()
graded: list[dict] = []
considered = 0
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
for city in cities.all_cities()[: limit or CANDIDATE_LIMIT]:
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
row = _grade_city(city, today)
if row is None:
continue
considered += 1
if row["departure"] >= MIN_DEPARTURE:
graded.append(row)
graded.sort(key=lambda r: r["departure"], reverse=True)
ranked = graded[:RANK_LIMIT]
# Two-sided honesty: if any tracked city sits in the cold tail, one must
# appear in the strip even when warm anomalies dominate the ranking.
if not any(r["tail"] == "cold" for r in ranked):
cold = next(
(r for r in graded if r["tail"] == "cold"
and r["percentile"] <= COLD_TAIL_MAX_PCT),
None,
)
if cold is not None:
ranked = ranked[: RANK_LIMIT - 1] + [cold]
picks = {}
if graded:
picks["extreme"] = graded[0]
cold = next((r for r in graded if r["tail"] == "cold"), None)
if cold is not None:
picks["cold"] = cold
return {
"generated_at": time.time(),
"date": today.isoformat(),
"considered": considered,
"picks": picks,
"ranked": ranked,
}
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 _refresh_recent(limit: int | None = None, budget: int = 0) -> int:
"""Top up the recent/forecast cache for the stalest candidate cities.
Bounded and oldest-first, so each pass advances the set a little and no pass
can burst the forecast quota the same shape as the notifier's archive-fetch
budget. Returns how many cells were refreshed.
"""
if budget <= 0:
return 0
candidates = []
for city in cities.all_cities()[: limit or CANDIDATE_LIMIT]:
cell = grid.snap(city["lat"], city["lon"])
# Only cities whose archive is already warm can be graded at all, so
# there is no point refreshing a forecast we can't compare to anything.
if climate.load_cached_history(cell) is None:
continue
candidates.append((climate.recent_stamp(cell["id"]), cell))
candidates.sort(key=lambda t: t[0])
done = 0
for _, cell in candidates[:budget]:
try:
climate.get_recent_forecast(cell)
done += 1
except Exception: # noqa: BLE001 - one failed cell must not stop the pass
continue
return done
def refresh(limit: int | None = None, fetch: int | None = None) -> dict:
"""Rebuild the feed and persist it atomically, so a concurrent reader never
sees a half-written result.
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
``fetch`` caps the recent/forecast top-ups this pass may spend; pass 0 for a
strictly cache-only rebuild (what the tests do).
"""
refreshed = _refresh_recent(
limit=limit,
budget=MAX_RF_REFRESH_PER_PASS if fetch is None else fetch,
)
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
feed = build(limit=limit)
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
feed["refreshed"] = refreshed
if store.IS_POSTGRES:
# A single upsert is atomic in the DB sense already; store.put_payload is
# fail-soft (a Postgres hiccup drops the write silently, matching every
# other store.py caller), so this never turns a refresh into a hard failure.
store.put_payload(_FEED_STORE_KIND, _FEED_STORE_CELL, _FEED_STORE_KEY,
_FEED_STORE_TOKEN, feed)
return feed
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
path = os.path.abspath(FEED_PATH)
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(feed, f, separators=(",", ":"))
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return feed
def load() -> dict | None:
"""Read the feed. Returns None when it is missing or unreadable — the
homepage renders its frame without live numbers rather than failing, so a
cold checkout with no feed still serves a complete page.
On Postgres this reads the same row every web replica shares (see refresh);
elsewhere it reads the local file, unchanged from before this store split."""
if store.IS_POSTGRES:
feed = store.get_json(_FEED_STORE_KIND, _FEED_STORE_CELL, _FEED_STORE_KEY,
_FEED_STORE_TOKEN)
if not isinstance(feed, dict) or "ranked" not in feed:
return None
return feed
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
try:
with open(os.path.abspath(FEED_PATH), encoding="utf-8") as f:
feed = json.load(f)
except (OSError, ValueError):
return None
if not isinstance(feed, dict) or "ranked" not in feed:
return None
return feed
def is_stale(feed: dict) -> bool:
"""Older than STALE_AFTER_S, or built for a day that is no longer today."""
if feed.get("date") != datetime.date.today().isoformat():
return True
return (time.time() - float(feed.get("generated_at") or 0)) > STALE_AFTER_S
if __name__ == "__main__":
import sys
n = int(sys.argv[1]) if len(sys.argv) > 1 else None
out = refresh(limit=n)
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
print(f"refreshed {out.get('refreshed', 0)} forecast caches; "
f"graded {out['considered']} cached cities, "
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
f"{len(out['ranked'])} ranked, top: "
f"{out['ranked'][0]['display'] if out['ranked'] else ''}")