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.
2026-07-15 18:46:46 +00:00
|
|
|
// 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";
|
2026-07-15 23:21:06 +00:00
|
|
|
import * as pushClient from "./push-client.js";
|
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.
2026-07-15 18:46:46 +00:00
|
|
|
|
|
|
|
|
// 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 = `
|
2026-07-15 23:21:06 +00:00
|
|
|
<div class="push-bar" id="push-bar" hidden></div>
|
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.
2026-07-15 18:46:46 +00:00
|
|
|
<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;
|
2026-07-15 23:21:06 +00:00
|
|
|
renderPushBar();
|
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.
2026-07-15 18:46:46 +00:00
|
|
|
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)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 23:21:06 +00:00
|
|
|
// --- push notifications toggle (this device) ---------------------------------
|
|
|
|
|
// Delivery over OS push is per-device: a subscription lives in each browser, so
|
|
|
|
|
// this control reflects/toggles *this* device, separate from the account-wide
|
|
|
|
|
// alert list below.
|
|
|
|
|
async function renderPushBar() {
|
|
|
|
|
const el = document.getElementById("push-bar");
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
if (!pushClient.supported()) {
|
|
|
|
|
// Insecure context (plain-HTTP LAN) or an old browser — push simply isn't
|
|
|
|
|
// available. Keep it quiet: a single muted line, no dead controls.
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
el.innerHTML = `<p class="push-note muted">Open this page over HTTPS (or on localhost) to get
|
|
|
|
|
OS notifications on this device. In-app alerts via the bell still work anywhere.</p>`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (pushClient.permission() === "denied") {
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
el.innerHTML = `<p class="push-note muted">Notifications are blocked for this site — re-enable
|
|
|
|
|
them in your browser settings to get OS alerts here.</p>`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enabled = await pushClient.isEnabled();
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
<div class="push-row">
|
|
|
|
|
<span class="push-status">${enabled
|
|
|
|
|
? "🔔 OS notifications are on for this device."
|
|
|
|
|
: "🔕 OS notifications are off for this device."}</span>
|
|
|
|
|
<span class="push-actions">
|
|
|
|
|
<button type="button" class="find-btn push-toggle">${enabled ? "Turn off" : "Turn on"}</button>
|
|
|
|
|
${enabled ? `<button type="button" class="push-test-btn" title="Send a test notification">Send test</button>` : ""}
|
|
|
|
|
</span>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
el.querySelector(".push-toggle").onclick = async (e) => {
|
|
|
|
|
const btn = e.currentTarget;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
if (enabled) await pushClient.disable();
|
|
|
|
|
else await pushClient.enable();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(err.message || "Couldn't change notification settings.");
|
|
|
|
|
}
|
|
|
|
|
renderPushBar(); // repaint from the new state
|
|
|
|
|
};
|
|
|
|
|
const testBtn = el.querySelector(".push-test-btn");
|
|
|
|
|
if (testBtn) {
|
|
|
|
|
testBtn.onclick = async (e) => {
|
|
|
|
|
const btn = e.currentTarget;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const original = btn.textContent;
|
|
|
|
|
try {
|
|
|
|
|
await pushClient.sendTest();
|
|
|
|
|
btn.textContent = "Sent ✓";
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(err.message || "Couldn't send a test notification.");
|
|
|
|
|
}
|
|
|
|
|
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2500);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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.
2026-07-15 18:46:46 +00:00
|
|
|
// --- 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();
|