Commit graph

5 commits

Author SHA1 Message Date
Emi Griffith
63ba5ab44e 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
Emi Griffith
3163586cd2 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
Emi Griffith
a1656751e8 Restore the brand lockup on the homepage (#181)
The homepage header lost its wordmark treatment: the mark baseline-aligned
instead of centring (riding ~8px high) and the wordmark fell back to 20px Inter
regular instead of the 26px monospace used everywhere else.

The lockup is styled by `.brand h1` — an element selector. The homepage renders
the brand as `<p class="site-name">` so its hero headline can own the page's
only h1, so it matched none of it: no flex centring, no gap, no monospace, no
weight. Measured against /about at 393px:

  homepage   <p>   Inter         20px w400  display:block  mark -8.5px
  /about     <h1>  ui-monospace  26px w700  display:flex   mark -1.2px

Match `.brand .site-name` alongside `.brand h1` (and the same for the inner
link and the compact-header size). Both now measure -1.2px, the intended
optical nudge.

Adds a test for the invariant. Nothing else caught this: the page rendered,
every test passed, and only the rendering was wrong — so the test asserts the
lockup rule covers the class the homepage actually uses, and fails if the
selector is narrowed again.
2026-07-18 07:55:43 +00:00
Emi Griffith
aab42610ef Fix the hero's "Use my location" doing nothing (#179)
The button was dead on plain HTTP and failed silently everywhere.

Browsers only expose geolocation on secure origins, but `navigator.geolocation`
still EXISTS on http:// — getCurrentPosition just fails with a permission error
("Only secure origins are allowed"). The guard was `if (!navigator.geolocation)
return;`, which passes on http://, so the call went ahead, errored, and landed
in an empty error callback. Nothing happened at all.

`make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the
button could never work in the normal dev-and-phone workflow, and gave no hint
why. It would have worked on thermograph.org, which is HTTPS.

- Gate on `window.isSecureContext`, not just the API's presence. Where the
  browser will refuse, hide the locate button and promote "Pick on the map" to
  primary rather than offering a control that cannot work.
- Report failures: a declined prompt, a timeout, and an unavailable fix each get
  their own message in an aria-live slot. A silent failure is indistinguishable
  from a broken button, which is exactly how this went unnoticed.
- Show a "Locating…" state while the fix is pending; it can take seconds.

To exercise geolocation against the LAN dev server, serve it over TLS:
`make lan-run TLS=1` (self-signed cert into certs/).

Verified by driving the button in a real browser across all three cases:
insecure origin (button hidden, map promoted), permission granted (hero
re-points at the located place), permission denied (message shown, button
restored).
2026-07-18 07:48:17 +00:00
Emi Griffith
3a66682322 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