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.
This commit is contained in:
Emi Griffith 2026-07-18 00:39:47 -07:00 committed by GitHub
parent 118efeb158
commit c4ca6b38ea
4 changed files with 242 additions and 87 deletions

View file

@ -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 (<h2>) 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

70
static/digest.js Normal file
View file

@ -0,0 +1,70 @@
// Digest signup + product-event beacons. Loaded on every page (base.html.j2).
//
// Both halves degrade: the form is a real <form method="post"> 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;
}
});
}

View file

@ -1,87 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — how unusual is your weather?</title>
<meta name="description" content="See how today's weather compares to decades of local climate history for any place on Earth — daily high, low, feels-like, humidity, wind and rain graded by percentile." />
<meta name="theme-color" content="#f0803c" />
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
og:image origins in from this request's scheme://host + base path —
crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph — how unusual is your weather?" />
<meta property="og:description" content="See how today's weather compares to decades of local climate history for any place on Earth — daily high, low, feels-like, humidity, wind and rain graded by percentile." />
<meta property="og:url" content="__ORIGIN__/" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=2" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Thermograph logo" />
<meta name="twitter:card" content="summary" />
<link rel="canonical" href="__ORIGIN__/" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebApplication","name":"Thermograph","url":"__ORIGIN__/","applicationCategory":"WeatherApplication","operatingSystem":"Web, iOS, Android","browserRequirements":"Requires JavaScript","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"description":"See how today's weather compares to ~45 years of local climate history for any place on Earth — graded by percentile."}
</script>
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
<link rel="icon" href="favicon-48.png" type="image/png" sizes="48x48" />
<link rel="icon" href="favicon-32.png" type="image/png" sizes="32x32" />
<link rel="icon" href="favicon-16.png" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<div class="brand">
<div>
<h1><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#fff" stroke-opacity=".08" stroke-width="4"/><rect x="64" y="170" width="384" height="48" rx="8" fill="#27131C"/><rect x="64" y="222" width="384" height="48" rx="8" fill="#38322E"/><rect x="64" y="274" width="384" height="48" rx="8" fill="#1A2D27"/><rect x="64" y="326" width="384" height="48" rx="8" fill="#26333F"/><rect x="64" y="378" width="384" height="48" rx="8" fill="#18293A"/><line x1="64" y1="132" x2="448" y2="132" stroke="#46586E" stroke-width="8" stroke-dasharray="26 20"/><path d="M64 410H126V386H160" fill="none" stroke="var(--cold)" stroke-width="34"/><path d="M160 403V352H244V368H282" fill="none" stroke="var(--cool)" stroke-width="34"/><path d="M282 385V320H312" fill="none" stroke="var(--normal)" stroke-width="34"/><path d="M312 337V248H338" fill="none" stroke="var(--warm)" stroke-width="34"/><path d="M338 265V196H366" fill="none" stroke="var(--hot)" stroke-width="34"/><path d="M366 213V132H396" fill="none" stroke="var(--very-hot)" stroke-width="34"/><rect x="382" y="100" width="64" height="64" rx="12" fill="var(--rec-hot)" stroke="#10141B" stroke-width="12"/></svg></span>Thermograph</h1>
<p class="tag">Grading today's weather against ~45 years of local climate history</p>
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map" class="active">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day Detail</a>
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
<a href="alerts">Alerts</a>
</nav>
</div>
</details>
</div>
</header>
<main>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
<div class="date-row">
<label>Target date
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint"><a href="legend">What do the grades mean? →</a></span>
</div>
</section>
<section id="panel" class="panel panel-full">
<div class="placeholder" id="placeholder">
<p>Find a location to begin.</p>
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
for your ~4&nbsp;sq&nbsp;mi cell, builds a &plusmn;7-day seasonal distribution for each
day of the year, and grades recent weather by where it falls on that distribution.</p>
</div>
<div id="results" hidden></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>

View file

@ -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); }