// Weather-alerts management page (/alerts). Lists a user's subscriptions and lets // them add a city (via the shared map picker), tune the percentile threshold and // watched metrics, toggle active, and remove. Auth-gated: signed-out visitors get // a sign-in prompt. All calls go through account.js's cookie-aware apiFetch. 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.) const METRICS = [ ["tmax", "High temp"], ["tmin", "Low temp"], ["feels", "Feels-like"], ["humid", "Humidity"], ["wind", "Wind"], ["gust", "Gust"], ["precip", "Precipitation"], ]; const METRIC_LABEL = Object.fromEntries(METRICS); METRIC_LABEL.fmax = "Feels-like high"; METRIC_LABEL.fmin = "Feels-like low"; const DEFAULT_METRICS = ["tmax", "feels", "precip"]; const TRASH_IC = ``; const body = document.getElementById("alerts-body"); let subs = []; function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } async function readErr(res) { try { const d = await res.json(); if (typeof d.detail === "string") return d.detail; } catch (e) {} return `Request failed (${res.status}).`; } // --- load + top-level render ------------------------------------------------- async function load() { let res; try { res = await apiFetch("api/v2/subscriptions"); } catch (e) { body.innerHTML = `

Offline — can't load alerts.

`; return; } if (res.status === 401) { renderGate(); return; } if (!res.ok) { body.innerHTML = `

${esc(await readErr(res))}

`; return; } subs = await res.json(); render(); } function renderGate() { body.innerHTML = `

What are weather alerts?

Thermograph can watch any city and tell you when its weather turns unusually extreme for that place — a heat spell, a cold snap, heavy rain — graded against ~45 years of local climate history.

  1. Pick a city and the metrics to watch — high, low, feels-like, rain, wind…
  2. Set a threshold. Alert when a day passes, say, the 97th percentile for that location. Higher = rarer, so fewer but more significant alerts.
  3. Get notified in-app (and via OS push on this device) when a recorded day — or the upcoming forecast — crosses your threshold. At most one notification per week per alert.

For example: “Tell me when Tokyo's high passes the 97th percentile.”

You'll need an account to create and manage alerts.

