From 67284dfe2e6938d1f5a98bd32043bd5c0613b3ee Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:40 +0000 Subject: [PATCH 1/9] bookmarks: account-synced saved locations API --- backend/accounts/api_accounts.py | 181 ++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 3 deletions(-) diff --git a/backend/accounts/api_accounts.py b/backend/accounts/api_accounts.py index 96f3276..6e83350 100644 --- a/backend/accounts/api_accounts.py +++ b/backend/accounts/api_accounts.py @@ -1,4 +1,4 @@ -"""Authenticated API for subscriptions and notifications. +"""Authenticated API for subscriptions, notifications, and bookmarks. All routes require a logged-in user (the fastapi-users cookie session) and are scoped to that user — a row that isn't theirs reads as 404, never 403, so the API @@ -7,7 +7,7 @@ doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of import os import time -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.concurrency import run_in_threadpool from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,8 +17,15 @@ from data import climate from data import grid from notifications import push from accounts.db import get_async_session, get_read_session -from accounts.models import Notification, PushSubscription, Subscription +from accounts.models import Bookmark, Notification, PushSubscription, Subscription from accounts.schemas import ( + BOOKMARK_MAX_PER_USER, + BookmarkImportIn, + BookmarkImportOut, + BookmarkIn, + BookmarkList, + BookmarkOut, + BookmarkPatch, NotificationList, NotificationOut, PushSubscriptionIn, @@ -323,3 +330,171 @@ async def push_test( # `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client # can tell "request accepted" apart from "actually delivered". return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed} + + +# --- bookmarks ----------------------------------------------------------------- +async def _owned_bookmark(session: AsyncSession, bookmark_id: int, user) -> Bookmark: + bookmark = ( + await session.execute( + select(Bookmark).where( + Bookmark.id == bookmark_id, Bookmark.user_id == user.id + ) + ) + ).scalar_one_or_none() + if bookmark is None: + raise HTTPException(status_code=404, detail="Bookmark not found.") + return bookmark + + +@router.get("/bookmarks", response_model=BookmarkList) +async def list_bookmarks( + user=Depends(current_active_user), + session: AsyncSession = Depends(get_read_session), # pure read → read-only engine +): + rows = ( + await session.execute( + select(Bookmark) + .where(Bookmark.user_id == user.id) + .order_by(Bookmark.created_at) + ) + ).scalars().all() + return BookmarkList(bookmarks=[BookmarkOut.model_validate(r) for r in rows]) + + +@router.post("/bookmarks", response_model=BookmarkOut, status_code=201) +async def create_bookmark( + body: BookmarkIn, + response: Response, + user=Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + """Create a bookmark, or — if one already exists for this (user, cell) — + rename it in place and return 200. Idempotent upsert rather than a 409, so + re-bookmarking a place you already saved is never an error.""" + cell = grid.snap(body.lat, body.lon) + cell_id = cell["id"] + + existing = ( + await session.execute( + select(Bookmark).where( + Bookmark.user_id == user.id, Bookmark.cell_id == cell_id + ) + ) + ).scalar_one_or_none() + if existing is not None: + existing.label = body.label + await session.commit() + await session.refresh(existing) + audit.log_activity("bookmark.update", {"user_id": str(user.id), + "bookmark_id": existing.id}) + response.status_code = 200 + return existing + + count = ( + await session.execute( + select(func.count()).select_from(Bookmark).where(Bookmark.user_id == user.id) + ) + ).scalar_one() + if count >= BOOKMARK_MAX_PER_USER: + raise HTTPException( + status_code=409, + detail=f"You can have at most {BOOKMARK_MAX_PER_USER} bookmarks.", + ) + + bookmark = Bookmark( + user_id=user.id, + cell_id=cell_id, + label=body.label, + lat=body.lat, + lon=body.lon, + ) + session.add(bookmark) + await session.commit() + await session.refresh(bookmark) + audit.log_activity("bookmark.create", {"user_id": str(user.id), "bookmark_id": bookmark.id, + "cell_id": cell_id}) + return bookmark + + +@router.patch("/bookmarks/{bookmark_id}", response_model=BookmarkOut) +async def update_bookmark( + bookmark_id: int, + body: BookmarkPatch, + user=Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + bookmark = await _owned_bookmark(session, bookmark_id, user) + bookmark.label = body.label + await session.commit() + await session.refresh(bookmark) + audit.log_activity("bookmark.rename", {"user_id": str(user.id), "bookmark_id": bookmark.id}) + return bookmark + + +@router.delete("/bookmarks/{bookmark_id}", status_code=204) +async def delete_bookmark( + bookmark_id: int, + user=Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + bookmark = await _owned_bookmark(session, bookmark_id, user) + await session.delete(bookmark) + await session.commit() + audit.log_activity("bookmark.delete", {"user_id": str(user.id), "bookmark_id": bookmark_id}) + + +@router.post("/bookmarks/import", response_model=BookmarkImportOut) +async def import_bookmarks( + body: BookmarkImportIn, + user=Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + """Bulk-import a visitor's localStorage bookmarks at login. Existing rows keep + their label (never overwritten); duplicates within the payload itself are also + skipped. Stops importing once the user's total hits BOOKMARK_MAX_PER_USER — + any remaining items count as skipped rather than erroring.""" + existing_rows = ( + await session.execute(select(Bookmark).where(Bookmark.user_id == user.id)) + ).scalars().all() + existing_cells = {r.cell_id for r in existing_rows} + count = len(existing_rows) + + imported = 0 + skipped = 0 + seen_cells: set[str] = set() + for item in body.items: + cell_id = grid.snap(item.lat, item.lon)["id"] + if cell_id in existing_cells or cell_id in seen_cells: + skipped += 1 + continue + if count >= BOOKMARK_MAX_PER_USER: + skipped += 1 + continue + session.add(Bookmark( + user_id=user.id, + cell_id=cell_id, + label=item.label, + lat=item.lat, + lon=item.lon, + )) + seen_cells.add(cell_id) + count += 1 + imported += 1 + + if imported: + await session.commit() + audit.log_activity("bookmark.import", {"user_id": str(user.id), + "imported": imported, "skipped": skipped}) + + rows = ( + await session.execute( + select(Bookmark) + .where(Bookmark.user_id == user.id) + .order_by(Bookmark.created_at) + ) + ).scalars().all() + return BookmarkImportOut( + imported=imported, + skipped=skipped, + bookmarks=[BookmarkOut.model_validate(r) for r in rows], + ) -- 2.45.2 From 3fafd6760076de398f94da78f10d268573bb49b4 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:41 +0000 Subject: [PATCH 2/9] bookmarks: account-synced saved locations API --- backend/accounts/models.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/accounts/models.py b/backend/accounts/models.py index bc3dc54..56ed510 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -195,3 +195,31 @@ class PendingDigest(Base): # duplicating, which is what makes the form idempotent. UniqueConstraint("email", name="uq_pending_digest_email"), ) + + +class Bookmark(Base): + """A saved location, owned by a user — the "bookmarked locations" feature. + + Keyed like ``Subscription`` on ``grid.snap(lat, lon)["id"]``: one bookmark per + (user, cell), so re-bookmarking the same cell is an upsert of the label rather + than a duplicate row (see ``create_bookmark`` / ``import_bookmarks`` in + api_accounts.py, which enforce this at the application layer too). + """ + __tablename__ = "bookmark" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + # grid.snap(lat, lon)["id"] — the stable per-location key used across the app. + cell_id: Mapped[str] = mapped_column(String(40), nullable=False) + label: Mapped[str] = mapped_column(String(80), nullable=False) + lat: Mapped[float] = mapped_column(Float, nullable=False) + lon: Mapped[float] = mapped_column(Float, nullable=False) + created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) + + __table_args__ = ( + # One bookmark per location per user — re-bookmarking upserts the label. + UniqueConstraint("user_id", "cell_id", name="uq_bookmark_user_cell"), + Index("idx_bookmark_user", "user_id"), + ) -- 2.45.2 From 53fd5d92253d7b63f60c42b049dcfbca6bcaf843 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:43 +0000 Subject: [PATCH 3/9] bookmarks: account-synced saved locations API --- backend/accounts/schemas.py | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/backend/accounts/schemas.py b/backend/accounts/schemas.py index 0715e12..7385240 100644 --- a/backend/accounts/schemas.py +++ b/backend/accounts/schemas.py @@ -16,6 +16,11 @@ from data import grading ALLOWED_METRICS = tuple(grading.CLIMO_METRICS) DEFAULT_METRICS = ["tmax", "feels", "precip"] +# Bookmark limits, shared by the single-create and bulk-import paths. +BOOKMARK_LABEL_MAX_LEN = 80 +BOOKMARK_MAX_PER_USER = 200 +BOOKMARK_IMPORT_MAX_ITEMS = 200 + # --- users (fastapi-users) --------------------------------------------------- class UserRead(schemas.BaseUser[uuid.UUID]): @@ -135,3 +140,69 @@ class PushSubscriptionIn(BaseModel): class PushUnsubscribeIn(BaseModel): endpoint: str + + +# --- bookmarks ---------------------------------------------------------------- +def _check_label(v: str) -> str: + v = v.strip() + if not v: + raise ValueError("label is required") + if len(v) > BOOKMARK_LABEL_MAX_LEN: + raise ValueError(f"label must be at most {BOOKMARK_LABEL_MAX_LEN} characters") + return v + + +class BookmarkIn(BaseModel): + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + label: str + + @field_validator("label") + @classmethod + def _label(cls, v): + return _check_label(v) + + +class BookmarkPatch(BaseModel): + label: str + + @field_validator("label") + @classmethod + def _label(cls, v): + return _check_label(v) + + +class BookmarkOut(BaseModel): + id: int + cell_id: str + label: str + lat: float + lon: float + created_at: float + + model_config = {"from_attributes": True} + + +class BookmarkList(BaseModel): + bookmarks: list[BookmarkOut] + + +class BookmarkImportItem(BaseModel): + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + label: str + + @field_validator("label") + @classmethod + def _label(cls, v): + return _check_label(v) + + +class BookmarkImportIn(BaseModel): + items: list[BookmarkImportItem] = Field(max_length=BOOKMARK_IMPORT_MAX_ITEMS) + + +class BookmarkImportOut(BaseModel): + imported: int + skipped: int + bookmarks: list[BookmarkOut] -- 2.45.2 From 58e3a8d625c7d6464f84fb2585c1fca78bf34e7c Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:44 +0000 Subject: [PATCH 4/9] bookmarks: account-synced saved locations API --- backend/alembic/versions/0004_bookmarks.py | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 backend/alembic/versions/0004_bookmarks.py diff --git a/backend/alembic/versions/0004_bookmarks.py b/backend/alembic/versions/0004_bookmarks.py new file mode 100644 index 0000000..436add5 --- /dev/null +++ b/backend/alembic/versions/0004_bookmarks.py @@ -0,0 +1,45 @@ +"""bookmark: saved locations + +Adds the ``bookmark`` table backing the "bookmarked locations" feature — one row +per (user, grid cell), following the same shape as ``subscription``. + +Conditional on purpose, like 0003_user_discord_only: revision 0001 builds the +schema from the *current* models.py via ``Base.metadata.create_all``, so a +database created after ``Bookmark`` was added to the ORM (fresh envs, most tests) +already has the table, and an unconditional ``create_table`` would fail there +with a duplicate-table error. Only a database stamped before this revision is +missing it. + +Revision ID: 0004_bookmarks +Revises: 0003_user_discord_only +Create Date: 2026-07-26 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0004_bookmarks" +down_revision = "0003_user_discord_only" +branch_labels = None +depends_on = None + + +def _has_table() -> bool: + return sa.inspect(op.get_bind()).has_table("bookmark") + + +def upgrade() -> None: + if _has_table(): + return + # Built from the live ORM table (accounts.models.Bookmark) rather than + # hand-duplicated column definitions, so this can never drift from models.py. + from accounts.models import Bookmark + + Bookmark.__table__.create(bind=op.get_bind()) + + +def downgrade() -> None: + if not _has_table(): + return + from accounts.models import Bookmark + + Bookmark.__table__.drop(bind=op.get_bind()) -- 2.45.2 From 41858f3e4b1d73094b554287a417858f74587e21 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:45 +0000 Subject: [PATCH 5/9] bookmarks: account-synced saved locations API --- backend/tests/accounts/test_bookmarks.py | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 backend/tests/accounts/test_bookmarks.py diff --git a/backend/tests/accounts/test_bookmarks.py b/backend/tests/accounts/test_bookmarks.py new file mode 100644 index 0000000..c155abd --- /dev/null +++ b/backend/tests/accounts/test_bookmarks.py @@ -0,0 +1,160 @@ +"""Route-level tests for the bookmarks feature: CRUD, upsert-on-duplicate, +ownership, auth, bulk import (dedupe/counts), and the per-user cap. Uses a +throwaway accounts DB (conftest points THERMOGRAPH_ACCOUNTS_DB at a temp file), +same as test_api_accounts.py.""" +import pytest +from fastapi.testclient import TestClient + +from web import app as appmod +from accounts import db + +V2 = "/thermograph/api/v2" +PW = "supersecret123" + + +@pytest.fixture(scope="module", autouse=True) +def _tables(): + # TestClient(app) (no context manager) skips the lifespan, so create the + # account tables directly against the temp DB. + db.Base.metadata.create_all(db.sync_engine) + + +def _client(): + return TestClient(appmod.app) + + +def _login(client, email): + r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW}) + assert r.status_code in (201, 400) # 400 only if the email already exists + r = client.post(f"{V2}/auth/login", data={"username": email, "password": PW}) + assert r.status_code == 204 # sets the session cookie on the client + + +def test_bookmarks_require_auth(): + c = _client() + assert c.get(f"{V2}/bookmarks").status_code == 401 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x"}).status_code == 401 + assert c.post(f"{V2}/bookmarks/import", json={"items": []}).status_code == 401 + + +def test_bookmark_crud(): + c = _client() + _login(c, "bm-crud@example.com") + + r = c.post(f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Seattle"}) + assert r.status_code == 201 + bm = r.json() + assert bm["cell_id"] and bm["label"] == "Seattle" + assert bm["lat"] == 47.6 and bm["lon"] == -122.3 + + listed = c.get(f"{V2}/bookmarks").json()["bookmarks"] + assert len(listed) == 1 and listed[0]["id"] == bm["id"] + + patched = c.patch(f"{V2}/bookmarks/{bm['id']}", json={"label": "Home"}) + assert patched.status_code == 200 + assert patched.json()["label"] == "Home" + + assert c.delete(f"{V2}/bookmarks/{bm['id']}").status_code == 204 + assert c.get(f"{V2}/bookmarks").json()["bookmarks"] == [] + + +def test_bookmark_upsert_on_duplicate(): + c = _client() + _login(c, "bm-upsert@example.com") + body = {"lat": 33.4, "lon": -112.0, "label": "Phoenix"} + + first = c.post(f"{V2}/bookmarks", json=body) + assert first.status_code == 201 + bm_id = first.json()["id"] + + # Re-bookmarking the same location (same cell) upserts the label — 200, not 409. + second = c.post(f"{V2}/bookmarks", json={**body, "label": "Phoenix Home"}) + assert second.status_code == 200 + assert second.json()["id"] == bm_id + assert second.json()["label"] == "Phoenix Home" + + assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 1 + + +def test_bookmark_validation(): + c = _client() + _login(c, "bm-valid@example.com") + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": " "}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x" * 81}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 91, "lon": 1, "label": "x"}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 181, "label": "x"}).status_code == 422 + # a label that's only over-length due to surrounding whitespace is fine once trimmed + ok = c.post(f"{V2}/bookmarks", json={"lat": 2, "lon": 2, "label": " Trimmed "}) + assert ok.status_code == 201 and ok.json()["label"] == "Trimmed" + + +def test_ownership_is_404(): + a, b = _client(), _client() + _login(a, "bm-owner-a@example.com") + _login(b, "bm-owner-b@example.com") + bm_id = a.post(f"{V2}/bookmarks", json={"lat": 40.7, "lon": -74.0, "label": "NYC"}).json()["id"] + + # B cannot see, patch, or delete A's bookmark — 404, not 403 (no existence leak) + assert b.patch(f"{V2}/bookmarks/{bm_id}", json={"label": "Nope"}).status_code == 404 + assert b.delete(f"{V2}/bookmarks/{bm_id}").status_code == 404 + assert b.get(f"{V2}/bookmarks").json()["bookmarks"] == [] + + +def test_bookmark_import_dedupe_and_counts(): + c = _client() + _login(c, "bm-import@example.com") + # Pre-existing bookmark at the Seattle cell, with a label import must NOT overwrite. + existing = c.post( + f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Original"} + ).json() + assert existing["label"] == "Original" + + r = c.post(f"{V2}/bookmarks/import", json={"items": [ + {"lat": 47.6, "lon": -122.3, "label": "Should Not Overwrite"}, # dup of existing cell + {"lat": 34.0, "lon": -118.2, "label": "LA"}, # new + {"lat": 34.0, "lon": -118.2, "label": "LA Dup"}, # dup within payload + {"lat": 51.5, "lon": -0.1, "label": "London"}, # new + ]}) + assert r.status_code == 200 + body = r.json() + assert body["imported"] == 2 + assert body["skipped"] == 2 + assert len(body["bookmarks"]) == 3 + + by_label = {b["label"] for b in body["bookmarks"]} + assert by_label == {"Original", "LA", "London"} # existing label untouched + + +def test_bookmark_cap(): + c = _client() + _login(c, "bm-cap@example.com") + for i in range(200): + r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 0.0, "label": f"spot-{i}"}) + assert r.status_code == 201, r.text + + over = c.post(f"{V2}/bookmarks", json={"lat": 89.0, "lon": 179.0, "label": "one too many"}) + assert over.status_code == 409 + + assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 200 + + +def test_bookmark_import_stops_at_cap(): + c = _client() + _login(c, "bm-import-cap@example.com") + for i in range(198): + r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 10.0, "label": f"spot-{i}"}) + assert r.status_code == 201, r.text + + r = c.post(f"{V2}/bookmarks/import", json={"items": [ + {"lat": 60.0, "lon": 60.0, "label": "a"}, + {"lat": 61.0, "lon": 61.0, "label": "b"}, + {"lat": 62.0, "lon": 62.0, "label": "c"}, + {"lat": 63.0, "lon": 63.0, "label": "d"}, + {"lat": 64.0, "lon": 64.0, "label": "e"}, + ]}) + assert r.status_code == 200 + body = r.json() + # Only 2 slots remained before the 200 cap; the rest count as skipped. + assert body["imported"] == 2 + assert body["skipped"] == 3 + assert len(body["bookmarks"]) == 200 -- 2.45.2 From 4e1c4a419741be057e5948268c2983b063772f8b Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:36:31 +0000 Subject: [PATCH 6/9] bookmarks: map + account UI for saved locations --- frontend/static/bookmarks.js | 182 +++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 frontend/static/bookmarks.js diff --git a/frontend/static/bookmarks.js b/frontend/static/bookmarks.js new file mode 100644 index 0000000..361adfa --- /dev/null +++ b/frontend/static/bookmarks.js @@ -0,0 +1,182 @@ +// Bookmarked locations: the single source of truth for "places you've saved". +// Logged out, this is pure localStorage. Logged in, the server is authoritative +// and this module mirrors it into localStorage so the list still renders +// instantly (and offline) on the next load. Every consumer (the map page's +// star toggle + saved-locations dropdown, the alerts page's Saved locations +// section) reads through list()/subscribe() and never touches storage or the +// API directly. +import { apiFetch, onAuthChange, uv } from "./account.js"; + +const LS_KEY = "thermograph:bookmarks"; +const DISMISS_KEY = "thermograph:bookmarks:import-dismissed"; +const MAX_LOCAL = 200; + +// A stable local key for a spot: rounded to ~4dp (~11m), so re-picking +// "the same place" a pixel off the first pin still matches. +export function cellId(lat, lon) { + return `${lat.toFixed(4)},${lon.toFixed(4)}`; +} + +function readLocal() { + try { + const arr = JSON.parse(localStorage.getItem(LS_KEY)); + return Array.isArray(arr) + ? arr.filter((b) => b && typeof b.lat === "number" && typeof b.lon === "number") + : []; + } catch (e) { return []; } +} +function writeLocal(rows) { + try { localStorage.setItem(LS_KEY, JSON.stringify(rows.slice(0, MAX_LOCAL))); } catch (e) { /* private mode, quota, etc. — degrade quietly */ } +} + +let cache = readLocal(); // what every consumer renders +let pending = []; // local-only bookmarks a signed-in user hasn't imported yet +let signedIn = false; +const subs = []; + +function notify() { subs.forEach((cb) => { try { cb(cache.slice()); } catch (e) {} }); } + +/** Subscribe to bookmark-list changes. Returns an unsubscribe function. */ +export function subscribe(cb) { + subs.push(cb); + return () => { const i = subs.indexOf(cb); if (i > -1) subs.splice(i, 1); }; +} + +export function list() { return cache.slice(); } +export function find(lat, lon) { + const id = cellId(lat, lon); + return cache.find((b) => cellId(b.lat, b.lon) === id) || null; +} +export function isBookmarked(lat, lon) { return !!find(lat, lon); } +export function pendingImportCount() { return pending.length; } + +export function importDismissed() { + try { return localStorage.getItem(DISMISS_KEY) === "1"; } catch (e) { return false; } +} +export function dismissImportPrompt() { + try { localStorage.setItem(DISMISS_KEY, "1"); } catch (e) {} + pending = []; + notify(); +} + +function fromServerRow(b) { + return { + id: b.id != null ? String(b.id) : (b.cell_id || cellId(b.lat, b.lon)), + cell_id: b.cell_id || null, + lat: b.lat, lon: b.lon, + label: b.label || null, + created_at: b.created_at || Date.now() / 1000, + }; +} + +// Returns the server's list, or null on 401 (signed out) / offline / error — +// callers treat null as "can't say, fall back to local" rather than "empty". +async function fetchServerList() { + try { + const res = await apiFetch(uv("bookmarks")); + if (res.status === 401) { signedIn = false; return null; } + if (!res.ok) return null; + signedIn = true; + const data = await res.json(); + return Array.isArray(data.bookmarks) ? data.bookmarks : []; + } catch (e) { return null; } +} + +// Reconciles cache with the server, diffing against whatever was in +// localStorage right before this call so any local-only spots can be offered +// up for import instead of silently vanishing behind the server's list. +async function syncFromServer(preSyncLocal) { + const rows = await fetchServerList(); + if (rows == null) return; // signed out or offline: leave cache as-is + const serverList = rows.map(fromServerRow); + const serverIds = new Set(serverList.map((b) => cellId(b.lat, b.lon))); + const localOnly = preSyncLocal.filter((b) => !serverIds.has(cellId(b.lat, b.lon))); + cache = serverList; + writeLocal(cache); + pending = importDismissed() ? [] : localOnly; + notify(); +} + +/** Save (or re-save with a new label) the given spot. Upserts either way. */ +export async function add(lat, lon, label) { + const id = cellId(lat, lon); + if (signedIn) { + try { + const res = await apiFetch(uv("bookmarks"), { method: "POST", json: { lat, lon, label: label || undefined } }); + if (res.ok) { + const rows = await fetchServerList(); + if (rows != null) { cache = rows.map(fromServerRow); writeLocal(cache); notify(); return find(lat, lon); } + } + } catch (e) { /* fall through so the star still visibly sticks, even offline */ } + } + let b = cache.find((x) => cellId(x.lat, x.lon) === id); + if (b) { if (label) b.label = label; } + else { + b = { id, lat, lon, label: label || null, created_at: Date.now() / 1000 }; + cache = [b, ...cache].slice(0, MAX_LOCAL); + } + writeLocal(cache); + notify(); + return b; +} + +export async function remove(id) { + if (signedIn) { + try { await apiFetch(uv(`bookmarks/${encodeURIComponent(id)}`), { method: "DELETE" }); } catch (e) {} + } + cache = cache.filter((b) => b.id !== id); + writeLocal(cache); + notify(); +} + +export async function rename(id, label) { + if (signedIn) { + try { await apiFetch(uv(`bookmarks/${encodeURIComponent(id)}`), { method: "PATCH", json: { label } }); } + catch (e) { /* still apply locally so the edit shows up */ } + } + const b = cache.find((x) => x.id === id); + if (b) { b.label = label; writeLocal(cache); notify(); } +} + +async function runImport(items) { + if (!items.length) return { imported: 0, skipped: 0 }; + const res = await apiFetch(uv("bookmarks/import"), { method: "POST", json: { items } }); + if (!res.ok) throw new Error(`Import failed (${res.status}).`); + const data = await res.json(); + if (Array.isArray(data.bookmarks)) { cache = data.bookmarks.map(fromServerRow); writeLocal(cache); } + pending = []; + notify(); + return data; +} + +/** Import the local-only bookmarks the login prompt flagged. */ +export function importPending() { + return runImport(pending.map((b) => ({ lat: b.lat, lon: b.lon, label: b.label }))); +} + +/** Import everything currently in localStorage — the explicit "Import from + * this device" button on the alerts page, reachable even after the login + * prompt was dismissed. */ +export function importAllLocal() { + return runImport(readLocal().map((b) => ({ lat: b.lat, lon: b.lon, label: b.label }))); +} + +// --- boot + auth transitions ------------------------------------------------- +(async function boot() { + const local = readLocal(); + cache = local; + notify(); // render local state immediately, don't wait on the network + await syncFromServer(local); +})(); + +onAuthChange(async (user) => { + if (user) { + await syncFromServer(readLocal()); + } else { + // Logged out: fall back to local bookmarks without wiping them. + signedIn = false; + pending = []; + cache = readLocal(); + notify(); + } +}); -- 2.45.2 From d6ead569714692b0419398a772b4aa013d52ba2f Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:36:34 +0000 Subject: [PATCH 7/9] bookmarks: map + account UI for saved locations --- frontend/static/bookmarks-ui.js | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 frontend/static/bookmarks-ui.js diff --git a/frontend/static/bookmarks-ui.js b/frontend/static/bookmarks-ui.js new file mode 100644 index 0000000..82877ed --- /dev/null +++ b/frontend/static/bookmarks-ui.js @@ -0,0 +1,252 @@ +// Map-page bookmark UI: a star toggle in the results "share" row, and a +// compact "Saved" pill + dropdown near the Find button for jumping between +// saved spots without retyping. Pure DOM glue over bookmarks.js, which is the +// only place bookmark data actually lives — this module holds no state of its +// own beyond references to the elements it built. +// +// Loaded as an extra module alongside app.js rather than merged into it: app.js +// owns #results' markup (it rewrites results.innerHTML wholesale on every grade +// via its own render()), so a MutationObserver on #results is what lets this +// module re-attach the star after every re-render without needing a hook +// app.js doesn't expose. selectLocation() is module-private to app.js, so +// jumping to a saved spot goes through the same hash the app already treats as +// authoritative on load (see nav.js: "a hash always wins") via a reload — +// the same path opening a shared link takes, so the normal grade flow runs. +import * as bookmarks from "./bookmarks.js"; + +const STAR_OUTLINE = ``; +const STAR_FILLED = ``; +const CHEV_IC = ``; +const CLOSE_X = ``; +const PENCIL_IC = ``; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +function injectStyle() { + if (document.getElementById("tg-bookmarks-style")) return; + const style = document.createElement("style"); + style.id = "tg-bookmarks-style"; + style.textContent = ` +.bkm-star[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); } +.bkm-pill-wrap { position: relative; display: inline-flex; align-self: center; } +.bkm-pill { + border-radius: 10px; border: 1px solid var(--border); cursor: pointer; + background: var(--surface-2); color: var(--text); font-weight: 600; font-size: 13px; + padding: 9px 12px; min-height: 40px; display: inline-flex; align-items: center; gap: 6px; +} +.bkm-pill:hover { border-color: var(--accent); } +.bkm-pill[hidden] { display: none; } +.bkm-drop { + position: absolute; left: 0; top: calc(100% + 6px); z-index: 1100; min-width: 260px; + max-width: min(340px, 90vw); max-height: 60vh; overflow-y: auto; + display: flex; flex-direction: column; padding: 6px; margin: 0; list-style: none; + background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + box-shadow: 0 16px 40px rgba(0, 0, 0, .45); +} +.bkm-drop[hidden] { display: none; } +.bkm-item { display: flex; align-items: center; gap: 2px; border-radius: 8px; } +.bkm-item:hover { background: var(--surface-2); } +.bkm-item-go { + flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: 0; cursor: pointer; + color: var(--text); font: inherit; font-size: 13.5px; padding: 9px 8px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.bkm-item-btn { + flex-shrink: 0; background: none; border: 0; color: var(--muted); cursor: pointer; + padding: 7px; border-radius: 6px; display: inline-flex; +} +.bkm-item-btn:hover { color: var(--text); background: var(--border); } +.bkm-empty { padding: 10px 8px; font-size: 12.5px; color: var(--muted); } +.bkm-import-banner { + display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; + background: var(--surface-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); + border-radius: 12px; padding: 10px 14px; margin: 0 0 16px; font-size: 13.5px; +} +.bkm-import-banner .bkm-import-actions { display: flex; gap: 8px; flex-shrink: 0; } +.bkm-import-banner button { + border-radius: 8px; border: 1px solid var(--border); cursor: pointer; + padding: 7px 12px; font-size: 12.5px; font-weight: 600; background: var(--surface); color: var(--text); +} +.bkm-import-banner button.bkm-import-yes { background: var(--accent); color: #1a1206; border-color: var(--accent); } +@media (max-width: 480px) { .bkm-drop { left: auto; right: 0; } } +`; + document.head.appendChild(style); +} + +function parseHash() { + const p = new URLSearchParams(location.hash.slice(1)); + const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon")); + if (isNaN(lat) || isNaN(lon)) return null; + return { lat, lon }; +} + +function jumpTo(lat, lon) { + const dateInput = document.getElementById("date-input"); + const date = dateInput && dateInput.value ? dateInput.value : ""; + let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`; + if (date) h += `&date=${date}`; + location.hash = h; + location.reload(); +} + +function currentLabel() { + const h2 = document.querySelector("#results .loc-title h2"); + return (h2 && h2.textContent.trim()) || null; +} + +// ---- star toggle in the .share row ---- +function buildStar() { + const btn = document.createElement("button"); + btn.type = "button"; + btn.id = "btn-bookmark"; + btn.className = "bkm-star"; + btn.addEventListener("click", async () => { + const loc = parseHash(); + if (!loc) return; + const existing = bookmarks.find(loc.lat, loc.lon); + btn.disabled = true; + try { + if (existing) await bookmarks.remove(existing.id); + else await bookmarks.add(loc.lat, loc.lon, currentLabel()); + } finally { + btn.disabled = false; + } + }); + return btn; +} + +function paintStar() { + const share = document.querySelector("#results .share"); + if (!share) return; + let btn = share.querySelector("#btn-bookmark"); + if (!btn) { + btn = buildStar(); + share.insertBefore(btn, share.firstChild); + } + const loc = parseHash(); + const saved = loc ? !!bookmarks.find(loc.lat, loc.lon) : false; + btn.setAttribute("aria-pressed", saved ? "true" : "false"); + btn.title = saved ? "Remove this saved location" : "Save this location"; + btn.setAttribute("aria-label", btn.title); + btn.innerHTML = (saved ? STAR_FILLED : STAR_OUTLINE) + `${saved ? "Saved" : "Save"}`; +} + +const resultsEl = document.getElementById("results"); +if (resultsEl) { + new MutationObserver(() => paintStar()).observe(resultsEl, { childList: true }); +} + +// ---- "Saved" dropdown near the Find button ---- +let dropEl = null, pillEl = null; + +function buildSavedControl() { + const findBar = document.querySelector(".find-bar"); + if (!findBar) return; + const wrap = document.createElement("div"); + wrap.className = "bkm-pill-wrap"; + wrap.innerHTML = ` + + `; + findBar.appendChild(wrap); + pillEl = wrap.querySelector("#bkm-pill"); + dropEl = wrap.querySelector("#bkm-drop"); + + pillEl.addEventListener("click", () => { + const open = dropEl.hidden; + dropEl.hidden = !open; + pillEl.setAttribute("aria-expanded", open ? "true" : "false"); + }); + document.addEventListener("click", (e) => { + if (!wrap.contains(e.target) && !dropEl.hidden) { + dropEl.hidden = true; + pillEl.setAttribute("aria-expanded", "false"); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !dropEl.hidden) { + dropEl.hidden = true; + pillEl.setAttribute("aria-expanded", "false"); + pillEl.focus(); + } + }); +} + +function renderDropdown(list) { + if (!pillEl || !dropEl) return; + pillEl.hidden = list.length === 0; // new visitors see nothing extra at all + if (!list.length) { dropEl.hidden = true; return; } + dropEl.innerHTML = list.map((b) => { + const label = b.label || `${b.lat.toFixed(3)}, ${b.lon.toFixed(3)}`; + return ` +
  • + + + +
  • `; + }).join(""); + dropEl.querySelectorAll(".bkm-item-go").forEach((el) => { + el.addEventListener("click", () => { + const b = list.find((x) => x.id === el.dataset.id); + if (b) { dropEl.hidden = true; jumpTo(b.lat, b.lon); } + }); + }); + dropEl.querySelectorAll(".bkm-item-rename").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const b = list.find((x) => x.id === el.dataset.id); + if (!b) return; + const next = window.prompt("Rename this saved location:", b.label || ""); + if (next && next.trim()) bookmarks.rename(b.id, next.trim()); + }); + }); + dropEl.querySelectorAll(".bkm-item-remove").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const b = list.find((x) => x.id === el.dataset.id); + if (b) bookmarks.remove(b.id); + }); + }); +} + +// ---- import prompt banner (non-blocking, shown once until dismissed) ---- +let bannerEl = null; +function renderBanner() { + const n = bookmarks.pendingImportCount(); + if (!n) { if (bannerEl) bannerEl.hidden = true; return; } + const controls = document.querySelector(".controls"); + if (!bannerEl && controls) { + bannerEl = document.createElement("div"); + bannerEl.className = "bkm-import-banner"; + controls.insertAdjacentElement("afterend", bannerEl); + } + if (!bannerEl) return; + bannerEl.hidden = false; + bannerEl.innerHTML = ` + Import ${n} saved location${n === 1 ? "" : "s"} into your account? + + + + `; + bannerEl.querySelector(".bkm-import-yes").onclick = async () => { + const btn = bannerEl.querySelector(".bkm-import-yes"); + btn.disabled = true; + try { await bookmarks.importPending(); } catch (e) { btn.disabled = false; } + }; + bannerEl.querySelector(".bkm-import-no").onclick = () => bookmarks.dismissImportPrompt(); +} + +// ---- boot ---- +injectStyle(); +buildSavedControl(); +bookmarks.subscribe((list) => { + renderDropdown(list); + paintStar(); + renderBanner(); +}); +renderDropdown(bookmarks.list()); +renderBanner(); -- 2.45.2 From 17cc14b48bd37807d84873cfde5b7cc4fbda8bd4 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:36:39 +0000 Subject: [PATCH 8/9] bookmarks: map + account UI for saved locations --- frontend/static/subscriptions.js | 99 +++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/frontend/static/subscriptions.js b/frontend/static/subscriptions.js index 22dc124..24701cc 100644 --- a/frontend/static/subscriptions.js +++ b/frontend/static/subscriptions.js @@ -2,10 +2,16 @@ // 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. +// +// Also the closest thing to an account page, so it doubles as the Saved +// locations surface: bookmarks.js is the single source of truth for those (see +// its header comment), this just lists/renders/edits them the same way it does +// alert subscriptions, reusing .sub-list/.sub-card. import "./nav.js"; import { apiFetch, openAuth, onAuthChange, uv } from "./account.js"; import { open as openPicker } from "./mappicker.js"; import * as pushClient from "./push-client.js"; +import * as bookmarks from "./bookmarks.js"; // 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.) @@ -24,6 +30,7 @@ METRIC_LABEL.fmin = "Feels-like low"; const DEFAULT_METRICS = ["tmax", "feels", "precip"]; const TRASH_IC = ``; +const PENCIL_IC = ``; const body = document.getElementById("alerts-body"); let subs = []; @@ -33,6 +40,25 @@ function esc(s) { ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } +// Small stylesheet for the bits of the Saved locations section that don't +// already have a home in .sub-list/.sub-card — injected once, not appended to +// the shared style.css, so this stays self-contained. +(function injectBkmStyle() { + if (document.getElementById("tg-bkm-alerts-style")) return; + const s = document.createElement("style"); + s.id = "tg-bkm-alerts-style"; + s.textContent = ` +.bkm-heading { font-size: 15px; margin: 26px 0 4px; } +.bkm-import-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 12px; } +.bkm-import-status { margin: -4px 0 10px; } +.bkm-card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } +.bkm-rename { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 7px; border-radius: 6px; display: inline-flex; } +.bkm-rename:hover { color: var(--text); background: var(--border); } +.bkm-card-loc { color: var(--muted); font-size: 12.5px; } +`; + document.head.appendChild(s); +})(); + async function readErr(res) { try { const d = await res.json(); @@ -80,17 +106,84 @@ function render() {

    Each alert sends at most one notification per week.

    -
      `; +
        +
        `; body.querySelector("#add-alert").onclick = startAdd; renderPushBar(); const list = body.querySelector("#sub-list"); if (!subs.length) { list.innerHTML = `
      • No alerts yet. Add a city to get started.
      • `; - return; + } else { + subs.forEach((s) => list.appendChild(subCard(s))); } - subs.forEach((s) => list.appendChild(subCard(s))); + renderBookmarksSection(); } +// --- Saved locations (bookmarks) --------------------------------------------- +function bkmCard(b) { + const li = document.createElement("li"); + li.className = "sub-card"; + const label = b.label || `${b.lat.toFixed(3)}, ${b.lon.toFixed(3)}`; + li.innerHTML = ` +
        +
        + ${esc(label)} + ${b.label ? `${esc(b.lat.toFixed(3))}, ${esc(b.lon.toFixed(3))}` : ""} +
        + + + + +
        `; + li.querySelector(".bkm-rename").onclick = () => { + const next = window.prompt("Rename this saved location:", b.label || ""); + if (next && next.trim()) bookmarks.rename(b.id, next.trim()); + }; + li.querySelector(".sub-remove").onclick = () => bookmarks.remove(b.id); + return li; +} + +function renderBookmarksSection() { + const el = document.getElementById("bkm-alerts-section"); + if (!el) return; + const list = bookmarks.list(); + el.innerHTML = ` +

        Saved locations

        +
        +

        Places you've starred on the map, kept in sync with your account.

        + +
        + +
          `; + const ul = el.querySelector("#bkm-list"); + if (!list.length) { + ul.innerHTML = `
        • No saved locations yet. Star a spot on the map to see it here.
        • `; + } else { + list.forEach((b) => ul.appendChild(bkmCard(b))); + } + el.querySelector("#bkm-import-btn").onclick = async () => { + const btn = el.querySelector("#bkm-import-btn"); + const status = el.querySelector("#bkm-import-status"); + btn.disabled = true; + try { + const res = await bookmarks.importAllLocal(); + status.hidden = false; + status.textContent = res.imported + ? `Imported ${res.imported} location${res.imported === 1 ? "" : "s"}${res.skipped ? ` (${res.skipped} already saved)` : ""}.` + : "Nothing new to import from this device."; + } catch (e) { + status.hidden = false; + status.textContent = e.message || "Couldn't import right now."; + } finally { + btn.disabled = false; + } + }; +} + +// Bookmarks can change from outside this render cycle (rename/remove above, +// or an import banner) — keep the section live rather than only painting once. +bookmarks.subscribe(() => renderBookmarksSection()); + // --- 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 -- 2.45.2 From 1c6ecc2a6f7f105a1a2e176bd7b619f792023e49 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:36:43 +0000 Subject: [PATCH 9/9] bookmarks: map + account UI for saved locations --- frontend/templates/home.html.j2 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/templates/home.html.j2 b/frontend/templates/home.html.j2 index 0551ca5..387c76d 100644 --- a/frontend/templates/home.html.j2 +++ b/frontend/templates/home.html.j2 @@ -198,6 +198,10 @@ {% block body_scripts %} + {# Bookmarked locations: a star toggle in the results share row + a "Saved" + dropdown by the Find button. Loaded as its own module alongside app.js + (not merged into it) — see bookmarks-ui.js's header comment for why. #} + {# The records strip renders real temperatures server-side as .temp spans, so it needs the same converter the SEO pages use or they'd stay in °F while the toggle says °C. Both import units.js, which the module loader dedupes. #} -- 2.45.2