Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
This commit is contained in:
parent
cd55b63e48
commit
d742cfe11f
9 changed files with 873 additions and 1 deletions
342
static/account.js
Normal file
342
static/account.js
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
// Accounts: the header "Sign in / account" entry, the auth modal, and the shared
|
||||
// helpers other modules (subscriptions.js) use to talk to authed endpoints.
|
||||
//
|
||||
// Auth is a same-origin HttpOnly cookie, so the frontend stores no token — it just
|
||||
// sends `credentials: same-origin` and asks `GET users/me` who it is. Loaded on
|
||||
// every page the way units.js is (imported by each page's entry module).
|
||||
|
||||
let currentUser = null; // {id, email, display_name} or null
|
||||
const authCbs = []; // notified on login/logout so pages can re-gate
|
||||
|
||||
// --- shared fetch helper -----------------------------------------------------
|
||||
// Base-relative URL (resolves under /thermograph automatically), cookie sent,
|
||||
// and a constant X-TG-Auth header the server can require as cheap CSRF defense
|
||||
// (trivial same-origin, impossible cross-site without a preflight we never grant).
|
||||
export async function apiFetch(path, { method = "GET", json, form } = {}) {
|
||||
const opts = { method, credentials: "same-origin", headers: { "X-TG-Auth": "1" } };
|
||||
if (json !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(json);
|
||||
} else if (form !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
opts.body = new URLSearchParams(form).toString();
|
||||
}
|
||||
return fetch(path, opts);
|
||||
}
|
||||
|
||||
// Parse a JSON response, throwing a friendly Error on failure. fastapi-users
|
||||
// returns machine codes ("LOGIN_BAD_CREDENTIALS") or {detail:{reason}} for
|
||||
// password rejects; map the common ones to human text.
|
||||
async function readJson(res) {
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* empty body (e.g. 204) */ }
|
||||
if (!res.ok) {
|
||||
const detail = data && data.detail;
|
||||
let msg;
|
||||
if (detail && typeof detail === "object" && detail.reason) msg = detail.reason;
|
||||
else if (detail === "LOGIN_BAD_CREDENTIALS") msg = "Wrong email or password.";
|
||||
else if (detail === "REGISTER_USER_ALREADY_EXISTS") msg = "That email is already registered.";
|
||||
else if (typeof detail === "string") msg = detail.replace(/_/g, " ").toLowerCase();
|
||||
else msg = `Request failed (${res.status}).`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getUser() { return currentUser; }
|
||||
export function onAuthChange(cb) { authCbs.push(cb); }
|
||||
function emitAuth() { authCbs.forEach((cb) => { try { cb(currentUser); } catch (e) {} }); }
|
||||
|
||||
async function refreshUser() {
|
||||
try {
|
||||
const res = await apiFetch("api/v2/users/me");
|
||||
currentUser = res.ok ? await res.json() : null;
|
||||
} catch (e) { currentUser = null; }
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// --- auth calls --------------------------------------------------------------
|
||||
async function login(email, password) {
|
||||
// fastapi-users login is an OAuth2 form: username=email, password.
|
||||
const res = await apiFetch("api/v2/auth/login", { method: "POST", form: { username: email, password } });
|
||||
await readJson(res); // throws on bad creds; 204 body is empty
|
||||
}
|
||||
async function register(email, password) {
|
||||
const res = await apiFetch("api/v2/auth/register", { method: "POST", json: { email, password } });
|
||||
await readJson(res);
|
||||
}
|
||||
export async function logout() {
|
||||
try { await apiFetch("api/v2/auth/logout", { method: "POST" }); } catch (e) {}
|
||||
currentUser = null;
|
||||
renderHeader();
|
||||
emitAuth();
|
||||
}
|
||||
|
||||
// --- auth modal --------------------------------------------------------------
|
||||
let modal = null, mode = "login";
|
||||
|
||||
function buildModal() {
|
||||
modal = document.createElement("div");
|
||||
modal.className = "mp-overlay acct-overlay";
|
||||
modal.hidden = true;
|
||||
modal.innerHTML = `
|
||||
<div class="mp-modal acct-modal" role="dialog" aria-modal="true" aria-label="Account">
|
||||
<div class="mp-head">
|
||||
<h2 class="acct-title">Sign in</h2>
|
||||
<button type="button" class="mp-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form class="acct-form" autocomplete="on">
|
||||
<label>Email
|
||||
<input type="email" name="email" required autocomplete="email" placeholder="you@example.com" />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required minlength="8" autocomplete="current-password" placeholder="At least 8 characters" />
|
||||
</label>
|
||||
<p class="acct-error" role="alert" hidden></p>
|
||||
<button type="submit" class="acct-submit">Sign in</button>
|
||||
<p class="acct-switch"></p>
|
||||
</form>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelector(".mp-close").onclick = closeAuth;
|
||||
modal.addEventListener("pointerdown", (e) => { if (e.target === modal) closeAuth(); });
|
||||
document.addEventListener("keydown", (e) => { if (!modal.hidden && e.key === "Escape") closeAuth(); });
|
||||
|
||||
const form = modal.querySelector(".acct-form");
|
||||
form.addEventListener("submit", onSubmit);
|
||||
modal.querySelector(".acct-switch").addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (b) setMode(mode === "login" ? "register" : "login");
|
||||
});
|
||||
setMode("login");
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
const isLogin = m === "login";
|
||||
modal.querySelector(".acct-title").textContent = isLogin ? "Sign in" : "Create account";
|
||||
modal.querySelector(".acct-submit").textContent = isLogin ? "Sign in" : "Create account";
|
||||
modal.querySelector('input[name="password"]').setAttribute(
|
||||
"autocomplete", isLogin ? "current-password" : "new-password");
|
||||
modal.querySelector(".acct-switch").innerHTML = isLogin
|
||||
? 'Need an account? <button type="button">Create one</button>'
|
||||
: 'Already have an account? <button type="button">Sign in</button>';
|
||||
showError("");
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = modal.querySelector(".acct-error");
|
||||
el.textContent = msg || "";
|
||||
el.hidden = !msg;
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
const email = modal.querySelector('input[name="email"]').value.trim();
|
||||
const password = modal.querySelector('input[name="password"]').value;
|
||||
const btn = modal.querySelector(".acct-submit");
|
||||
btn.disabled = true;
|
||||
showError("");
|
||||
try {
|
||||
if (mode === "register") { await register(email, password); }
|
||||
await login(email, password); // register flow signs in right after
|
||||
await refreshUser();
|
||||
closeAuth();
|
||||
renderHeader();
|
||||
emitAuth();
|
||||
} catch (err) {
|
||||
showError(err.message || "Something went wrong.");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openAuth(startMode = "login") {
|
||||
if (!modal) buildModal();
|
||||
setMode(startMode);
|
||||
modal.querySelector('input[name="email"]').value = "";
|
||||
modal.querySelector('input[name="password"]').value = "";
|
||||
modal.hidden = false;
|
||||
modal.querySelector('input[name="email"]').focus();
|
||||
}
|
||||
function closeAuth() { if (modal) modal.hidden = true; }
|
||||
|
||||
// --- notifications (header bell) ---------------------------------------------
|
||||
const BELL_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="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`;
|
||||
|
||||
let notifications = [];
|
||||
let unreadCount = 0;
|
||||
let notifTimer = null;
|
||||
|
||||
function timeAgo(sec) {
|
||||
const d = Date.now() / 1000 - sec;
|
||||
if (d < 60) return "just now";
|
||||
if (d < 3600) return `${Math.floor(d / 60)}m ago`;
|
||||
if (d < 86400) return `${Math.floor(d / 3600)}h ago`;
|
||||
return `${Math.floor(d / 86400)}d ago`;
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
try {
|
||||
const res = await apiFetch("api/v2/notifications?limit=50");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
notifications = data.notifications || [];
|
||||
unreadCount = data.unread_count || 0;
|
||||
paintBadge();
|
||||
paintNotifList();
|
||||
} catch (e) { /* offline — leave prior state */ }
|
||||
}
|
||||
|
||||
function paintBadge() {
|
||||
if (!acctEl) return;
|
||||
const badge = acctEl.querySelector(".notif-badge");
|
||||
if (!badge) return;
|
||||
badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
badge.hidden = unreadCount === 0;
|
||||
}
|
||||
|
||||
function paintNotifList() {
|
||||
if (!acctEl) return;
|
||||
const list = acctEl.querySelector(".notif-list");
|
||||
if (!list) return;
|
||||
if (!notifications.length) {
|
||||
list.innerHTML = `<li class="notif-empty muted">No alerts yet. Weather notifications will show up here.</li>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notifications.map((n) => `
|
||||
<li class="notif-item ${n.read_at ? "" : "is-unread"}" data-id="${n.id}">
|
||||
<div class="notif-item-title">${escapeHtml(n.title)}</div>
|
||||
<div class="notif-item-body">${escapeHtml(n.body || "")}</div>
|
||||
<div class="notif-item-time muted">${timeAgo(n.created_at)}</div>
|
||||
</li>`).join("");
|
||||
list.querySelectorAll(".notif-item.is-unread").forEach((li) => {
|
||||
li.addEventListener("click", () => markRead(Number(li.dataset.id), li));
|
||||
});
|
||||
}
|
||||
|
||||
async function markRead(id, li) {
|
||||
try {
|
||||
const res = await apiFetch(`api/v2/notifications/${id}/read`, { method: "POST" });
|
||||
if (!res.ok) return;
|
||||
const n = notifications.find((x) => x.id === id);
|
||||
if (n && !n.read_at) { n.read_at = Date.now() / 1000; unreadCount = Math.max(0, unreadCount - 1); }
|
||||
li.classList.remove("is-unread");
|
||||
paintBadge();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
const res = await apiFetch("api/v2/notifications/read-all", { method: "POST" });
|
||||
if (!res.ok) return;
|
||||
notifications.forEach((n) => { n.read_at = n.read_at || Date.now() / 1000; });
|
||||
unreadCount = 0;
|
||||
paintBadge();
|
||||
paintNotifList();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function startNotifPolling() {
|
||||
loadNotifications();
|
||||
if (notifTimer) return;
|
||||
notifTimer = setInterval(loadNotifications, 120000); // refresh unread every 2 min
|
||||
}
|
||||
function stopNotifPolling() {
|
||||
if (notifTimer) { clearInterval(notifTimer); notifTimer = null; }
|
||||
notifications = []; unreadCount = 0;
|
||||
}
|
||||
|
||||
// --- header entry ------------------------------------------------------------
|
||||
const USER_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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
||||
|
||||
let acctEl = null;
|
||||
|
||||
function ensureAcctEl() {
|
||||
const brand = document.querySelector(".brand");
|
||||
if (!brand) return null;
|
||||
if (!acctEl) {
|
||||
acctEl = document.createElement("div");
|
||||
acctEl.className = "acct";
|
||||
const nav = brand.querySelector(".view-nav");
|
||||
brand.insertBefore(acctEl, nav); // top-right, just left of the view tabs
|
||||
}
|
||||
return acctEl;
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const el = ensureAcctEl();
|
||||
if (!el) return;
|
||||
if (currentUser) {
|
||||
const name = currentUser.display_name || currentUser.email;
|
||||
el.innerHTML = `
|
||||
<div class="notif">
|
||||
<button type="button" class="acct-btn notif-btn" aria-haspopup="true" aria-expanded="false" aria-label="Notifications">
|
||||
${BELL_IC}<span class="notif-badge" hidden>0</span>
|
||||
</button>
|
||||
<div class="notif-pop" hidden>
|
||||
<div class="notif-head">
|
||||
<span>Notifications</span>
|
||||
<button type="button" class="notif-readall">Mark all read</button>
|
||||
</div>
|
||||
<ul class="notif-list"></ul>
|
||||
<a href="alerts" class="notif-manage">Manage alerts →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acct-menu">
|
||||
<button type="button" class="acct-btn" aria-haspopup="true" aria-expanded="false" aria-label="Account: ${escapeHtml(name)}">
|
||||
${USER_IC}<span class="acct-name">${escapeHtml(name)}</span>
|
||||
</button>
|
||||
<div class="acct-pop" hidden>
|
||||
<a href="alerts" class="acct-pop-link">My alerts</a>
|
||||
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const btn = el.querySelector(".acct-btn:not(.notif-btn)");
|
||||
const pop = el.querySelector(".acct-pop");
|
||||
btn.addEventListener("click", () => {
|
||||
const open = pop.hidden;
|
||||
pop.hidden = !open;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
// Notification bell dropdown.
|
||||
const nBtn = el.querySelector(".notif-btn");
|
||||
const nPop = el.querySelector(".notif-pop");
|
||||
nBtn.addEventListener("click", () => {
|
||||
const open = nPop.hidden;
|
||||
nPop.hidden = !open;
|
||||
nBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) loadNotifications(); // refresh on open
|
||||
});
|
||||
el.querySelector(".notif-readall").addEventListener("click", markAllRead);
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!el.contains(e.target)) {
|
||||
pop.hidden = true; btn.setAttribute("aria-expanded", "false");
|
||||
nPop.hidden = true; nBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
el.querySelector(".acct-signout").addEventListener("click", logout);
|
||||
paintBadge();
|
||||
paintNotifList();
|
||||
startNotifPolling();
|
||||
} else {
|
||||
stopNotifPolling();
|
||||
el.innerHTML = `<button type="button" class="acct-btn acct-signin" aria-label="Sign in">${USER_IC}<span>Sign in</span></button>`;
|
||||
el.querySelector(".acct-signin").addEventListener("click", () => openAuth("login"));
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
// --- boot --------------------------------------------------------------------
|
||||
(async function initAccount() {
|
||||
ensureAcctEl();
|
||||
renderHeader(); // paint the signed-out state immediately
|
||||
await refreshUser(); // then confirm session from the cookie
|
||||
renderHeader();
|
||||
emitAuth();
|
||||
})();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Weekly (map) page. Imports replace the old script-tag ordering contract.
|
||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||
import { fmtTemp, onUnitChange } from "./units.js";
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
|
||||
import { fmtTemp, onUnitChange } from "./units.js";
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
|||
allMonths, monthsToMask, maskToMonths,
|
||||
namePrimary, nameParts } from "./shared.js";
|
||||
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { initFilterSheet } from "./filtersheet.js";
|
||||
|
||||
const CMP = {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
|
||||
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
|
||||
import { fmtTemp, onUnitChange } from "./units.js";
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@
|
|||
<script type="module">
|
||||
import "./nav.js"; // header view-links follow the last location
|
||||
import "./units.js"; // the °F/°C toggle
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
// The scale rows come from the shared tier tables, so this guide can't
|
||||
// drift from what the app actually shows.
|
||||
import { SCALE_TEMP, SCALE_RAIN } from "./shared.js";
|
||||
|
|
|
|||
183
static/style.css
183
static/style.css
|
|
@ -65,7 +65,7 @@ header {
|
|||
/* The title block (logo's neighbour) grows and may shrink, so the unit toggle is
|
||||
pushed to the top-right on the logo's row and the long tagline wraps inside it
|
||||
instead of bumping the toggle onto its own line. */
|
||||
.brand > div:not(.unit-toggle) { flex: 1 1 0; min-width: 0; }
|
||||
.brand > div:not(.unit-toggle):not(.acct) { flex: 1 1 0; min-width: 0; }
|
||||
.logo {
|
||||
font-size: 30px;
|
||||
color: var(--accent);
|
||||
|
|
@ -271,6 +271,183 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
drop its auto-margin so the two sit together in the corner. */
|
||||
.unit-toggle + .view-nav { margin-left: 8px; }
|
||||
|
||||
/* --- account entry (header) + auth modal --- */
|
||||
.acct { align-self: center; flex-shrink: 0; position: relative; }
|
||||
.acct-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||
background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 11px; padding: 8px 12px; min-height: 40px; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.acct-btn:hover { border-color: var(--accent); }
|
||||
.acct-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
.acct-name { max-width: 16ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.acct-menu { position: relative; }
|
||||
.acct-pop {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 1100; min-width: 180px;
|
||||
display: flex; flex-direction: column; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.acct-pop[hidden] { display: none; }
|
||||
.acct-pop-link {
|
||||
display: block; text-align: left; padding: 10px 12px; border-radius: 8px; min-height: 40px;
|
||||
background: transparent; border: 0; color: var(--text); cursor: pointer;
|
||||
font: inherit; font-size: 14px; text-decoration: none;
|
||||
}
|
||||
.acct-pop-link:hover { background: var(--surface-2); }
|
||||
|
||||
/* Notification bell + dropdown. */
|
||||
.acct { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.notif { position: relative; }
|
||||
.notif-btn { position: relative; padding: 8px; min-width: 40px; justify-content: center; }
|
||||
.notif-badge {
|
||||
position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); color: #1a1206; font-size: 11px; font-weight: 800;
|
||||
border-radius: 999px; line-height: 1;
|
||||
}
|
||||
.notif-badge[hidden] { display: none; }
|
||||
.notif-pop {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 1100;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, .45); overflow: hidden;
|
||||
}
|
||||
.notif-pop[hidden] { display: none; }
|
||||
.notif-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 14px;
|
||||
}
|
||||
.notif-readall {
|
||||
background: none; border: 0; color: var(--accent); cursor: pointer;
|
||||
font: inherit; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.notif-list { list-style: none; margin: 0; padding: 4px; max-height: 60vh; overflow-y: auto; }
|
||||
.notif-empty { padding: 20px 14px; text-align: center; font-size: 13px; }
|
||||
.notif-item { padding: 10px 12px; border-radius: 9px; cursor: default; }
|
||||
.notif-item.is-unread { cursor: pointer; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
.notif-item.is-unread:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
.notif-item + .notif-item { margin-top: 2px; }
|
||||
.notif-item-title { font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.notif-item.is-unread .notif-item-title::before {
|
||||
content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0;
|
||||
}
|
||||
.notif-item-body { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||
.notif-item-time { font-size: 11px; margin-top: 3px; }
|
||||
.notif-manage {
|
||||
display: block; padding: 10px 14px; border-top: 1px solid var(--border);
|
||||
font-size: 13px; font-weight: 600; color: var(--accent); text-decoration: none;
|
||||
}
|
||||
.notif-manage:hover { background: var(--surface-2); }
|
||||
|
||||
/* Auth modal reuses .mp-overlay/.mp-modal chrome; this styles its form body. */
|
||||
.acct-modal { max-width: 400px; }
|
||||
.acct-form { display: flex; flex-direction: column; gap: 14px; padding: 16px; }
|
||||
.acct-form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--muted); }
|
||||
.acct-form input {
|
||||
padding: 11px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg); color: var(--text); font-size: 16px;
|
||||
}
|
||||
.acct-form input:focus { outline: none; border-color: var(--accent); }
|
||||
.acct-submit {
|
||||
margin-top: 2px; padding: 12px 16px; min-height: 44px; border: none; border-radius: 10px;
|
||||
background: var(--accent); color: #1a1206; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||
}
|
||||
.acct-submit:hover { filter: brightness(1.05); }
|
||||
.acct-submit:disabled { opacity: .6; cursor: progress; }
|
||||
.acct-error {
|
||||
margin: 0; padding: 9px 12px; border-radius: 8px; font-size: 13px;
|
||||
background: color-mix(in srgb, var(--rec-hot, #d64545) 20%, transparent);
|
||||
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
||||
}
|
||||
.acct-error[hidden] { display: none; }
|
||||
.acct-switch { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
|
||||
.acct-switch button {
|
||||
background: none; border: 0; color: var(--accent); cursor: pointer;
|
||||
font: inherit; font-weight: 600; padding: 0; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- alerts page (subscriptions) --- */
|
||||
.alerts-loading { color: var(--muted); }
|
||||
.alerts-gate {
|
||||
max-width: 480px; margin: 24px auto; padding: 24px; text-align: center;
|
||||
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
||||
}
|
||||
.alerts-gate h2 { margin: 0; }
|
||||
.alerts-gate .acct-submit { max-width: 280px; }
|
||||
.alerts-toolbar {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 16px;
|
||||
}
|
||||
.alerts-toolbar .find-btn { padding: 11px 18px; min-height: 44px; }
|
||||
.alerts-note { margin: 0; font-size: 13px; }
|
||||
|
||||
.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; }
|
||||
.sub-card {
|
||||
border: 1px solid var(--border); background: var(--surface); border-radius: 14px;
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.sub-card.is-paused { opacity: .62; }
|
||||
.sub-card.is-busy { pointer-events: none; opacity: .6; }
|
||||
.sub-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.sub-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
|
||||
.sub-name { font-weight: 700; font-size: 15px; }
|
||||
.sub-kind {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
||||
padding: 3px 8px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.sub-kind-observed { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); }
|
||||
.sub-kind-forecast { background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); }
|
||||
.sub-remove {
|
||||
flex-shrink: 0; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||||
cursor: pointer; border-radius: 9px; min-width: 40px; min-height: 40px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sub-remove svg { width: 17px; height: 17px; }
|
||||
.sub-remove:hover { border-color: var(--rec-hot, #d64545); color: var(--rec-hot, #d64545); }
|
||||
.sub-metrics { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sub-metric {
|
||||
font-size: 12px; padding: 3px 9px; border-radius: 999px;
|
||||
background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
|
||||
}
|
||||
.sub-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; justify-content: space-between; }
|
||||
.sub-thr { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); }
|
||||
.sub-active-lbl { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; cursor: pointer; }
|
||||
.sub-active-lbl input { width: 18px; height: 18px; accent-color: var(--accent); }
|
||||
|
||||
/* Segmented control shared by the threshold + kind pickers. */
|
||||
.seg { display: inline-flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 3px; }
|
||||
.seg button {
|
||||
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
||||
font: inherit; font-weight: 700; font-size: 13px; padding: 7px 11px; border-radius: 7px;
|
||||
min-height: 38px; min-width: 40px; line-height: 1;
|
||||
}
|
||||
.seg button.active { background: var(--accent); color: #1a1206; }
|
||||
.seg button:hover:not(.active) { color: var(--text); }
|
||||
|
||||
/* Alert editor modal (reuses .mp-overlay / .mp-modal chrome). */
|
||||
.sub-editor { max-width: 460px; }
|
||||
.sub-form { display: flex; flex-direction: column; gap: 16px; padding: 16px; overflow-y: auto; }
|
||||
.sub-form-loc { margin: 0; font-weight: 700; font-size: 15px; }
|
||||
.sub-field { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.sub-field legend { padding: 0 6px; font-size: 13px; color: var(--muted); }
|
||||
.sub-kind-help { margin: 0; font-size: 12px; }
|
||||
.metric-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
|
||||
.metric-check { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; min-height: 32px; }
|
||||
.metric-check input { width: 18px; height: 18px; accent-color: var(--accent); flex-shrink: 0; }
|
||||
.thr-range { width: 100%; accent-color: var(--accent); height: 28px; }
|
||||
.two-sided-lbl { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
||||
.two-sided-lbl input { width: 18px; height: 18px; accent-color: var(--accent); flex-shrink: 0; }
|
||||
.sub-form-error {
|
||||
margin: 0; padding: 9px 12px; border-radius: 8px; font-size: 13px;
|
||||
background: color-mix(in srgb, var(--rec-hot, #d64545) 20%, transparent);
|
||||
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
||||
}
|
||||
.sub-form-error[hidden] { display: none; }
|
||||
@media (max-width: 640px) { .metric-checks { grid-template-columns: 1fr; } }
|
||||
|
||||
/* --- shared header view switcher (Map · Calendar · Day) --- */
|
||||
/* The bar is split into equal, fully-clickable segments (one per page) with a
|
||||
clear divider between them, so the whole width is a set of obvious tabs. */
|
||||
|
|
@ -1109,6 +1286,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
.view-nav { margin-left: 0; width: 100%; }
|
||||
.unit-toggle + .view-nav { margin-left: 0; }
|
||||
.view-nav a { min-height: 44px; padding: 8px 12px; }
|
||||
/* Collapse the account entry to an icon-only button so it doesn't crush the
|
||||
title/tagline on narrow screens (it still opens the modal / menu). */
|
||||
.acct-btn span { display: none; }
|
||||
.acct-btn { padding: 8px; min-width: 40px; justify-content: center; }
|
||||
|
||||
main { padding: 14px 14px 40px; }
|
||||
.controls { gap: 12px; margin-bottom: 14px; }
|
||||
|
|
|
|||
50
static/subscriptions.html
Normal file
50
static/subscriptions.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Thermograph — weather alerts</title>
|
||||
<meta name="description" content="Subscribe to a city and get notified when its weather is unusually extreme — above the percentile you choose." />
|
||||
<meta name="theme-color" content="#f0803c" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Thermograph" />
|
||||
<meta property="og:title" content="Thermograph — weather alerts" />
|
||||
<meta property="og:description" content="Subscribe to a city and get notified when its weather is unusually extreme — above the percentile you choose." />
|
||||
<meta property="og:url" content="__ORIGIN__/alerts" />
|
||||
<meta property="og:image" content="__ORIGIN__/logo.png" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Thermograph logo" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="logo.png" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<span class="logo">▚</span>
|
||||
<div>
|
||||
<h1>Thermograph · Alerts</h1>
|
||||
<p class="tag">Get notified when a city's weather passes the percentile you set</p>
|
||||
</div>
|
||||
<nav class="view-nav" aria-label="Views">
|
||||
<a href="./" data-view="map">Weekly</a>
|
||||
<a href="calendar" data-view="calendar">Calendar</a>
|
||||
<a href="day" data-view="day">Day</a>
|
||||
<a href="compare" data-view="compare">Compare</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="alerts-body" class="alerts-body">
|
||||
<p class="alerts-loading">Loading…</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script type="module" src="subscriptions.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
294
static/subscriptions.js
Normal file
294
static/subscriptions.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// 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";
|
||||
|
||||
// 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>Sign in to manage alerts</h2>
|
||||
<p class="muted">Create an account to subscribe to cities and get notified when their
|
||||
weather is unusually extreme — above the percentile you choose.</p>
|
||||
<button type="button" class="acct-submit" id="gate-signin">Sign in / Create account</button>
|
||||
</div>`;
|
||||
body.querySelector("#gate-signin").onclick = () => openAuth("login");
|
||||
}
|
||||
|
||||
function render() {
|
||||
body.innerHTML = `
|
||||
<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;
|
||||
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)));
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
Loading…
Reference in a new issue