Merge pull request 'promote: dev → main (location bookmarks)' (#119) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 13s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m7s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Deploy / deploy (backend) (push) Successful in 1m16s
Deploy / deploy (frontend) (push) Successful in 1m29s

This commit is contained in:
emi 2026-07-26 18:46:01 +00:00
commit 4f21e47750
9 changed files with 1016 additions and 6 deletions

View file

@ -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],
)

View file

@ -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"),
)

View file

@ -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]

View file

@ -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())

View file

@ -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

View file

@ -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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
const STAR_FILLED = `<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
const CHEV_IC = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
const CLOSE_X = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const PENCIL_IC = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) + `<span>${saved ? "Saved" : "Save"}</span>`;
}
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 = `
<button type="button" class="bkm-pill" id="bkm-pill" hidden aria-haspopup="true" aria-expanded="false">
<span>Saved</span>${CHEV_IC}
</button>
<ul class="bkm-drop" id="bkm-drop" hidden aria-label="Saved locations"></ul>`;
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 `
<li class="bkm-item">
<button type="button" class="bkm-item-go" data-id="${esc(b.id)}" title="${esc(label)}">${esc(label)}</button>
<button type="button" class="bkm-item-btn bkm-item-rename" data-id="${esc(b.id)}" title="Rename" aria-label="Rename ${esc(label)}">${PENCIL_IC}</button>
<button type="button" class="bkm-item-btn bkm-item-remove" data-id="${esc(b.id)}" title="Remove" aria-label="Remove ${esc(label)}">${CLOSE_X}</button>
</li>`;
}).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 = `
<span>Import ${n} saved location${n === 1 ? "" : "s"} into your account?</span>
<span class="bkm-import-actions">
<button type="button" class="bkm-import-yes">Import</button>
<button type="button" class="bkm-import-no">Not now</button>
</span>`;
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();

View file

@ -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();
}
});

View file

@ -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 = `<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 PENCIL_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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
const body = document.getElementById("alerts-body");
let subs = [];
@ -33,6 +40,25 @@ function esc(s) {
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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() {
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
</div>
<ul class="sub-list" id="sub-list"></ul>`;
<ul class="sub-list" id="sub-list"></ul>
<section class="bkm-section" id="bkm-alerts-section" aria-labelledby="bkm-heading"></section>`;
body.querySelector("#add-alert").onclick = startAdd;
renderPushBar();
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;
} 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 = `
<div class="sub-top">
<div class="sub-title">
<span class="sub-name">${esc(label)}</span>
${b.label ? `<span class="bkm-card-loc">${esc(b.lat.toFixed(3))}, ${esc(b.lon.toFixed(3))}</span>` : ""}
</div>
<span class="bkm-card-actions">
<button type="button" class="bkm-rename" title="Rename" aria-label="Rename ${esc(label)}">${PENCIL_IC}</button>
<button type="button" class="sub-remove" aria-label="Remove ${esc(label)}">${TRASH_IC}</button>
</span>
</div>`;
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 = `
<h2 class="bkm-heading" id="bkm-heading">Saved locations</h2>
<div class="bkm-import-row">
<p class="alerts-note muted">Places you've starred on the map, kept in sync with your account.</p>
<button type="button" class="find-btn" id="bkm-import-btn">Import saved locations from this device</button>
</div>
<p class="bkm-import-status muted" id="bkm-import-status" hidden></p>
<ul class="sub-list" id="bkm-list"></ul>`;
const ul = el.querySelector("#bkm-list");
if (!list.length) {
ul.innerHTML = `<li class="sub-empty muted">No saved locations yet. Star a spot on the map to see it here.</li>`;
} 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

View file

@ -198,6 +198,10 @@
{% block body_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{ asset_base }}/app.js"></script>
{# 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. #}
<script type="module" src="{{ asset_base }}/bookmarks-ui.js"></script>
{# 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. #}