From f34ab7ef96b711e2951ed9188d89d7065d089be2 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Wed, 15 Jul 2026 16:21:06 -0700 Subject: [PATCH] Add PWA + Web Push delivery for weather alerts (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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. --- static/calendar.html | 1 + static/compare.html | 1 + static/day.html | 1 + static/index.html | 1 + static/legend.html | 1 + static/logo-192.png | Bin 0 -> 4420 bytes static/logo-maskable-512.png | Bin 0 -> 2554 bytes static/manifest.webmanifest | 16 ++++++ static/nav.js | 10 ++++ static/push-client.js | 93 +++++++++++++++++++++++++++++++++++ static/style.css | 18 +++++++ static/subscriptions.html | 1 + static/subscriptions.js | 67 +++++++++++++++++++++++++ static/sw.js | 54 ++++++++++++++++++++ 14 files changed, 264 insertions(+) create mode 100644 static/logo-192.png create mode 100644 static/logo-maskable-512.png create mode 100644 static/manifest.webmanifest create mode 100644 static/push-client.js create mode 100644 static/sw.js diff --git a/static/calendar.html b/static/calendar.html index 0574411..1286d8c 100644 --- a/static/calendar.html +++ b/static/calendar.html @@ -21,6 +21,7 @@ + diff --git a/static/compare.html b/static/compare.html index d0b5d30..78d5be4 100644 --- a/static/compare.html +++ b/static/compare.html @@ -21,6 +21,7 @@ + diff --git a/static/day.html b/static/day.html index 60c9da5..a42427c 100644 --- a/static/day.html +++ b/static/day.html @@ -21,6 +21,7 @@ + diff --git a/static/index.html b/static/index.html index aaf29c1..9bfd069 100644 --- a/static/index.html +++ b/static/index.html @@ -21,6 +21,7 @@ + diff --git a/static/legend.html b/static/legend.html index 5ffd7b2..445381d 100644 --- a/static/legend.html +++ b/static/legend.html @@ -21,6 +21,7 @@ + diff --git a/static/logo-192.png b/static/logo-192.png new file mode 100644 index 0000000000000000000000000000000000000000..d3041b37f609e4867b33f63f68e545dd71c5e0ea GIT binary patch literal 4420 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 LEf6GqmwW#OfPPkU literal 0 HcmV?d00001 diff --git a/static/logo-maskable-512.png b/static/logo-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..75875d2c9fd62ff4d905cbf3f6d985533e8cb1a9 GIT binary patch literal 2554 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e4|=*dhE&XXd-t@ja-e|2 z#WWW_ojpmeDK4%lEDkbfxH=jCOD)da{mxR*X63w)?^|_#zDxZ6V;=*9X8krh28Iv+ zIT;u@gcukER2Uc(JQx@pCNMBGC@?ZGI0EfzVqsul8C5U@!=YsVSH>NB>(B3A+kb%3 zUq3%uo>AfnIOe}9-k7$uzWNA5fhMCs+Q<8LiVT0IFmP=8@m!GUKvW%zMDow`DeDeI zF)19GG~;YdqbZ9+%hL-9=ML;-acEe2A?e-$OO^u#(;O%7WM0nEa8Rew>@$0xAUFZF zefpIgd58Id0XX?2D6F@$E0%AlMI@rbKkl5id&l}=>S!7p3VE%qva0g-FOC?+?@T|u z;6;PMg!6lAp4l<{<^Y!*&Xr#`>bzqtm;sJcgBjQ3Xa8hA&e8Btr%|n#-A|B#ahhXy zCG&Fu2El1g&6dp1g%}j49nN{iy+)BCNvCt>8PSN>u!MQ+_2<0T$_0wu@;@{g8w7SZ zirE8G=>hvA2fl9l(s&7>%Bf4tY>7{f9TSF+oT1) z3=HBMf8@^>$Pi*U&>!*VGe@JCJrhgV=0E91*Tona)+BkJN!usO#9%QSl<;|hjBZfE z|IEqoK;`s;lXudE84d(abC~_}yeg#7WUu@S41=|Gv%MJ-M|k>n2nib|aj7Hr;2o^BEYYXC~jt82-_%=W_+Iyn8eaN&!nu= zE$82T{QFvd#yjh-Kfim;{ehEM{bUA)x>kW5wqo_OfvmX_JL0C<$%9JJpMnabb@A|P cllFVdQ&MBb@0Mod~CIA2c literal 0 HcmV?d00001 diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..7d2827d --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Thermograph — how unusual is your weather?", + "short_name": "Thermograph", + "description": "See how recent weather anywhere on Earth stacks up against ~45 years of local climate history, and get alerts when it turns unusual.", + "start_url": ".", + "scope": "./", + "display": "standalone", + "background_color": "#171b21", + "theme_color": "#f0803c", + "icons": [ + { "src": "logo.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "logo-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "logo.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "logo-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/static/nav.js b/static/nav.js index 3473732..c2c971b 100644 --- a/static/nav.js +++ b/static/nav.js @@ -55,3 +55,13 @@ function refreshNav(lat, lon, date) { const loc = loadLastLocation(); if (loc) refreshNav(loc.lat, loc.lon, loc.date); })(); + +// Register the service worker so the app is installable and can receive Web Push +// (see sw.js). Base-relative, so it resolves under /thermograph automatically and +// takes /thermograph/ as its scope. A no-op in insecure contexts (plain-HTTP LAN) +// — registration just rejects and we swallow it; in-app features are unaffected. +if ("serviceWorker" in navigator && window.isSecureContext) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("sw.js").catch(() => {}); + }); +} diff --git a/static/push-client.js b/static/push-client.js new file mode 100644 index 0000000..f6efadb --- /dev/null +++ b/static/push-client.js @@ -0,0 +1,93 @@ +// Web Push enablement for the current device. The service worker itself is +// registered globally by nav.js; this module owns the *subscription* handshake: +// ask permission, subscribe with the server's VAPID key, and register the +// resulting endpoint against the signed-in user (via account.js's cookie-aware +// apiFetch). Used by the /alerts page to render a "Notifications on this device" +// control. +// +// Push + service workers require a secure context (HTTPS, or http://localhost), +// so on a plain-HTTP LAN origin this cleanly reports "unsupported" instead of +// throwing. +import { apiFetch } from "./account.js"; + +export function supported() { + return ( + typeof window !== "undefined" && + window.isSecureContext && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +// The applicationServerKey must be a Uint8Array of the base64url-decoded VAPID +// public key. +function urlB64ToUint8Array(base64) { + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(b64); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +async function registration() { + // nav.js registered sw.js already; ready resolves once it's active. + return navigator.serviceWorker.ready; +} + +// Is this device currently subscribed? (a PushSubscription exists locally) +export async function isEnabled() { + if (!supported()) return false; + try { + const reg = await registration(); + return !!(await reg.pushManager.getSubscription()); + } catch (e) { + return false; + } +} + +export function permission() { + return supported() ? Notification.permission : "unsupported"; +} + +// Subscribe this device and register it server-side. Throws on failure so the +// caller can surface a message. +export async function enable() { + if (!supported()) throw new Error("This browser doesn't support push notifications."); + const perm = await Notification.requestPermission(); + if (perm !== "granted") throw new Error("Notification permission was not granted."); + + const reg = await registration(); + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + const res = await apiFetch("api/v2/push/vapid-key"); + if (!res.ok) throw new Error("Couldn't fetch the server key."); + const { key } = await res.json(); + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlB64ToUint8Array(key), + }); + } + const res = await apiFetch("api/v2/push/subscribe", { method: "POST", json: sub.toJSON() }); + if (!res.ok) throw new Error("Couldn't register this device."); +} + +// Unsubscribe locally and remove the endpoint server-side. +export async function disable() { + if (!supported()) return; + const reg = await registration(); + const sub = await reg.pushManager.getSubscription(); + if (!sub) return; + try { + await apiFetch("api/v2/push/subscribe", { method: "DELETE", json: { endpoint: sub.endpoint } }); + } catch (e) { /* best-effort server cleanup */ } + await sub.unsubscribe(); +} + +// Ask the server to push a test notification to every device on the account. +export async function sendTest() { + const res = await apiFetch("api/v2/push/test", { method: "POST" }); + if (!res.ok) throw new Error("Couldn't send a test notification."); + return res.json(); +} diff --git a/static/style.css b/static/style.css index 4505592..534f3a7 100644 --- a/static/style.css +++ b/static/style.css @@ -382,6 +382,24 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } .alerts-toolbar .find-btn { padding: 11px 18px; min-height: 44px; } .alerts-note { margin: 0; font-size: 13px; } +/* Per-device push toggle, above the account-wide alert list. */ +.push-bar { + border: 1px solid var(--border); background: var(--surface); border-radius: 14px; + padding: 12px 16px; margin-bottom: 16px; +} +.push-row { + display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; +} +.push-status { font-size: 14px; font-weight: 600; } +.push-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.push-toggle { padding: 9px 16px; min-height: 44px; } +.push-test-btn { + min-height: 44px; padding: 9px 14px; border-radius: 10px; cursor: pointer; + border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 14px; +} +.push-test-btn:hover { border-color: var(--accent); } +.push-note { margin: 0; } + .sub-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; } @media (min-width: 720px) { .sub-list { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } } .sub-empty { padding: 20px; border: 1px dashed var(--border); border-radius: 12px; text-align: center; } diff --git a/static/subscriptions.html b/static/subscriptions.html index 767397f..78fda19 100644 --- a/static/subscriptions.html +++ b/static/subscriptions.html @@ -18,6 +18,7 @@ + diff --git a/static/subscriptions.js b/static/subscriptions.js index c235147..f9850c3 100644 --- a/static/subscriptions.js +++ b/static/subscriptions.js @@ -5,6 +5,7 @@ import "./nav.js"; import { apiFetch, openAuth, onAuthChange } from "./account.js"; import { open as openPicker } from "./mappicker.js"; +import * as pushClient from "./push-client.js"; // Metric keys a user can watch, with friendly labels. (fmax/fmin exist server-side // but are omitted from the picker to keep it focused; labelled here if present.) @@ -64,12 +65,14 @@ function renderGate() { function render() { body.innerHTML = ` +

Each alert sends at most one notification per week.

    `; body.querySelector("#add-alert").onclick = startAdd; + renderPushBar(); const list = body.querySelector("#sub-list"); if (!subs.length) { list.innerHTML = `
  • No alerts yet. Add a city to get started.
  • `; @@ -78,6 +81,70 @@ function render() { subs.forEach((s) => list.appendChild(subCard(s))); } +// --- push notifications toggle (this device) --------------------------------- +// Delivery over OS push is per-device: a subscription lives in each browser, so +// this control reflects/toggles *this* device, separate from the account-wide +// alert list below. +async function renderPushBar() { + const el = document.getElementById("push-bar"); + if (!el) return; + + if (!pushClient.supported()) { + // Insecure context (plain-HTTP LAN) or an old browser — push simply isn't + // available. Keep it quiet: a single muted line, no dead controls. + el.hidden = false; + el.innerHTML = `

    Open this page over HTTPS (or on localhost) to get + OS notifications on this device. In-app alerts via the bell still work anywhere.

    `; + return; + } + if (pushClient.permission() === "denied") { + el.hidden = false; + el.innerHTML = `

    Notifications are blocked for this site — re-enable + them in your browser settings to get OS alerts here.

    `; + return; + } + + const enabled = await pushClient.isEnabled(); + el.hidden = false; + el.innerHTML = ` +
    + ${enabled + ? "🔔 OS notifications are on for this device." + : "🔕 OS notifications are off for this device."} + + + ${enabled ? `` : ""} + +
    `; + + el.querySelector(".push-toggle").onclick = async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + try { + if (enabled) await pushClient.disable(); + else await pushClient.enable(); + } catch (err) { + alert(err.message || "Couldn't change notification settings."); + } + renderPushBar(); // repaint from the new state + }; + const testBtn = el.querySelector(".push-test-btn"); + if (testBtn) { + testBtn.onclick = async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + const original = btn.textContent; + try { + await pushClient.sendTest(); + btn.textContent = "Sent ✓"; + } catch (err) { + alert(err.message || "Couldn't send a test notification."); + } + setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2500); + }; + } +} + // --- a single subscription card ---------------------------------------------- function subCard(s) { const li = document.createElement("li"); diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..4b10139 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,54 @@ +// Thermograph service worker — push delivery only. +// +// Deliberately NO fetch/offline caching: the app already has an app-level +// IndexedDB response cache (cache.js), and a caching service worker would fight +// it. This worker exists purely so the app is installable (a registered SW is a +// PWA prerequisite) and can receive Web Push while no tab is open. +// +// Served from {BASE}/sw.js, so its scope is {BASE}/ — it controls the whole app. + +self.addEventListener("install", () => { + self.skipWaiting(); // activate this version immediately +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); // take control of open pages at once +}); + +// A push arrived. The payload is the JSON we send from push.py: +// { title, body, url, tag }. +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch (e) { + data = { title: "Thermograph", body: event.data ? event.data.text() : "" }; + } + const title = data.title || "Thermograph"; + const options = { + body: data.body || "", + icon: "logo-192.png", + badge: "logo-192.png", + tag: data.tag || "thermograph", + data: { url: data.url || "./" }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// Tapping a notification: focus an existing app tab if one is open, else open the +// deep link the notification carried. +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const target = (event.notification.data && event.notification.data.url) || "./"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ("focus" in client) { + client.navigate(target).catch(() => {}); + return client.focus(); + } + } + return self.clients.openWindow(target); + }) + ); +});