Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
119 lines
11 KiB
Django/Jinja
119 lines
11 KiB
Django/Jinja
<!DOCTYPE html>
|
|
<html lang="en"{% if events_enabled %} data-tg-events="1"{% endif %}>
|
|
<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" />
|
|
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when
|
|
added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
<meta name="apple-mobile-web-app-title" content="Thermograph" />
|
|
<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="{{ asset_base_url }}/logo.png?v=3" />
|
|
<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="{{ asset_base }}/favicon.svg?v=3" type="image/svg+xml" />
|
|
<link rel="icon" href="{{ asset_base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
|
|
<link rel="icon" href="{{ asset_base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
|
|
<link rel="icon" href="{{ asset_base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
|
|
<link rel="apple-touch-icon" href="{{ asset_base }}/apple-touch-icon.png?v=3" />
|
|
<link rel="manifest" href="{{ asset_base }}/manifest.webmanifest" />
|
|
{% block head_extra %}{% endblock %}
|
|
<link rel="stylesheet" href="{{ asset_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="#FFFFFF" stroke-opacity="0.08" stroke-width="4"/><rect x="115" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="211" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="259" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="307" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="355" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="403" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="211" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="259" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="307" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="355" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="403" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="67" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="163" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="211" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="259" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="307" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="355" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="403" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="67" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="211" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="403" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="67" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="115" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="211" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="307" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="67" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="115" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="307" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="355" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="67" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="115" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="163" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="211" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="259" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="307" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="355" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="67" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="115" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="163" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="211" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="259" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="307" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="355" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="67" y="403" width="42" height="42" rx="10" fill="#2166ac"/><rect x="67" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="307" width="42" height="42" rx="10" fill="#92c5de"/><rect x="115" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="163" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="211" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="259" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="307" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="403" y="115" width="42" height="42" rx="10" fill="#dd6b52"/><rect x="403" y="67" width="42" height="42" rx="10" fill="#8f0e20"/></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="{{ asset_base }}/calendar" data-view="calendar">Calendar</a>
|
|
<a href="{{ asset_base }}/day" data-view="day">Day Detail</a>
|
|
<a href="{{ asset_base }}/compare" data-view="compare">Compare</a>
|
|
<a href="{{ asset_base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
|
|
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
|
|
<a href="{{ asset_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.
|
|
Hidden for now; restore by uncommenting the form block below. #}
|
|
{#
|
|
<form class="digest-form digest-compact" method="post" action="{{ asset_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 third-party trackers. No account needed. Built by one
|
|
person in the open.</p>
|
|
{# Withdrawing consent must be as easy as giving it and reachable from
|
|
anywhere, so the control lives in the site-wide footer. consent.js
|
|
fills it in and leaves it empty when instrumentation is disabled. #}
|
|
<p class="muted consent-control" data-consent-control></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>
|
|
<p class="muted">Made by <a href="https://emigriffith.dev">emigriffith.dev</a>
|
|
<a href="mailto:emerytgriffith@gmail.com" aria-label="Email" title="emerytgriffith@gmail.com">@</a></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="{{ asset_base }}/units.js"></script>
|
|
<script type="module" src="{{ asset_base }}/account.js"></script>
|
|
<script type="module" src="{{ asset_base }}/climate.js"></script>
|
|
{% endblock %}
|
|
<script type="module" src="{{ asset_base }}/consent.js"></script>
|
|
<script type="module" src="{{ asset_base }}/digest.js"></script>
|
|
<script type="module" src="{{ asset_base }}/ios-install.js"></script>
|
|
</body>
|
|
</html>
|