2026-07-21 17:48:55 +00:00
|
|
|
<!DOCTYPE html>
|
UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.
Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.
Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.
Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 22:49:55 +00:00
|
|
|
<html lang="en"{% if events_enabled %} data-tg-events="1"{% endif %}>
|
2026-07-21 17:48:55 +00:00
|
|
|
<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() }}" />
|
2026-07-21 20:52:11 +00:00
|
|
|
<meta property="og:image" content="{{ asset_base_url }}/logo.png?v=3" />
|
2026-07-21 17:48:55 +00:00
|
|
|
<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" />
|
2026-07-21 20:52:11 +00:00
|
|
|
<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" />
|
2026-07-21 17:48:55 +00:00
|
|
|
{% block head_extra %}{% endblock %}
|
2026-07-21 20:52:11 +00:00
|
|
|
<link rel="stylesheet" href="{{ asset_base }}/style.css" />
|
2026-07-21 17:48:55 +00:00
|
|
|
{% 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" w
|
|
|
|
|
<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>
|
2026-07-21 20:52:11 +00:00
|
|
|
<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>
|
2026-07-21 17:48:55 +00:00
|
|
|
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
|
2026-07-21 20:52:11 +00:00
|
|
|
<a href="{{ asset_base }}/alerts">Alerts</a>
|
2026-07-21 17:48:55 +00:00
|
|
|
</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. #}
|
|
|
|
|
{#
|
2026-07-21 20:52:11 +00:00
|
|
|
<form class="digest-form digest-compact" method="post" action="{{ asset_base }}/digest"
|
2026-07-21 17:48:55 +00:00
|
|
|
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>
|
UI events v2: consented session tier, failed-search capture, privacy rewrite
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.
2026-07-23 23:11:06 +00:00
|
|
|
<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>
|
2026-07-21 17:48:55 +00:00
|
|
|
<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. -->
|
2026-07-21 20:52:11 +00:00
|
|
|
<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>
|
2026-07-21 17:48:55 +00:00
|
|
|
{% endblock %}
|
UI events v2: consented session tier, failed-search capture, privacy rewrite
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.
2026-07-23 23:11:06 +00:00
|
|
|
<script type="module" src="{{ asset_base }}/consent.js"></script>
|
2026-07-21 20:52:11 +00:00
|
|
|
<script type="module" src="{{ asset_base }}/digest.js"></script>
|
|
|
|
|
<script type="module" src="{{ asset_base }}/ios-install.js"></script>
|
2026-07-21 17:48:55 +00:00
|
|
|
</body>
|
|
|
|
|
</html>
|