From c4ca6b38ea4fa92684f2b9af6bec78bab01e8ce8 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 18 Jul 2026 00:39:47 -0700 Subject: [PATCH] Rebuild the homepage as a distribution landing; add an SMTP seam (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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//records. --- static/app.js | 39 ++++++++++++++ static/digest.js | 70 ++++++++++++++++++++++++ static/index.html | 87 ------------------------------ static/style.css | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 87 deletions(-) create mode 100644 static/digest.js delete mode 100644 static/index.html diff --git a/static/app.js b/static/app.js index b45b532..169fa0d 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,7 @@ import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, tempChart, precipChart, dryChart, attachChartHover } from "./chart.js"; +import { track } from "./digest.js"; import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; @@ -47,6 +48,43 @@ const locLabel = document.getElementById("loc-label"); initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => selectLocation(lat, lon)); +// ---- hero ---- +// The hero's grade card is server-rendered showing the most unusual city we're +// tracking; once the visitor has a place of their own it re-points at that. The +// frame and its text slots already exist in the HTML, so swapping the numbers in +// shifts nothing on the page. +const hero = document.getElementById("hero"); + +document.getElementById("hero-locate")?.addEventListener("click", () => { + track("home.locate"); + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => selectLocation(pos.coords.latitude, pos.coords.longitude), + () => { /* declined or unavailable: the map picker is still right there */ }, + { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }, + ); +}); + +document.getElementById("hero-pick")?.addEventListener("click", () => findBtn.click()); + +/** Re-point the hero card at the place the visitor actually chose. */ +function updateHero(data) { + if (!hero) return; + const card = document.getElementById("hero-grade"); + const targetDay = data.recent.find((d) => d.date === data.target_date); + const g = targetDay && (targetDay.tmax || targetDay.tmin); + if (!card || !g) return; + + document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`; + document.getElementById("hero-band").textContent = g.grade; + document.getElementById("hero-detail").textContent = + `${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${ord(Math.round(g.percentile))} percentile`; + // Band colour rides the same token set as everything else; the band word is + // always present too, so colour is never the only signal. + card.style.setProperty("--cat", TIER_COLORS[g.class] || ""); + hero.querySelector(".grade-why")?.remove(); +} + dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); }); // ---- grade request + render ---- @@ -116,6 +154,7 @@ const IC = { function render(data) { recentData = data; + updateHero(data); // The place name appears once — as the results heading (

) below, which also // carries the coordinates + climatology context. The top label only holds the // transient coordinates written on selection, so hide it now that the named diff --git a/static/digest.js b/static/digest.js new file mode 100644 index 0000000..c408d65 --- /dev/null +++ b/static/digest.js @@ -0,0 +1,70 @@ +// Digest signup + product-event beacons. Loaded on every page (base.html.j2). +// +// Both halves degrade: the form is a real
that works with +// this file blocked, and the beacons are fire-and-forget with no UI depending +// on them. + +const BASE = new URL("./", import.meta.url).pathname.replace(/\/$/, ""); + +/** Report one product event. Aggregate-only: the server takes the referrer from + * the request itself, so nothing identifying is sent from here. */ +export function track(event) { + try { + const url = `${BASE}/api/v2/event`; + const body = JSON.stringify({ event }); + // sendBeacon survives the page unloading, which a fetch() started on a link + // click usually does not — that's the whole point for nav/records clicks. + if (navigator.sendBeacon) { + navigator.sendBeacon(url, new Blob([body], { type: "application/json" })); + } else { + fetch(url, { method: "POST", body, headers: { "Content-Type": "application/json" }, + keepalive: true }).catch(() => {}); + } + } catch { /* instrumentation must never break the page */ } +} + +// Anything carrying data-event reports itself when activated. +document.addEventListener("click", (e) => { + const el = e.target.closest?.("[data-event]"); + if (el) track(el.dataset.event); +}); + +// --- digest form ------------------------------------------------------------- +for (const form of document.querySelectorAll("form[data-digest]")) { + const status = form.querySelector(".digest-status"); + const button = form.querySelector("button[type=submit]"); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = form.querySelector("input[name=email]")?.value?.trim(); + if (!email) return; + + button.disabled = true; + try { + const res = await fetch(form.action, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, place: form.dataset.place || null }), + }); + const data = await res.json().catch(() => ({})); + if (status) { + status.textContent = data.message || "Something went wrong — try again."; + status.hidden = false; + } + if (res.ok) { + // Collapse the inputs on success so the confirmation is the only thing + // left to read; the form is done either way. + form.querySelector(".digest-row")?.setAttribute("hidden", ""); + form.querySelector(".digest-note")?.setAttribute("hidden", ""); + track("home.digest_signup"); + } + } catch { + if (status) { + status.textContent = "Couldn't reach the server — try again."; + status.hidden = false; + } + } finally { + button.disabled = false; + } + }); +} diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 18810ba..0000000 --- a/static/index.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - Thermograph — how unusual is your weather? - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

