thermograph/static/compare.html
Emi Griffith f34ab7ef96 Add PWA + Web Push delivery for weather alerts (#95)
Make the app installable and deliver existing alert notifications as OS
push, alongside the in-app bell.

Backend:
- PushSubscription model (per-device endpoint + keys, owned by a user) and
  register/unregister/test endpoints under /api/v2/push, cookie-auth scoped
  to the user like the subscription routes.
- push.py: VAPID key management (env -> data/vapid.json -> generated) and a
  pywebpush send helper that reports gone endpoints for pruning. No DB coupling.
- notify.py: after creating an in-app Notification, dispatch Web Push to the
  user's devices (guarded — a push failure never affects the in-app write;
  endpoints reported gone are pruned).
- Serve the .webmanifest with the correct media type.

Frontend:
- manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages.
- sw.js: push + notificationclick handlers (push-only; no fetch caching, so it
  doesn't fight the existing IndexedDB cache). Registered globally in nav.js in
  secure contexts.
- push-client.js + a "Notifications on this device" toggle and test-send on the
  /alerts page, subscribing through the existing cookie-aware apiFetch.

Push and service workers require a secure context, so this is active over HTTPS
(or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
2026-07-15 23:21:06 +00:00

165 lines
8.6 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph — compare comfort</title>
<meta name="description" content="Pick any point on Earth and see how its recent weather stacks up against ~45 years of local climate history — graded days, calendars, forecasts, and side-by-side comparisons." />
<meta name="theme-color" content="#f0803c" />
<!-- 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 — compare comfort" />
<meta property="og:description" content="Pick any point on Earth and see how its recent weather stacks up against ~45 years of local climate history — graded days, calendars, forecasts, and side-by-side comparisons." />
<meta property="og:url" content="__ORIGIN__/compare" />
<meta property="og:image" content="__ORIGIN__/logo.png" />
<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="logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="logo.png" />
<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>
<div class="brand">
<span class="logo"></span>
<div>
<h1>Thermograph · Compare</h1>
<p class="tag">Which place best hits your comfort temperature over a date range</p>
</div>
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
<a href="day" data-view="day">Day</a>
<a href="compare" data-view="compare" class="active">Compare</a>
</nav>
</div>
</header>
<main>
<!-- Locations to line up against each other. -->
<section class="controls">
<div class="cmp-locs">
<span class="cal-filter-label">Locations</span>
<ul id="cmp-loc-list" class="cmp-loc-list"></ul>
<div class="cmp-loc-actions">
<button type="button" id="cmp-add" class="find-btn"></button>
<button type="button" id="cmp-refresh" class="cmp-refresh-btn" hidden>Refresh comparison ↻</button>
</div>
</div>
</section>
<!-- The comfort target, what temperature to judge by, the tolerance band, and
the date range. Comfort/band/basis recompute instantly (no refetch); only
the date range or the location set trigger a data load. -->
<!-- Comfort/tolerance target — the primary knob, kept inline above the results
(never inside the mobile filter sheet). Recomputes the ranking instantly. -->
<div id="cmp-comfort" class="cmp-comfort-wrap" hidden>
<div class="cmp-range-labels">
<span class="cmp-rl cmp-rl-comfort">Comfort <b id="cmp-comfort-val"></b></span>
<span class="cmp-rl cmp-rl-tol">Tolerance <b id="cmp-tol-val"></b></span>
</div>
<div class="cmp-ticks" id="cmp-ticks" aria-hidden="true">
<span class="cmp-tick tol" id="cmp-tick-tlo"></span>
<span class="cmp-tick" id="cmp-tick-lo"></span>
<span class="cmp-tick" id="cmp-tick-hi"></span>
<span class="cmp-tick tol" id="cmp-tick-thi"></span>
</div>
<div class="dual-range quad-range" id="cmp-slicer">
<div class="dual-track">
<span class="dual-fill dual-fill-tol" id="cmp-fill-tol"></span>
<span class="dual-fill" id="cmp-fill"></span>
</div>
<input id="cmp-tlo" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range lower bound" />
<input id="cmp-lo" type="range" min="30" max="100" step="1" aria-label="Comfort range lower bound" />
<input id="cmp-hi" type="range" min="30" max="100" step="1" aria-label="Comfort range upper bound" />
<input id="cmp-thi" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range upper bound" />
</div>
<span class="hint">Inner handles set your comfort range; outer handles set the tolerance range. Days in tolerance still count toward the score, scaled by how far past comfort they fall.</span>
<!-- Which temperature to judge each day by — a scoring control, so it sits with
the comfort slider above the results, not in the filter sheet. -->
<div class="cmp-basis-block">
<span class="cal-filter-label">Judge each day by its</span>
<div class="metric-toggle" id="cmp-basis" role="group" aria-label="Temperature to compare">
<button type="button" data-basis="tmax" class="active">High</button>
<button type="button" data-basis="mean">Mean</button>
<button type="button" data-basis="tmin">Low</button>
<button type="button" data-basis="feels">Feels</button>
</div>
</div>
</div>
<div id="cmp-params" class="cmp-params" hidden>
<div class="cmp-param cmp-season" id="cmp-season">
<span class="cal-filter-label">Time of year</span>
<div class="cmp-season-dd" id="cmp-season-dd"></div>
<span class="hint">Count only days in these seasons/months — re-ranks instantly.</span>
</div>
<div class="cmp-param cmp-range cal-range" id="cmp-range">
<div class="cal-range-head">
<span class="cal-filter-label">Date range</span>
<span class="hint">any span; longer loads take a moment</span>
</div>
<label>From <input id="cmp-start" type="month" /></label>
<label>To <input id="cmp-end" type="month" /></label>
<span class="hint">Editing the range arms Load — tap it to reload.</span>
<button type="button" id="cmp-refresh-sheet" class="cmp-refresh-btn" hidden>Load / Refresh ↻</button>
</div>
<!-- A synced copy of the "Distribution by" selector (below), for the phone
sheet; buttons are cloned from the inline one in JS. Hidden on desktop. -->
<div class="cmp-param cmp-dist-metric-block">
<span class="cal-filter-label">Distribution by</span>
<div class="metric-toggle" id="cmp-dist-metric-sheet" role="group" aria-label="Distribution metric (in filters)"></div>
</div>
</div>
<div id="cmp-head" class="cal-head" hidden></div>
<div class="placeholder" id="cmp-placeholder">
<p>Add two or more places to see which one best matches your comfort temperature
across a date range — the share of days that land in your comfort band, how
often it runs colder or warmer, and by how much.</p>
</div>
<!-- Baseline: every place's typical temperature on one shared axis, so their
baselines line up against each other and the shaded comfort range. -->
<section id="cmp-baseline" class="cmp-baseline" hidden></section>
<div id="cmp-results" class="cmp-results"></div>
<!-- Climate distribution, independent of the comfort filter: how each place's
days spread across a metric's Record-Low→Record-High categories, mirroring
the calendar's totals strip. Its own metric + share/count toggles. -->
<section id="cmp-dist" class="cmp-dist" hidden>
<div class="ct-head">
<div class="cmp-dist-controls">
<span class="cal-filter-label">Distribution by</span>
<div class="metric-toggle" id="cmp-dist-metric" role="group" aria-label="Distribution metric">
<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>
<label class="ct-count-toggle"><input type="checkbox" id="cmp-dist-count" /> Show count</label>
</div>
<div id="cmp-dist-body" class="cmp-dist-body"></div>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="compare.js"></script>
</body>
</html>