Replace em-dashes in user-facing copy across the server-rendered pages, static frontend views, and the strings the app injects at runtime, using colons, commas, parentheses or full stops as the context wants. Data placeholder glyphs (a lone "—" for a missing reading) are left alone, since a hyphen there reads as a minus sign in temperature columns. Also tighten the high-visibility surfaces (home hero and meta, about, privacy, city and records ledes, glossary blurbs) toward a plainer, more direct voice while keeping every factual claim intact. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
379 lines
15 KiB
JavaScript
379 lines
15 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.
|
|
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 = `<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 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 = `<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>`;
|
|
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>`;
|
|
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 = `<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(`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 = `
|
|
<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">×</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("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();
|