thermograph/static/day.js
Emi Griffith d742cfe11f 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

213 lines
9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Thermograph single-day detail subpage — for one day + location, shows the value
// at every percentile tier boundary in that day-of-year's ±7-day climate window,
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
import "./account.js"; // header sign-in entry + notification bell
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
todayISO, weatherType, placeLabel } from "./shared.js";
// Color a tier the same way the calendar cell would be colored for it.
const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || "");
let selected = null; // {lat, lon}
let curDate = null; // "YYYY-MM-DD" currently shown
let latest = null; // newest date available in the record
const placeholder = document.getElementById("day-placeholder");
const dayHead = document.getElementById("day-head");
const dayBody = document.getElementById("day-body");
const dateInput = document.getElementById("date-input");
// ---- location picker ----
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
initFindButton(findBtn, "Find a location", () => selected,
(lat, lon) => {
selected = { lat, lon };
// Show the raw coordinates immediately; render() replaces them with the
// resolved neighborhood + city name once the day fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
fetchDay();
});
// ---- date navigation ----
dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } });
document.getElementById("prev-day").addEventListener("click", () => stepDay(-1));
document.getElementById("next-day").addEventListener("click", () => stepDay(1));
function stepDay(delta) {
if (!curDate) return;
const d = new Date(curDate + "T00:00:00");
d.setDate(d.getDate() + delta);
const iso = d.toISOString().slice(0, 10);
if (iso > todayISO()) return; // don't step past today
curDate = iso;
fetchDay();
}
// ---- fetch + render ----
let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function fetchDay() {
if (!selected) return;
const dateQ = curDate ? `&date=${curDate}` : "";
history.replaceState(null, "", locHash(selected.lat, selected.lon, curDate));
placeholder.hidden = true;
dayHead.hidden = false;
const seq = ++daySeq;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// page immediately would just flash. Only a slow (network) load shows it.
const spin = setTimeout(() => {
if (seq !== daySeq) return;
dayHead.innerHTML = `<p class="spinner">Building the percentile breakdown…</p>`;
dayBody.innerHTML = "";
}, 150);
let data;
try {
// Stale-while-revalidate: a stale cached copy renders immediately; the update
// callback repaints if the background revalidation finds new data.
data = await getJSON(
`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, TTL.day,
false, (upd) => { if (seq === daySeq) finishDay(upd); });
} catch (err) {
clearTimeout(spin);
if (seq !== daySeq) return;
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
dayBody.innerHTML = "";
return;
}
clearTimeout(spin);
if (seq !== daySeq) return;
finishDay(data);
}
function finishDay(data) {
latest = data.latest;
curDate = data.detail.date;
dateInput.value = curDate;
// Allow navigating up to today (recent days beyond the archive come from the
// forecast bundle), not just the latest fully-archived day.
dateInput.max = todayISO();
document.getElementById("next-day").disabled = curDate >= todayISO();
saveLastLocation(selected.lat, selected.lon, curDate);
render(data);
prefetchViews(selected.lat, selected.lon, ["day"]); // warm weekly/calendar/forecast
}
let lastData = null; // most recent /day response, for repainting on a unit switch
onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const d = data.detail;
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
setFindLabel(findBtn, "Change location");
const dt = new Date(d.date + "T00:00:00");
const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const yrs = d.year_range ? `${d.year_range[0]}${d.year_range[1]}` : "—";
// Weather summary from the observed high + precip (omitted for days with no obs yet).
const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null;
const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null;
const wt = th != null || pr != null ? weatherType(th, pr) : null;
dayHead.innerHTML = `
<h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
<p class="meta">${place}
· ${d.n_samples.toLocaleString()} days around this date (${yrs})</p>`;
dayBody.innerHTML = [
ladderCard("Daily high", d.metrics.tmax, fmtTemp, "temp"),
ladderCard("Daily low", d.metrics.tmin, fmtTemp, "temp"),
ladderCard("Feels like", d.metrics.feels, fmtTemp, "temp"),
ladderCard("Humidity", d.metrics.humid, fmtHumid, "temp"),
ladderCard("Wind speed", d.metrics.wind, fmtWind, "temp"),
ladderCard("Wind gust", d.metrics.gust, fmtWind, "temp"),
ladderCard("Precipitation", d.metrics.precip, fmtPrecip, "precip"),
].join("");
}
// Collapse a space-separated unit shared by both bounds ("17.3 g/m³19.9 g/m³"
// → "17.319.9 g/m³") so ranges fit the narrow value column on phones. Attached
// units (76°, 0.25") pass through untouched.
function fmtRange(fmt, lo, hi) {
const a = fmt(lo), b = fmt(hi);
const i = a.lastIndexOf(" ");
if (i > 0 && b.endsWith(a.slice(i))) return `${a.slice(0, i)}${b}`;
return `${a}${b}`;
}
// The unit-less value for the ◀ marker — the unit is already on the range beside
// it and in the card's summary, and dropping it keeps the marker on one line.
const bareVal = (fmt, v) => {
const s = fmt(v), i = s.lastIndexOf(" ");
return i > 0 ? s.slice(0, i) : s;
};
// One metric card: an observed summary line + the tier ladder (highest tier on
// top). The tier the observed value falls into is highlighted with its value.
function ladderCard(title, m, fmt, kind) {
if (!m || !m.ladder) return "";
const obs = m.obs;
const activeClass = obs ? obs.class : null;
let summary;
if (obs) {
const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`;
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
} else {
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
}
// Left: a per-metric summary. Right: the bold, right-aligned Historical Range.
const noteLeft = kind === "temp"
? `median ${fmt(m.ladder.median)}`
: `${m.ladder.rain_days.toLocaleString()} rain days · dry ${m.ladder.dry_pct}%`;
const note = `<span class="ladder-note-left">${noteLeft}</span>` +
`<span class="ladder-range">Historical Range ${fmtRange(fmt, m.ladder.min, m.ladder.max)}</span>`;
const rows = m.ladder.tiers.map((t) => {
const active = t.c === activeClass ? " active" : "";
// Dry has no rain percentile — leave that column blank and show the threshold
// as the value. Other tiers show their percentile range and value range.
let val, pct = t.range;
if (t.c === "dry") { val = t.range; pct = ""; }
else if (t.hi == null) val = `> ${fmt(t.lo)}`; // top tier: strictly beyond p99
else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1
else val = fmtRange(fmt, t.lo, t.hi);
const marker = t.c === activeClass && obs
? `<span class="ladder-mark">◀ ${bareVal(fmt, obs.value)}</span>` : "";
return `<div class="ladder-row${active}">
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
<span class="ladder-pct">${pct}</span>
<span class="ladder-val">${val}</span>
<span class="ladder-mk">${marker}</span>
</div>`;
}).join("");
return `<section class="ladder-card">
<div class="ladder-head">
<h3>${title}</h3>
<div class="ladder-summary">${summary}</div>
</div>
<p class="ladder-note">${note}</p>
<div class="ladder">${rows}</div>
</section>`;
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = loadLastLocation();
if (loc) {
selected = { lat: loc.lat, lon: loc.lon };
curDate = loc.date || todayISO(); // default to today when no date is given
fetchDay();
}
})();