// 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. * * Throws if signed in and the server explicitly rejects the request (e.g. the * 409 per-user cap, a 422 on bad input) — that is a real failure, not the * "offline" case below, and must not be papered over with a local-only * bookmark that doesn't actually exist server-side and would just vanish, * unexplained, on the next sync. A genuine network failure (no response at * all) still falls through to the local-only save so the star visibly * sticks even offline. */ export async function add(lat, lon, label) { const id = cellId(lat, lon); if (signedIn) { let res = null; try { res = await apiFetch(uv("bookmarks"), { method: "POST", json: { lat, lon, label: label || undefined } }); } catch (e) { // offline / network failure: fall through to the local-only path below. } if (res) { if (res.ok) { const rows = await fetchServerList(); if (rows != null) { cache = rows.map(fromServerRow); writeLocal(cache); notify(); return find(lat, lon); } } else { let msg = `Couldn't save this location (${res.status}).`; try { const d = await res.json(); if (typeof d.detail === "string") msg = d.detail; } catch (e) { /* no body */ } throw new Error(msg); } } } 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(); } });