Search Console shows impressions skewing hard to °C countries — India, South Africa, New Zealand, the UK, Australia — while every climate page rendered °F into the HTML. Search snippets quote the server-rendered text, so a reader in Delhi saw a temperature they had to convert in their head before the page was worth a click. Climate pages now render temperatures in the city's country convention: °F for the US and the handful of other Fahrenheit countries, °C everywhere else. This happens server-side, so it holds with no JS and shows up in the snippet. data-temp-f keeps the raw Fahrenheit in every case — it stays the conversion source of truth, and climate.js repaints from it on toggle exactly as before. The visitor's stored choice still wins over everything; the page default only sits between that and the locale guess, so a first-time visitor stops seeing a °F→°C repaint on load for the majority of our traffic. The unit rides on a ContextVar rather than a parameter because _temp() is reached from both directions — templates call it as a Jinja global, and the context builders call it directly to pre-render the records tables into strings. Threading an argument would mean touching ~20 call sites across three levels of nesting, where missing one yields a silently wrong unit instead of an error. The scope wraps the context builder as well as the render, since the builder is evaluated as an argument and runs first, and it resets in a finally: the page handlers are sync `def`, so Starlette runs them on a recycled threadpool thread and a value left set would leak into the next request that thread serves. Conversion rounds half-up to match JS's Math.round rather than Python's round-half-to-even, so the server prints the number climate.js would and there is no flicker on load for a visitor whose unit already matched. Pages with no single city — the homepage strip, the hub, the interactive views — set no scope, emit no data-unit-default, and keep today's locale behaviour. Precipitation, wind and humidity stay imperial; that inconsistency is worth a follow-up but is outside this change. Two existing assertions were passing for the wrong reason: base.html.j2 mentions "°F/°C" in an HTML comment, so a bare `"°F" in html` check succeeded even on a page whose every temperature was Celsius. They now read the rendered spans. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
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{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
|
|
<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>
|