`; body.querySelector("#gate-signin").onclick = () => openAuth("login"); } 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.
  • `; return; } 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 { const r = await pushClient.sendTest(); if (!r.devices) { btn.textContent = "No device"; alert("No device is registered for push on this account. Turn alerts off and on again on this device to re-subscribe."); } else if (r.sent > 0) { btn.textContent = "Sent ✓"; } else { btn.textContent = "Failed"; alert("The push service rejected delivery to this device. Turn alerts off and on again to re-subscribe; if it keeps failing, the server's push (VAPID) keys may have changed."); } } 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"); li.className = "sub-card" + (s.active ? "" : " is-paused"); li.dataset.id = s.id; const kindLabel = s.kind === "forecast" ? "Forecast" : "Observed"; const metrics = s.metrics.map((m) => `${esc(METRIC_LABEL[m] || m)}`).join(""); li.innerHTML = `
    ${esc(s.label || `${s.lat.toFixed(2)}, ${s.lon.toFixed(2)}`)} ${kindLabel}
    ${metrics}
    Alert above the percentile
    `; const seg = li.querySelector(".thr-seg"); [95, 96, 97, 98, 99].forEach((p) => { const b = document.createElement("button"); b.type = "button"; b.textContent = p; b.className = p === s.threshold ? "active" : ""; b.onclick = () => patch(s.id, { threshold: p }, li); seg.appendChild(b); }); li.querySelector(".sub-active").onchange = (e) => patch(s.id, { active: e.target.checked }, li); li.querySelector(".sub-remove").onclick = () => removeSub(s.id, li); return li; } async function patch(id, data, li) { li.classList.add("is-busy"); try { const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "PATCH", json: data }); if (!res.ok) throw new Error(await readErr(res)); const updated = await res.json(); subs = subs.map((s) => (s.id === id ? updated : s)); li.replaceWith(subCard(updated)); } catch (e) { li.classList.remove("is-busy"); alert(e.message || "Couldn't update the alert."); } } async function removeSub(id, li) { li.classList.add("is-busy"); try { const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "DELETE" }); if (!res.ok && res.status !== 404) throw new Error(await readErr(res)); subs = subs.filter((s) => s.id !== id); li.remove(); if (!subs.length) render(); } catch (e) { li.classList.remove("is-busy"); alert(e.message || "Couldn't remove the alert."); } } // --- add flow: pick a location, then configure ------------------------------- function startAdd() { openPicker(null, async (lat, lon) => { let label = null; try { const r = await fetch(`api/v2/place?lat=${lat}&lon=${lon}`); const d = await r.json(); label = d.place || null; } catch (e) { /* label optional; server falls back to cached/coords */ } openEditor({ lat, lon, label }); }); } let editor = null; function buildEditor() { editor = document.createElement("div"); editor.className = "mp-overlay acct-overlay sub-editor-overlay"; editor.hidden = true; editor.innerHTML = ` `; document.body.appendChild(editor); editor.querySelector(".mp-close").onclick = closeEditor; editor.addEventListener("pointerdown", (e) => { if (e.target === editor) closeEditor(); }); document.addEventListener("keydown", (e) => { if (!editor.hidden && e.key === "Escape") closeEditor(); }); // metric checkboxes const checks = editor.querySelector(".metric-checks"); METRICS.forEach(([key, label]) => { const id = `mc-${key}`; const wrap = document.createElement("label"); wrap.className = "metric-check"; wrap.innerHTML = ` ${esc(label)}`; checks.appendChild(wrap); }); // kind toggle editor.querySelector(".kind-seg").addEventListener("click", (e) => { const b = e.target.closest("button[data-kind]"); if (!b) return; editor.querySelectorAll(".kind-seg button").forEach((x) => x.classList.toggle("active", x === b)); updateKindHelp(); }); // threshold range const range = editor.querySelector(".thr-range"); range.addEventListener("input", () => { editor.querySelector(".thr-val").textContent = range.value; }); editor.querySelector(".sub-form").addEventListener("submit", onCreate); } function updateKindHelp() { const kind = editor.querySelector(".kind-seg button.active").dataset.kind; editor.querySelector(".sub-kind-help").textContent = kind === "forecast" ? "Heads-up in advance when the upcoming forecast is projected to pass your threshold." : "Notify me once a recorded day has actually passed the threshold."; } let editorLoc = null; function openEditor(loc) { if (!editor) buildEditor(); editorLoc = loc; editor.querySelector(".sub-form-loc").textContent = loc.label || `${loc.lat.toFixed(3)}, ${loc.lon.toFixed(3)}`; editor.querySelector(".kind-seg button[data-kind='observed']").click(); editor.querySelectorAll(".metric-checks input").forEach((c) => { c.checked = DEFAULT_METRICS.includes(c.value); }); const range = editor.querySelector(".thr-range"); range.value = 97; editor.querySelector(".thr-val").textContent = "97"; editor.querySelector(".two-sided").checked = true; showFormError(""); editor.hidden = false; } function closeEditor() { if (editor) editor.hidden = true; } function showFormError(msg) { const el = editor.querySelector(".sub-form-error"); el.textContent = msg || ""; el.hidden = !msg; } async function onCreate(e) { e.preventDefault(); const metrics = [...editor.querySelectorAll(".metric-checks input:checked")].map((c) => c.value); if (!metrics.length) { showFormError("Pick at least one metric to watch."); return; } const payload = { lat: editorLoc.lat, lon: editorLoc.lon, label: editorLoc.label || undefined, threshold: Number(editor.querySelector(".thr-range").value), metrics, kind: editor.querySelector(".kind-seg button.active").dataset.kind, two_sided: editor.querySelector(".two-sided").checked, }; const btn = editor.querySelector(".sub-form .acct-submit"); btn.disabled = true; showFormError(""); try { const res = await apiFetch("api/v2/subscriptions", { method: "POST", json: payload }); if (!res.ok) throw new Error(await readErr(res)); const created = await res.json(); subs.push(created); render(); closeEditor(); } catch (err) { showFormError(err.message || "Couldn't create the alert."); } finally { btn.disabled = false; } } // --- boot: react to auth resolving (account.js emits after checking the cookie) - onAuthChange(() => load()); load();