* 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.
213 lines
9 KiB
JavaScript
213 lines
9 KiB
JavaScript
// 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.3–19.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();
|
||
}
|
||
})();
|