// 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.
Pick a city and the metrics to watch — high, low, feels-like, rain, wind…
Set a threshold. Alert when a day passes, say, the 97th percentile for
that location. Higher = rarer, so fewer but more significant alerts.
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.”
Sign in / Create account
You'll need an account to create and manage alerts.
`;
body.querySelector("#gate-signin").onclick = () => openAuth("login");
}
function render() {
body.innerHTML = `
`;
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 ? "Turn off" : "Turn on"}
${enabled ? `Send test ` : ""}
`;
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");
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}
${TRASH_IC}
${metrics}
`;
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();