Thermograph

-

Grading today's weather against ~45 years of local climate history

-
- -
-
- -
-
-
- - -
-
- - - What do the grades mean? → -
-
- -
-
-

Find a location to begin.

-

Thermograph fetches decades of daily highs, lows, and precipitation - for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each - day of the year, and grades recent weather by where it falls on that distribution.

-
- -
-
- - - - - diff --git a/static/style.css b/static/style.css index a8672a6..4ba6a8b 100644 --- a/static/style.css +++ b/static/style.css @@ -1757,3 +1757,136 @@ details.hub-country > summary:hover { color: var(--accent); } main.content { padding: 8px 14px 40px; } .today-cards { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } } + +/* ============================================================ + Homepage distribution surfaces: hero, records strip, how it + works, explore cards, city chips, digest form. Mobile-first; + the wide-monitor steps at the top of this file widen `main`, + and these grids flow into whatever room that gives them. + ============================================================ */ + +/* --- hero ---------------------------------------------------------------- */ +.hero { display: grid; gap: 18px; margin: 8px 0 22px; } +.hero h1 { font-size: clamp(28px, 6vw, 44px); line-height: 1.1; letter-spacing: -0.03em; } +.hero-sub { margin: 10px 0 0; color: var(--muted); font-size: clamp(15px, 2.4vw, 19px); max-width: 46ch; } + +/* The grade card is the LCP element. Its three text slots have reserved + heights so hydrating the live numbers can't shift the page. */ +.hero-card { display: grid; gap: 12px; justify-items: start; } +.grade-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 4px solid var(--cat, var(--border)); + border-radius: 11px; + padding: 16px 18px; + width: 100%; + max-width: 460px; +} +.grade-where { margin: 0; color: var(--muted); font-size: 13px; min-height: 18px; } +.grade-band { + margin: 4px 0 0; + font-size: clamp(26px, 5vw, 34px); + font-weight: 700; + letter-spacing: -0.02em; + min-height: 40px; + color: color-mix(in oklab, var(--cat, var(--text)) 78%, var(--text)); +} +.grade-detail { margin: 6px 0 0; font-size: 15px; min-height: 22px; } +.grade-why { margin: 6px 0 0; font-size: 12px; } + +.hero-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.hero-actions button { + min-height: 44px; padding: 11px 18px; border-radius: 10px; + font-size: 16px; font-weight: 600; cursor: pointer; +} +.btn-primary { background: var(--accent); color: #1a1206; border: 1px solid var(--accent); } +.btn-ghost { background: var(--surface); color: var(--text); border: 1px solid var(--border); } +.btn-ghost:hover, .btn-primary:hover { border-color: var(--accent); } +.hero-jump { margin: 0; font-size: 14px; } +.hero-jump a { color: var(--muted); text-decoration: none; } +.hero-jump a:hover { color: var(--accent); } + +/* Desktop: copy beside the card rather than stacked above it. */ +@media (min-width: 900px) { + .hero { grid-template-columns: 1.1fr 1fr; align-items: center; gap: 40px; } +} + +.stance-line { margin: 14px 0 28px; font-size: 13px; } +.stance-line a { color: var(--muted); } +.stance-line a:hover { color: var(--accent); } + +/* --- unusual right now --------------------------------------------------- */ +.unusual-strip { margin: 32px 0; } +.unusual-strip h2, .how-it-works h2, .explore h2, .city-hub h2 { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin: 0 0 12px; +} +/* Scroll-snap, not a JS carousel: keyboard- and touch-scrollable for free. */ +.unusual-row { + display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 10px; + margin: 0; list-style: none; scroll-snap-type: x proximity; +} +.unusual-card { scroll-snap-align: start; flex: 0 0 auto; } +.unusual-card a { + display: grid; gap: 2px; min-width: 150px; padding: 12px 14px; + background: var(--surface); border: 1px solid var(--border); + border-left: 4px solid var(--cat, var(--border)); + border-radius: 11px; text-decoration: none; color: var(--text); +} +.unusual-card a:hover { border-color: var(--accent); } +.unusual-city { font-weight: 600; font-size: 14px; } +.unusual-pct { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; + color: color-mix(in oklab, var(--cat, var(--text)) 78%, var(--text)); } +.unusual-band { font-size: 12px; color: var(--muted); } +.unusual-more { margin: 2px 0 0; font-size: 14px; } +.unusual-more a { color: var(--accent); text-decoration: none; } + +/* --- how it works -------------------------------------------------------- */ +.how-it-works { margin: 32px 0; } +.how-steps { margin: 0 0 10px; padding-left: 22px; display: grid; gap: 8px; } +.how-steps li { line-height: 1.45; } +.how-it-works a { color: var(--accent); } + +/* --- explore ------------------------------------------------------------- */ +.explore { margin: 32px 0; } +.explore-cards { display: grid; gap: 10px; } +@media (min-width: 700px) { .explore-cards { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1400px) { .explore-cards { grid-template-columns: repeat(4, 1fr); } } +.explore-card { + display: grid; gap: 4px; padding: 14px 16px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 11px; text-decoration: none; color: var(--text); +} +.explore-card:hover { border-color: var(--accent); } +.explore-name { font-weight: 600; } +.explore-desc { color: var(--muted); font-size: 14px; line-height: 1.4; } + +/* --- city chips ---------------------------------------------------------- */ +.city-hub { margin: 32px 0; } +.city-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; padding: 0; list-style: none; } +.city-chips a { + display: inline-block; padding: 8px 13px; border-radius: 999px; + background: var(--surface); border: 1px solid var(--border); + color: var(--text); text-decoration: none; font-size: 14px; +} +.city-chips a:hover { border-color: var(--accent); } +.city-hub > p a { color: var(--accent); text-decoration: none; } + +/* --- digest form (site-wide footer pattern) ------------------------------ */ +.digest-form { display: grid; gap: 8px; margin: 0 0 20px; max-width: 480px; } +.digest-form label { font-size: 14px; font-weight: 600; } +.digest-row { display: flex; flex-wrap: wrap; gap: 8px; } +.digest-row input { + flex: 1 1 200px; min-height: 44px; padding: 10px 12px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 10px; color: var(--text); + font-size: 16px; /* hard minimum: smaller makes iOS zoom on focus */ +} +.digest-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.digest-row button { + min-height: 44px; padding: 10px 18px; border-radius: 10px; + background: var(--accent); color: #1a1206; border: 1px solid var(--accent); + font-size: 16px; font-weight: 600; cursor: pointer; +} +.digest-note { margin: 0; font-size: 12px; } +.digest-status { margin: 0; font-size: 14px; color: var(--accent); }