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.
100 lines
6.7 KiB
Django/Jinja
100 lines
6.7 KiB
Django/Jinja
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
{{ head_verify() }}
|
|
<title>{% block title %}Thermograph{% endblock %}</title>
|
|
<meta name="description" content="{% block description %}{% endblock %}" />
|
|
<meta name="theme-color" content="#f0803c" />
|
|
<link rel="canonical" href="{{ base_url }}{% block canonical_path %}/{% endblock %}" />
|
|
<meta property="og:type" content="{% block og_type %}website{% endblock %}" />
|
|
<meta property="og:site_name" content="Thermograph" />
|
|
<meta property="og:title" content="{{ self.title() }}" />
|
|
<meta property="og:description" content="{{ self.description() }}" />
|
|
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
|
|
<meta property="og:image" content="{{ base_url }}/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="icon" href="{{ base }}/favicon.svg" type="image/svg+xml" />
|
|
<link rel="icon" href="{{ base }}/favicon-48.png" type="image/png" sizes="48x48" />
|
|
<link rel="icon" href="{{ base }}/favicon-32.png" type="image/png" sizes="32x32" />
|
|
<link rel="icon" href="{{ base }}/favicon-16.png" type="image/png" sizes="16x16" />
|
|
<link rel="apple-touch-icon" href="{{ base }}/apple-touch-icon.png" />
|
|
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
|
|
{% block head_extra %}{% endblock %}
|
|
<link rel="stylesheet" href="{{ base }}/style.css" />
|
|
{% block jsonld %}{% endblock %}
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="brand">
|
|
<div>
|
|
{# The brand is the page's h1 everywhere EXCEPT the homepage, where the
|
|
hero headline owns the sole h1 and the brand degrades to a <p>. The
|
|
.brand h1, .brand .site-name selector pair in style.css keeps the
|
|
lockup looking identical either way. #}
|
|
<{{ brand_tag|default('h1') }} class="site-name"><a href="{{ base }}/"><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</a></{{ brand_tag|default('h1') }}>
|
|
<p class="tag">How unusual is the weather? Graded 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">
|
|
{# data-view marks the links nav.js rewrites to carry the current
|
|
location hash, so moving between views keeps the place you picked.
|
|
Climate and Alerts deliberately opt out (they aren't cell-scoped).
|
|
Inert on the SEO pages, which never load nav.js. #}
|
|
<nav class="view-nav" aria-label="Views">
|
|
<a href="{{ base }}/" data-view="map"{% if section == 'home' %} class="active"{% endif %}>Weekly</a>
|
|
<a href="{{ base }}/calendar" data-view="calendar">Calendar</a>
|
|
<a href="{{ base }}/day" data-view="day">Day Detail</a>
|
|
<a href="{{ base }}/compare" data-view="compare">Compare</a>
|
|
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
|
|
<a href="{{ base }}/alerts">Alerts</a>
|
|
</nav>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</header>
|
|
|
|
<main{% if main_class|default('content') %} class="{{ main_class|default('content') }}"{% endif %}>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
|
|
<footer class="site-footer">
|
|
{# The compact digest signup is the site-wide footer pattern, not a
|
|
homepage-only surface — every page is a possible entry point. #}
|
|
<form class="digest-form digest-compact" method="post" action="{{ base }}/digest"
|
|
data-digest aria-labelledby="footer-digest-label">
|
|
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
|
|
<div class="digest-row">
|
|
<input id="footer-digest-email" name="email" type="email" required
|
|
autocomplete="email" placeholder="you@example.com" />
|
|
<button type="submit">Get the digest</button>
|
|
</div>
|
|
<p class="digest-note muted">No spam, no sharing your address, unsubscribe anytime.</p>
|
|
<p class="digest-status" role="status" aria-live="polite" hidden></p>
|
|
</form>
|
|
<nav aria-label="Footer">
|
|
<a href="{{ base }}/climate">City climates</a>
|
|
<a href="{{ base }}/glossary">Weather glossary</a>
|
|
<a href="{{ base }}/about">About & methodology</a>
|
|
<a href="{{ base }}/privacy">Privacy</a>
|
|
<a href="{{ base }}/">Open the tool</a>
|
|
</nav>
|
|
<p class="muted">Free. No ads. No tracking. No account needed. Built by one person in the open.</p>
|
|
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
|
|
climate history. Data: ERA5 via Open-Meteo · NASA POWER · MET Norway.</p>
|
|
</footer>
|
|
{% block body_scripts %}
|
|
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
|
|
and the client-side temperature converter. All self-contained modules. -->
|
|
<script type="module" src="{{ base }}/units.js"></script>
|
|
<script type="module" src="{{ base }}/account.js"></script>
|
|
<script type="module" src="{{ base }}/climate.js"></script>
|
|
{% endblock %}
|
|
<script type="module" src="{{ base }}/digest.js"></script>
|
|
</body>
|
|
</html>
|