promote: main → release #132

Merged
admin_emi merged 56 commits from main into release 2026-08-01 14:48:26 +00:00
9 changed files with 1016 additions and 6 deletions
Showing only changes of commit 4f21e47750 - Show all commits

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 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 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 os
import time 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 fastapi.concurrency import run_in_threadpool
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -17,8 +17,15 @@ from data import climate
from data import grid from data import grid
from notifications import push from notifications import push
from accounts.db import get_async_session, get_read_session 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 ( from accounts.schemas import (
BOOKMARK_MAX_PER_USER,
BookmarkImportIn,
BookmarkImportOut,
BookmarkIn,
BookmarkList,
BookmarkOut,
BookmarkPatch,
NotificationList, NotificationList,
NotificationOut, NotificationOut,
PushSubscriptionIn, PushSubscriptionIn,
@ -323,3 +330,171 @@ async def push_test(
# `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client # `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client
# can tell "request accepted" apart from "actually delivered". # can tell "request accepted" apart from "actually delivered".
return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed} 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. # duplicating, which is what makes the form idempotent.
UniqueConstraint("email", name="uq_pending_digest_email"), 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) ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
DEFAULT_METRICS = ["tmax", "feels", "precip"] 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) --------------------------------------------------- # --- users (fastapi-users) ---------------------------------------------------
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
@ -135,3 +140,69 @@ class PushSubscriptionIn(BaseModel):
class PushUnsubscribeIn(BaseModel): class PushUnsubscribeIn(BaseModel):
endpoint: str 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 // 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 // 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. // 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 "./nav.js";
import { apiFetch, openAuth, onAuthChange, uv } from "./account.js"; import { apiFetch, openAuth, onAuthChange, uv } from "./account.js";
import { open as openPicker } from "./mappicker.js"; import { open as openPicker } from "./mappicker.js";
import * as pushClient from "./push-client.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 // 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.) // 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 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 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"); const body = document.getElementById("alerts-body");
let subs = []; let subs = [];
@ -33,6 +40,25 @@ function esc(s) {
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); ({ "&": "&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) { async function readErr(res) {
try { try {
const d = await res.json(); const d = await res.json();
@ -80,17 +106,84 @@ function render() {
<button type="button" class="find-btn" id="add-alert">+ Add a city</button> <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> <p class="alerts-note muted">Each alert sends at most one notification per week.</p>
</div> </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; body.querySelector("#add-alert").onclick = startAdd;
renderPushBar(); renderPushBar();
const list = body.querySelector("#sub-list"); const list = body.querySelector("#sub-list");
if (!subs.length) { if (!subs.length) {
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`; 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) --------------------------------- // --- push notifications toggle (this device) ---------------------------------
// Delivery over OS push is per-device: a subscription lives in each browser, so // 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 // this control reflects/toggles *this* device, separate from the account-wide

View file

@ -198,6 +198,10 @@
{% block body_scripts %} {% block body_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{ asset_base }}/app.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 {# 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 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. #} the toggle says °C. Both import units.js, which the module loader dedupes. #}