From 3c97595093c3e151842368508753840a0af5c660 Mon Sep 17 00:00:00 2001 From: owain Date: Tue, 23 Jun 2026 12:37:27 +0100 Subject: [PATCH] feat: persist map tile settings (provider/style/API keys) server-side on user record; unbake Thunderforest default key into backend config (THUNDERFOREST_DEFAULT_KEY) served via profile --- CLAUDE.md | 3 +- backend/app/api/profile.py | 34 ++++++- backend/app/core/config.py | 5 + backend/app/main.py | 9 ++ backend/app/models/user.py | 4 + frontend/src/components/ui/Layout.jsx | 4 + frontend/src/hooks/useMapSettings.js | 140 ++++++++++++++++++-------- frontend/src/utils/mapTiles.js | 7 +- 8 files changed, 157 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 872d785..2e3f4be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,7 @@ docker compose -f docker-compose.deploy.yml up -d - `hooks/useSync.js` — Zustand store polling Garmin sync status; maps backend status strings to progress percentages - `hooks/useUnits.js` — Zustand store for the global km/mi display preference (`useUnit()` subscribes to the active unit). Distances are stored canonically; the unit only affects display, converted on the fly by the `format.js` helpers. Persisted to `localStorage`. Surfaced in the nav via `components/ui/UnitToggle.jsx` - `hooks/useMediaQuery.js` — responsive breakpoint hook (md=768px split); the dashboard widget grid must be conditionally mounted, not just CSS-hidden, on mobile -- `hooks/useMapSettings.js` — Zustand store (localStorage-persisted, like `useUnits`) for the global map tile preference: provider + style + per-provider API keys. The provider/style catalogue lives in `utils/mapTiles.js` (`MAP_PROVIDERS`, `resolveTile`); every Leaflet map (`ActivityMap`, `RouteTileMap`) resolves its base layer via `useResolvedTile()`. Configured in Profile › Map & Tiles. `ActivityMap` takes a `satellite` boolean to override with imagery (MapTiler satellite if keyed, else free Esri) +- `hooks/useMapSettings.js` — Zustand store for the global map tile preference: provider + style + per-provider API keys. **Persisted server-side on the user record** (`users.map_settings` JSON), with localStorage only as a first-paint cache. `useHydrateMapSettings()` (called in `Layout`) loads it from `GET /profile/`; mutations debounce-save to `PUT /profile/map-settings`. The provider/style catalogue lives in `utils/mapTiles.js` (`MAP_PROVIDERS`, `resolveTile`); every Leaflet map (`ActivityMap`, `RouteTileMap`) resolves its base layer via `useResolvedTile()`. The default Thunderforest key is **not baked into the bundle** — the backend supplies it via `settings.thunderforest_default_key` in the profile response, and `useResolvedTile` falls back to it when the user hasn't set their own. Configured in Profile › Map & Tiles. `ActivityMap` takes a `satellite` boolean to override with imagery (MapTiler satellite if keyed, else free Esri) - `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler - TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth, sync, and unit-preference state - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc. @@ -175,6 +175,7 @@ Required in `.env` (or passed to Docker Compose): | `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) | | `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) | | `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) | +| `THUNDERFOREST_DEFAULT_KEY` | Default Thunderforest tile key served to clients without their own (public client-side key; has a built-in default) | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group | | `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` | Optional — enables Strava API OAuth live sync (register an app at strava.com/settings/api) | diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index 8fbcca6..0e0f219 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -7,6 +7,7 @@ from datetime import datetime, date, timezone from app.core.database import get_db from app.core.security import get_current_user, hash_password, verify_password +from app.core.config import settings from app.models.user import User, WeightLog router = APIRouter() @@ -36,6 +37,8 @@ class ProfileOut(BaseModel): estimated_max_hr: Optional[int] is_admin: bool dashboard_layout: Optional[list] = None + map_settings: Optional[dict] = None + thunderforest_default_key: Optional[str] = None class Config: from_attributes = True @@ -45,6 +48,12 @@ class DashboardLayoutIn(BaseModel): layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h} +class MapSettingsIn(BaseModel): + provider: Optional[str] = None + style: Optional[str] = None + keys: Optional[dict] = None # {thunderforest, maptiler, ...} public tile keys + + def _estimated_max_hr(user: User) -> Optional[int]: if user.birth_year: return 220 - (datetime.now().year - user.birth_year) @@ -55,7 +64,27 @@ def _estimated_max_hr(user: User) -> Optional[int]: async def get_profile(current_user: User = Depends(get_current_user)): return {**{c.name: getattr(current_user, c.name) for c in User.__table__.columns}, - "estimated_max_hr": _estimated_max_hr(current_user)} + "estimated_max_hr": _estimated_max_hr(current_user), + "thunderforest_default_key": settings.thunderforest_default_key} + + +@router.put("/map-settings") +async def save_map_settings( + body: MapSettingsIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Persist the user's global map tile preference (provider/style/keys).""" + keys = {} + for k, v in (body.keys or {}).items(): + keys[str(k)] = (v or "").strip() + current_user.map_settings = { + "provider": body.provider, + "style": body.style, + "keys": keys, + } + await db.commit() + return {"status": "ok"} @router.put("/dashboard-layout") @@ -111,7 +140,8 @@ async def update_profile( return {**{c.name: getattr(current_user, c.name) for c in User.__table__.columns}, - "estimated_max_hr": _estimated_max_hr(current_user)} + "estimated_max_hr": _estimated_max_hr(current_user), + "thunderforest_default_key": settings.thunderforest_default_key} # ── Password change ──────────────────────────────────────────────────────── diff --git a/backend/app/core/config.py b/backend/app/core/config.py index adb304f..008652b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -28,6 +28,11 @@ class Settings(BaseSettings): # The Authorization Callback Domain there must match BASE_URL's host. strava_client_id: Optional[str] = Field(None, env="STRAVA_CLIENT_ID") strava_client_secret: Optional[str] = Field(None, env="STRAVA_CLIENT_SECRET") + # Default Thunderforest tile API key, served to clients that haven't set + # their own (a public, client-side tile key). Override per-deployment. + thunderforest_default_key: str = Field( + "872984f587484873a74ea454662ffacb", env="THUNDERFOREST_DEFAULT_KEY" + ) # Files file_store_path: str = Field("/data/files", env="FILE_STORE_PATH") # Environment diff --git a/backend/app/main.py b/backend/app/main.py index d9c1ce0..adc846f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -118,6 +118,15 @@ async def init_db(): except Exception as e: print(f"users.dashboard_layout column migration skipped: {e}") + # map_settings column on users added after initial creation + try: + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS map_settings JSON" + )) + except Exception as e: + print(f"users.map_settings column migration skipped: {e}") + # Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days try: async with engine.begin() as conn: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6e2a902..292e061 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -40,6 +40,10 @@ class User(Base): # Saved dashboard widget layout (react-grid-layout array). Null = use default. dashboard_layout = Column(JSON, nullable=True) + # Global map tile preference: {provider, style, keys:{thunderforest, maptiler}}. + # Null = use defaults. Tile API keys are public client-side keys. + map_settings = Column(JSON, nullable=True) + activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan") health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan") diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index 6f516e5..8c2aa5e 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../hooks/useAuth' import { useSyncStore, syncProgressPct } from '../../hooks/useSync' +import { useHydrateMapSettings } from '../../hooks/useMapSettings' import UnitToggle from './UnitToggle' const nav = [ @@ -24,6 +25,9 @@ export default function Layout() { const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1') const [moreOpen, setMoreOpen] = useState(false) + // Load the user's saved map tile preference from the server into the store. + useHydrateMapSettings() + useEffect(() => { startPolling() return () => stopPolling() diff --git a/frontend/src/hooks/useMapSettings.js b/frontend/src/hooks/useMapSettings.js index a5fd2d6..2d894ee 100644 --- a/frontend/src/hooks/useMapSettings.js +++ b/frontend/src/hooks/useMapSettings.js @@ -1,56 +1,116 @@ -import { useMemo } from 'react' +import { useMemo, useEffect } from 'react' import { create } from 'zustand' +import { useQuery } from '@tanstack/react-query' +import api from '../utils/api' +import { useAuthStore } from './useAuth' import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles' -// Global map tile preference (provider + style + per-provider API keys). Chosen -// once on the Profile page and used by every Leaflet map in the app. Persisted -// to localStorage so the choice sticks across reloads (mirrors useUnits). API -// keys live client-side only — these are public, client-side tile keys. -const KEY = 'mapSettings' +// Global map tile preference (provider + style + per-provider API keys). The +// source of truth is the user record on the server (so the choice and keys +// follow the user across devices); localStorage is only a cache so maps render +// correctly on first paint before the server hydrates. The built-in default +// Thunderforest key is supplied by the server (not baked into this bundle). +const CACHE = 'mapSettings' -function load() { +function loadCache() { try { - const saved = JSON.parse(localStorage.getItem(KEY)) - if (saved && typeof saved === 'object') { - return { ...DEFAULT_MAP_SETTINGS, ...saved, keys: { ...saved.keys } } + const s = JSON.parse(localStorage.getItem(CACHE)) + if (s && typeof s === 'object') return s + } catch { /* ignore malformed cache */ } + return {} +} + +function writeCache(s) { + localStorage.setItem(CACHE, JSON.stringify({ + provider: s.provider, + style: s.style, + keys: s.keys, + defaultThunderforestKey: s.defaultThunderforestKey, + })) +} + +// First style id for a provider, used when switching provider or validating. +const firstStyle = (provider) => Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0] + +let saveTimer = null +const cache0 = loadCache() + +export const useMapSettingsStore = create((set, get) => ({ + provider: cache0.provider || DEFAULT_MAP_SETTINGS.provider, + style: cache0.style || DEFAULT_MAP_SETTINGS.style, + keys: cache0.keys || {}, + defaultThunderforestKey: cache0.defaultThunderforestKey || '', + hydrated: false, + + // Populate from the server profile. Only marks the store ready to persist + // after this runs, so we never clobber the server with stale cache values. + hydrate: (server, defaultKey) => set((s) => { + const ms = server || {} + const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider + const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style + : (MAP_PROVIDERS[provider]?.styles[s.style] ? s.style : firstStyle(provider)) + const next = { + ...s, + provider, + style, + keys: ms.keys || s.keys || {}, + defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '', + hydrated: true, } - } catch { /* ignore malformed storage */ } - return { ...DEFAULT_MAP_SETTINGS, keys: {} } -} + writeCache(next) + return next + }), -function persist(s) { - localStorage.setItem(KEY, JSON.stringify({ provider: s.provider, style: s.style, keys: s.keys })) -} + // Debounced persist of the full settings to the user record. + _save: () => { + const s = get() + if (!s.hydrated) return + clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + api.put('/profile/map-settings', { provider: s.provider, style: s.style, keys: s.keys }) + .catch(() => { /* non-fatal; cache keeps the local choice */ }) + }, 600) + }, -export const useMapSettingsStore = create((set) => ({ - ...load(), - setProvider: (provider) => set((s) => { - // Reset the style to the provider's first available style. - const styleId = Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0] - const next = { ...s, provider, style: styleId } - persist(next) - return next - }), - setStyle: (style) => set((s) => { - const next = { ...s, style } - persist(next) - return next - }), - setKey: (provider, value) => set((s) => { - const next = { ...s, keys: { ...s.keys, [provider]: value } } - persist(next) - return next - }), + setProvider: (provider) => { + set((s) => { const next = { ...s, provider, style: firstStyle(provider) }; writeCache(next); return next }) + get()._save() + }, + setStyle: (style) => { + set((s) => { const next = { ...s, style }; writeCache(next); return next }) + get()._save() + }, + setKey: (provider, value) => { + set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value } }; writeCache(next); return next }) + get()._save() + }, })) +// Hydrate the store from the server profile once authenticated. Call once near +// the app root (Layout). Reuses the shared ['profile'] query cache. +export function useHydrateMapSettings() { + const token = useAuthStore((s) => s.token) + const hydrate = useMapSettingsStore((s) => s.hydrate) + const { data } = useQuery({ + queryKey: ['profile'], + queryFn: () => api.get('/profile/').then((r) => r.data), + enabled: !!token, + }) + useEffect(() => { + if (data) hydrate(data.map_settings, data.thunderforest_default_key) + }, [data]) +} + // Resolve the active tile layer config for a map. Pass satellite=true for an -// imagery override (e.g. the per-activity satellite toggle). +// imagery override (e.g. the per-activity satellite toggle). The Thunderforest +// key falls back to the server-supplied default when the user hasn't set one. export function useResolvedTile(satellite = false) { const provider = useMapSettingsStore((s) => s.provider) const style = useMapSettingsStore((s) => s.style) const keys = useMapSettingsStore((s) => s.keys) - return useMemo( - () => resolveTile({ provider, style, keys }, { satellite }), - [provider, style, keys, satellite], - ) + const defaultTf = useMapSettingsStore((s) => s.defaultThunderforestKey) + return useMemo(() => { + const effectiveKeys = { ...keys, thunderforest: keys.thunderforest || defaultTf } + return resolveTile({ provider, style, keys: effectiveKeys }, { satellite }) + }, [provider, style, keys, defaultTf, satellite]) } diff --git a/frontend/src/utils/mapTiles.js b/frontend/src/utils/mapTiles.js index 464dea7..72fe9a2 100644 --- a/frontend/src/utils/mapTiles.js +++ b/frontend/src/utils/mapTiles.js @@ -3,11 +3,6 @@ // stored in the useMapSettings store; every Leaflet map resolves its base layer // through resolveTile() so a single setting drives the entire application. -// Built-in Thunderforest key (a public, client-side tile key) used when the user -// hasn't supplied their own. Matches the historical RouteTileMap default so the -// app keeps working out of the box, but it may be rate-limited under load. -export const DEFAULT_THUNDERFOREST_KEY = '872984f587484873a74ea454662ffacb' - const OSM_ATTR = '© OSM contributors' // Each provider lists its selectable styles (keyed by the id used in the tile @@ -34,7 +29,7 @@ export const MAP_PROVIDERS = { 'spinal-map': { label: 'Spinal' }, }, build: (styleId, key) => - `https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || DEFAULT_THUNDERFOREST_KEY}`, + `https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || ''}`, }, maptiler: {