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" >
2026-07-16 23:41:24 +00:00
< h2 > What are weather alerts ? < / h 2 >
< p class = "alerts-lede" > Thermograph can watch any city and tell you when its weather turns
< b > unusually extreme for that place < / b > — a h e a t s p e l l , a c o l d s n a p , h e a v y r a i n — g r a d e d
against ~ 45 years of local climate history . < / p >
< ol class = "alerts-explain" >
< li > < b > Pick a city < / b > a n d t h e m e t r i c s t o w a t c h — h i g h , l o w , f e e l s - l i k e , r a i n , w i n d … < / l i >
< li > < b > Set a threshold . < / b > A l e r t w h e n a d a y p a s s e s , s a y , t h e < b > 9 7 t h p e r c e n t i l e < / b > f o r
that location . Higher = rarer , so fewer but more significant alerts . < / l i >
< li > < b > Get notified < / b > i n - a p p ( a n d v i a O S p u s h o n t h i s d e v i c e ) w h e n a r e c o r d e d d a y — o r
the upcoming forecast — crosses your threshold . At most one notification per week per alert . < / l i >
< / o l >
< p class = "alerts-eg muted" > For example : “ Tell me when Tokyo ' s high passes the 97 th percentile . ” < / p >
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
< button type = "button" class = "acct-submit" id = "gate-signin" > Sign in / C r e a t e a c c o u n t < / b u t t o n >
2026-07-16 23:41:24 +00:00
< p class = "alerts-gate-note muted" > You ' ll need an account to create and manage alerts . < / p >
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
< / d i v > ` ;
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 > < / d i v >
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 < / b u t t o n >
< p class = "alerts-note muted" > Each alert sends at most one notification per week . < / p >
< / d i v >
< ul class = "sub-list" id = "sub-list" > < / u l > ` ;
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." } < / s p a n >
< span class = "push-actions" >
< button type = "button" class = "find-btn push-toggle" > $ { enabled ? "Turn off" : "Turn on" } < / b u t t o n >
$ { enabled ? ` <button type="button" class="push-test-btn" title="Send a test notification">Send test</button> ` : "" }
< / s p a n >
< / d i v > ` ;
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 {
2026-07-17 00:12:14 +00:00
const r = await pushClient . sendTest ( ) ;
if ( ! r . devices ) {
btn . textContent = "No device" ;
alert ( "No device is registered for push on this account. Turn alerts off and on again on this device to re-subscribe." ) ;
} else if ( r . sent > 0 ) {
btn . textContent = "Sent ✓" ;
} else {
btn . textContent = "Failed" ;
alert ( "The push service rejected delivery to this device. Turn alerts off and on again to re-subscribe; if it keeps failing, the server's push (VAPID) keys may have changed." ) ;
}
2026-07-15 23:21:06 +00:00
} 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 ) } ` ) } < / s p a n >
< span class = "sub-kind sub-kind-${s.kind}" > $ { kindLabel } < / s p a n >
< / d i v >
< button type = "button" class = "sub-remove" aria - label = "Remove alert" > $ { TRASH _IC } < / b u t t o n >
< / d i v >
< div class = "sub-metrics" > $ { metrics } < / d i v >
< div class = "sub-controls" >
< div class = "sub-thr" >
< span class = "sub-thr-label" > Alert above the < / s p a n >
< span class = "seg thr-seg" role = "group" aria - label = "Percentile threshold" > < / s p a n >
< span class = "sub-thr-label" > percentile < / s p a n >
< / d i v >
< label class = "sub-active-lbl" >
< input type = "checkbox" class = "sub-active" $ { s . active ? "checked" : "" } / > Active
< / l a b e l >
< / d i v > ` ;
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 < / h 2 >
< button type = "button" class = "mp-close" aria - label = "Close" > & times ; < / b u t t o n >
< / d i v >
< form class = "sub-form" >
< p class = "sub-form-loc" > < / p >
< fieldset class = "sub-field" >
< legend > Trigger on < / l e g e n d >
< div class = "seg kind-seg" role = "group" aria - label = "Alert kind" >
< button type = "button" data - kind = "observed" class = "active" > Observed < / b u t t o n >
< button type = "button" data - kind = "forecast" > Forecast < / b u t t o n >
< / d i v >
< p class = "sub-kind-help muted" > < / p >
< / f i e l d s e t >
< fieldset class = "sub-field" >
< legend > Watch these metrics < / l e g e n d >
< div class = "metric-checks" > < / d i v >
< / f i e l d s e t >
< fieldset class = "sub-field" >
< legend > Alert above the < b class = "thr-val" > 97 < / b > t h p e r c e n t i l e < / l e g e n d >
< 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 > e x t r e m e s ( c o l d s n a p s , c a l m / d r y )
< / l a b e l >
< / f i e l d s e t >
< p class = "sub-form-error" role = "alert" hidden > < / p >
< button type = "submit" class = "acct-submit" > Create alert < / b u t t o n >
< / f o r m >
< / d i v > ` ;
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 ( ) ;