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 0000000..d3041b3 Binary files /dev/null and b/static/logo-192.png differ diff --git a/static/logo-maskable-512.png b/static/logo-maskable-512.png new file mode 100644 index 0000000..75875d2 Binary files /dev/null and b/static/logo-maskable-512.png differ 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 = ` +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 = ` +