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.
131 lines
11 KiB
HTML
131 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Thermograph: 2-year calendar</title>
|
|
<meta name="description" content="A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." />
|
|
<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 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: 2-year calendar" />
|
|
<meta property="og:description" content="A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." />
|
|
<meta property="og:url" content="__ORIGIN__/calendar" />
|
|
<meta property="og:image" content="__ORIGIN__/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="canonical" href="__ORIGIN__/calendar" />
|
|
<link rel="icon" href="favicon.svg?v=3" type="image/svg+xml" />
|
|
<link rel="icon" href="favicon-48.png?v=3" type="image/png" sizes="48x48" />
|
|
<link rel="icon" href="favicon-32.png?v=3" type="image/png" sizes="32x32" />
|
|
<link rel="icon" href="favicon-16.png?v=3" type="image/png" sizes="16x16" />
|
|
<link rel="apple-touch-icon" href="apple-touch-icon.png?v=3" />
|
|
<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 class="compact">
|
|
<div class="brand">
|
|
<div>
|
|
<h1><a href="./"><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></h1>
|
|
</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">Weekly</a>
|
|
<a href="calendar" data-view="calendar" class="active">Calendar</a>
|
|
<a href="day" data-view="day">Day Detail</a>
|
|
<a href="compare" data-view="compare">Compare</a>
|
|
<a href="score" data-view="score">Score</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>
|
|
</section>
|
|
|
|
<div id="cal-head" class="cal-head" hidden></div>
|
|
|
|
<!-- Time filters: month/year range + season + year, each a compact dropdown. -->
|
|
<div id="cal-filters" class="cal-filters" hidden>
|
|
<div class="cal-range" id="cal-range">
|
|
<div class="cal-range-head">
|
|
<span class="cal-filter-label">Range</span>
|
|
<span class="hint">longer spans load 2 years at a time</span>
|
|
</div>
|
|
<label>From <input id="range-start" type="month" /></label>
|
|
<label>To <input id="range-end" type="month" /></label>
|
|
<button type="button" id="range-refresh" class="range-refresh" hidden>Refresh dates ↻</button>
|
|
</div>
|
|
<div id="cal-seasonyear" class="cal-seasonyear"></div>
|
|
<!-- A synced copy of the metric selector for the phone sheet; buttons are cloned
|
|
from the inline #metric-toggle in JS. Hidden on desktop. -->
|
|
<div class="cal-metric-sheet">
|
|
<span class="cal-filter-label">Color the calendar by</span>
|
|
<div class="metric-toggle" id="metric-toggle-sheet" role="group" aria-label="Color the calendar by (in filters)"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metric selector sits below the time filters, right above the grid it colors. -->
|
|
<div id="metric-block" class="metric-block" hidden>
|
|
<span class="cal-filter-label">Color the calendar by</span>
|
|
<div class="metric-toggle" id="metric-toggle" role="group" aria-label="Color the calendar by">
|
|
<button type="button" data-metric="tmax" class="active">High</button>
|
|
<button type="button" data-metric="tmin">Low</button>
|
|
<button type="button" data-metric="feels">Feels</button>
|
|
<button type="button" data-metric="humid">Humid</button>
|
|
<button type="button" data-metric="wind">Wind</button>
|
|
<button type="button" data-metric="gust">Gust</button>
|
|
<button type="button" data-metric="precip">Precip</button>
|
|
<button type="button" data-metric="dsr">Dry streak</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plain-language climatology insights for the selected month(s): typical
|
|
highs, rainy-day count, sunshine, and a cross-month rain comparison. -->
|
|
<div id="cal-insights" class="cal-insights" hidden></div>
|
|
|
|
<!-- Category totals for the current metric across the shown (and weather-
|
|
filtered) days: a stacked share bar + per-category percentages. -->
|
|
<div id="cal-totals" class="cal-totals" hidden></div>
|
|
|
|
<div class="placeholder" id="cal-placeholder">
|
|
<p>Search a place, or open this from a graded location on the map, to see how
|
|
every day of the last two years graded against that spot's own climate history.</p>
|
|
</div>
|
|
<div id="calendar" class="calendar"></div>
|
|
<div id="cal-key"></div>
|
|
<div id="cal-tip" class="chart-tip" hidden></div>
|
|
</main>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<!-- Product-event beacon. Inert unless the server flagged this page
|
|
on the root element; see frontend/static/track.js. -->
|
|
<script type="module" src="consent.js"></script>
|
|
<script type="module" src="track.js"></script>
|
|
<script type="module" src="calendar.js"></script>
|
|
<script type="module" src="ios-install.js"></script>
|
|
</body>
|
|
</html>
|