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:
Emi Griffith 2026-07-15 11:46:46 -07:00 committed by GitHub
parent cd55b63e48
commit d742cfe11f
9 changed files with 873 additions and 1 deletions

342
static/account.js Normal file
View 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">&times;</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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// --- boot --------------------------------------------------------------------
(async function initAccount() {
ensureAcctEl();
renderHeader(); // paint the signed-out state immediately
await refreshUser(); // then confirm session from the cookie
renderHeader();
emitAuth();
})();

View file

@ -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,

View file

@ -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,

View file

@ -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 = {

View file

@ -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,

View file

@ -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";

View file

@ -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
View 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
View 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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">&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("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();