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
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-23 12:37:27 +01:00
parent ffe285b7ba
commit 3c97595093
8 changed files with 157 additions and 49 deletions
+2 -1
View File
@@ -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) |
+32 -2
View File
@@ -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 ────────────────────────────────────────────────────────
+5
View File
@@ -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
+9
View File
@@ -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:
+4
View File
@@ -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")
+4
View File
@@ -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()
+100 -40
View File
@@ -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])
}
+1 -6
View File
@@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OSM contributors</a>'
// 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: {