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" ) ; }
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
catch ( e ) { body . innerHTML = ` <p class="alerts-loading">Offline. Can't load alerts.</p> ` ; return ; }
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
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
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
< 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
2026-07-16 23:41:24 +00:00
against ~ 45 years of local climate history . < / p >
< ol class = "alerts-explain" >
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
< 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 >
2026-07-16 23:41:24 +00:00
< 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 >
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
< 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 >
2026-07-16 23:41:24 +00:00
< / 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 ;
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
el . innerHTML = ` <p class="push-note muted">Notifications are blocked for this site. Re-enable
2026-07-15 23:21:06 +00:00
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 ( ) ;