thermograph/frontend/static/subscriptions.js
emi 17cc14b48b
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 20s
PR build (required check) / changes (pull_request) Successful in 22s
shell-lint / shellcheck (pull_request) Successful in 19s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 2m27s
PR build (required check) / build-backend (pull_request) Successful in 2m51s
PR build (required check) / gate (pull_request) Successful in 3s
bookmarks: map + account UI for saved locations
2026-07-26 18:36:39 +00:00

472 lines
20 KiB
JavaScript

// 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.
//
// Also the closest thing to an account page, so it doubles as the Saved
// locations surface: bookmarks.js is the single source of truth for those (see
// its header comment), this just lists/renders/edits them the same way it does
// alert subscriptions, reusing .sub-list/.sub-card.
import "./nav.js";
import { apiFetch, openAuth, onAuthChange, uv } from "./account.js";
import { open as openPicker } from "./mappicker.js";
import * as pushClient from "./push-client.js";
import * as bookmarks from "./bookmarks.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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>`;
const PENCIL_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
const body = document.getElementById("alerts-body");
let subs = [];
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// Small stylesheet for the bits of the Saved locations section that don't
// already have a home in .sub-list/.sub-card — injected once, not appended to
// the shared style.css, so this stays self-contained.
(function injectBkmStyle() {
if (document.getElementById("tg-bkm-alerts-style")) return;
const s = document.createElement("style");
s.id = "tg-bkm-alerts-style";
s.textContent = `
.bkm-heading { font-size: 15px; margin: 26px 0 4px; }
.bkm-import-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 12px; }
.bkm-import-status { margin: -4px 0 10px; }
.bkm-card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.bkm-rename { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 7px; border-radius: 6px; display: inline-flex; }
.bkm-rename:hover { color: var(--text); background: var(--border); }
.bkm-card-loc { color: var(--muted); font-size: 12.5px; }
`;
document.head.appendChild(s);
})();
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(uv("subscriptions")); }
catch (e) { body.innerHTML = `<p class="alerts-loading">Offline. Can't load alerts.</p>`; return; }
if (res.status === 401) { renderGate(); return; }
if (!res.ok) { body.innerHTML = `<p class="alerts-loading">${esc(await readErr(res))}</p>`; return; }
subs = await res.json();
render();
}
function renderGate() {
body.innerHTML = `
<div class="alerts-gate panel">
<h2>What are weather alerts?</h2>
<p class="alerts-lede">Thermograph can watch any city and tell you when its weather turns
<b>unusually extreme for that place</b> (a heat spell, a cold snap, heavy rain), graded
against ~45 years of local climate history.</p>
<ol class="alerts-explain">
<li><b>Pick a city</b> and the metrics to watch: high, low, feels-like, rain, wind…</li>
<li><b>Set a threshold.</b> Alert when a day passes, say, the <b>97th percentile</b> for
that location. Higher = rarer, so fewer but more significant alerts.</li>
<li><b>Get notified</b> 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.</li>
</ol>
<p class="alerts-eg muted">For example: “Tell me when Tokyo's high passes the 97th percentile.”</p>
<button type="button" class="acct-submit" id="gate-signin">Sign in / Create account</button>
<p class="alerts-gate-note muted">You'll need an account to create and manage alerts.</p>
</div>`;
body.querySelector("#gate-signin").onclick = () => openAuth("login");
}
function render() {
body.innerHTML = `
<div class="push-bar" id="push-bar" hidden></div>
<div class="alerts-toolbar">
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
</div>
<ul class="sub-list" id="sub-list"></ul>
<section class="bkm-section" id="bkm-alerts-section" aria-labelledby="bkm-heading"></section>`;
body.querySelector("#add-alert").onclick = startAdd;
renderPushBar();
const list = body.querySelector("#sub-list");
if (!subs.length) {
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`;
} else {
subs.forEach((s) => list.appendChild(subCard(s)));
}
renderBookmarksSection();
}
// --- Saved locations (bookmarks) ---------------------------------------------
function bkmCard(b) {
const li = document.createElement("li");
li.className = "sub-card";
const label = b.label || `${b.lat.toFixed(3)}, ${b.lon.toFixed(3)}`;
li.innerHTML = `
<div class="sub-top">
<div class="sub-title">
<span class="sub-name">${esc(label)}</span>
${b.label ? `<span class="bkm-card-loc">${esc(b.lat.toFixed(3))}, ${esc(b.lon.toFixed(3))}</span>` : ""}
</div>
<span class="bkm-card-actions">
<button type="button" class="bkm-rename" title="Rename" aria-label="Rename ${esc(label)}">${PENCIL_IC}</button>
<button type="button" class="sub-remove" aria-label="Remove ${esc(label)}">${TRASH_IC}</button>
</span>
</div>`;
li.querySelector(".bkm-rename").onclick = () => {
const next = window.prompt("Rename this saved location:", b.label || "");
if (next && next.trim()) bookmarks.rename(b.id, next.trim());
};
li.querySelector(".sub-remove").onclick = () => bookmarks.remove(b.id);
return li;
}
function renderBookmarksSection() {
const el = document.getElementById("bkm-alerts-section");
if (!el) return;
const list = bookmarks.list();
el.innerHTML = `
<h2 class="bkm-heading" id="bkm-heading">Saved locations</h2>
<div class="bkm-import-row">
<p class="alerts-note muted">Places you've starred on the map, kept in sync with your account.</p>
<button type="button" class="find-btn" id="bkm-import-btn">Import saved locations from this device</button>
</div>
<p class="bkm-import-status muted" id="bkm-import-status" hidden></p>
<ul class="sub-list" id="bkm-list"></ul>`;
const ul = el.querySelector("#bkm-list");
if (!list.length) {
ul.innerHTML = `<li class="sub-empty muted">No saved locations yet. Star a spot on the map to see it here.</li>`;
} else {
list.forEach((b) => ul.appendChild(bkmCard(b)));
}
el.querySelector("#bkm-import-btn").onclick = async () => {
const btn = el.querySelector("#bkm-import-btn");
const status = el.querySelector("#bkm-import-status");
btn.disabled = true;
try {
const res = await bookmarks.importAllLocal();
status.hidden = false;
status.textContent = res.imported
? `Imported ${res.imported} location${res.imported === 1 ? "" : "s"}${res.skipped ? ` (${res.skipped} already saved)` : ""}.`
: "Nothing new to import from this device.";
} catch (e) {
status.hidden = false;
status.textContent = e.message || "Couldn't import right now.";
} finally {
btn.disabled = false;
}
};
}
// Bookmarks can change from outside this render cycle (rename/remove above,
// or an import banner) — keep the section live rather than only painting once.
bookmarks.subscribe(() => renderBookmarksSection());
// --- 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 = `<p class="push-note muted">Open this page over HTTPS (or on localhost) to get
OS notifications on this device. In-app alerts via the bell still work anywhere.</p>`;
return;
}
if (pushClient.permission() === "denied") {
el.hidden = false;
el.innerHTML = `<p class="push-note muted">Notifications are blocked for this site. Re-enable
them in your browser settings to get OS alerts here.</p>`;
return;
}
const enabled = await pushClient.isEnabled();
el.hidden = false;
el.innerHTML = `
<div class="push-row">
<span class="push-status">${enabled
? "🔔 OS notifications are on for this device."
: "🔕 OS notifications are off for this device."}</span>
<span class="push-actions">
<button type="button" class="find-btn push-toggle">${enabled ? "Turn off" : "Turn on"}</button>
${enabled ? `<button type="button" class="push-test-btn" title="Send a test notification">Send test</button>` : ""}
</span>
</div>`;
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) => `<span class="sub-metric">${esc(METRIC_LABEL[m] || m)}</span>`).join("");
li.innerHTML = `
<div class="sub-top">
<div class="sub-title">
<span class="sub-name">${esc(s.label || `${s.lat.toFixed(2)}, ${s.lon.toFixed(2)}`)}</span>
<span class="sub-kind sub-kind-${s.kind}">${kindLabel}</span>
</div>
<button type="button" class="sub-remove" aria-label="Remove alert">${TRASH_IC}</button>
</div>
<div class="sub-metrics">${metrics}</div>
<div class="sub-controls">
<div class="sub-thr">
<span class="sub-thr-label">Alert above the</span>
<span class="seg thr-seg" role="group" aria-label="Percentile threshold"></span>
<span class="sub-thr-label">percentile</span>
</div>
<label class="sub-active-lbl">
<input type="checkbox" class="sub-active" ${s.active ? "checked" : ""} /> Active
</label>
</div>`;
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(uv(`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(uv(`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 apiFetch(uv(`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 = `
<div class="mp-modal sub-editor" role="dialog" aria-modal="true" aria-label="New alert">
<div class="mp-head">
<h2>New alert</h2>
<button type="button" class="mp-close" aria-label="Close">&times;</button>
</div>
<form class="sub-form">
<p class="sub-form-loc"></p>
<fieldset class="sub-field">
<legend>Trigger on</legend>
<div class="seg kind-seg" role="group" aria-label="Alert kind">
<button type="button" data-kind="observed" class="active">Observed</button>
<button type="button" data-kind="forecast">Forecast</button>
</div>
<p class="sub-kind-help muted"></p>
</fieldset>
<fieldset class="sub-field">
<legend>Watch these metrics</legend>
<div class="metric-checks"></div>
</fieldset>
<fieldset class="sub-field">
<legend>Alert above the <b class="thr-val">97</b>th percentile</legend>
<input type="range" class="thr-range" min="95" max="99" step="1" value="97" />
<label class="two-sided-lbl">
<input type="checkbox" class="two-sided" checked />
Also alert unusually <i>low</i> extremes (cold snaps, calm/dry)
</label>
</fieldset>
<p class="sub-form-error" role="alert" hidden></p>
<button type="submit" class="acct-submit">Create alert</button>
</form>
</div>`;
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 = `<input type="checkbox" id="${id}" value="${key}" ${DEFAULT_METRICS.includes(key) ? "checked" : ""} /> ${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(uv("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();