fix: map settings no longer revert/lose in-flight edits when profile refetches (dirty-flag guards hydrate clobber)
This commit is contained in:
@@ -124,10 +124,10 @@ docker compose -f docker-compose.deploy.yml up -d
|
|||||||
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
|
- `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
|
- 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.
|
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
|
||||||
- `utils/track.js` — projects a lat/lng onto a GPS track (interpolated along-line snapping, used for map hover and segment selection); `utils/bodyBattery.js` — shared Body Battery colour/state helpers used by both the Health page and Dashboard mini chart
|
- `utils/track.js` — projects a lat/lng onto a GPS track (interpolated along-line snapping, used for map hover and segment selection); `utils/bodyBattery.js` — shared Body Battery colour/state helpers used by both the Health page and Dashboard mini chart; `utils/vo2.js` — VO2 max classification/colour helpers
|
||||||
- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login`
|
- `pages/` — one `*Page.jsx` file per route: `Dashboard` (drag-to-edit widget grid), `Activities` (type/year/date-range/distance filters + week totals), `ActivityDetail`, `Routes`, `Records`, `Health`, `Summary` (all-time and per-year/per-sport totals + distance-per-year chart), `Upload`, `Profile`, `Users`, `Login`
|
||||||
- `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
|
- `components/activity/` — `ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
|
||||||
- `components/health/` — `SleepHypnogram` (renders the `sleep_stages` hypnogram)
|
- `components/health/` — `SleepHypnogram` (renders the `sleep_stages` hypnogram), `BodyBatteryChart` (Body Battery trend chart)
|
||||||
- `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `UnitToggle` (km/mi switch), `HrvBadge`
|
- `components/ui/` — `Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards), `UnitToggle` (km/mi switch), `HrvBadge`
|
||||||
|
|
||||||
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
|
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
|
||||||
|
|||||||
@@ -41,10 +41,24 @@ export const useMapSettingsStore = create((set, get) => ({
|
|||||||
keys: cache0.keys || {},
|
keys: cache0.keys || {},
|
||||||
defaultThunderforestKey: cache0.defaultThunderforestKey || '',
|
defaultThunderforestKey: cache0.defaultThunderforestKey || '',
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
|
dirty: false, // true while a local change is unsaved; blocks hydrate clobber
|
||||||
|
|
||||||
// Populate from the server profile. Only marks the store ready to persist
|
// 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.
|
// after this runs, so we never clobber the server with stale cache values.
|
||||||
|
// While `dirty`, an unsaved local edit exists, so we must NOT overwrite the
|
||||||
|
// user's choice from the (possibly stale) profile cache — a refetch landing
|
||||||
|
// mid-edit would otherwise revert it. We still record the server default key
|
||||||
|
// and that we've hydrated.
|
||||||
hydrate: (server, defaultKey) => set((s) => {
|
hydrate: (server, defaultKey) => set((s) => {
|
||||||
|
if (s.dirty) {
|
||||||
|
const next = {
|
||||||
|
...s,
|
||||||
|
defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '',
|
||||||
|
hydrated: true,
|
||||||
|
}
|
||||||
|
writeCache(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
const ms = server || {}
|
const ms = server || {}
|
||||||
const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider
|
const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider
|
||||||
const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style
|
const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style
|
||||||
@@ -63,25 +77,34 @@ export const useMapSettingsStore = create((set, get) => ({
|
|||||||
|
|
||||||
// Debounced persist of the full settings to the user record.
|
// Debounced persist of the full settings to the user record.
|
||||||
_save: () => {
|
_save: () => {
|
||||||
const s = get()
|
if (!get().hydrated) return
|
||||||
if (!s.hydrated) return
|
|
||||||
clearTimeout(saveTimer)
|
clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
api.put('/profile/map-settings', { provider: s.provider, style: s.style, keys: s.keys })
|
const snap = get()
|
||||||
|
const payload = { provider: snap.provider, style: snap.style, keys: snap.keys }
|
||||||
|
api.put('/profile/map-settings', payload)
|
||||||
|
.then(() => {
|
||||||
|
// Clear `dirty` only if nothing changed since this save was dispatched,
|
||||||
|
// so a newer unsaved edit isn't wrongly treated as saved (and revertable).
|
||||||
|
const cur = get()
|
||||||
|
if (cur.provider === payload.provider && cur.style === payload.style && cur.keys === payload.keys) {
|
||||||
|
set({ dirty: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => { /* non-fatal; cache keeps the local choice */ })
|
.catch(() => { /* non-fatal; cache keeps the local choice */ })
|
||||||
}, 600)
|
}, 600)
|
||||||
},
|
},
|
||||||
|
|
||||||
setProvider: (provider) => {
|
setProvider: (provider) => {
|
||||||
set((s) => { const next = { ...s, provider, style: firstStyle(provider) }; writeCache(next); return next })
|
set((s) => { const next = { ...s, provider, style: firstStyle(provider), dirty: true }; writeCache(next); return next })
|
||||||
get()._save()
|
get()._save()
|
||||||
},
|
},
|
||||||
setStyle: (style) => {
|
setStyle: (style) => {
|
||||||
set((s) => { const next = { ...s, style }; writeCache(next); return next })
|
set((s) => { const next = { ...s, style, dirty: true }; writeCache(next); return next })
|
||||||
get()._save()
|
get()._save()
|
||||||
},
|
},
|
||||||
setKey: (provider, value) => {
|
setKey: (provider, value) => {
|
||||||
set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value } }; writeCache(next); return next })
|
set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value }, dirty: true }; writeCache(next); return next })
|
||||||
get()._save()
|
get()._save()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user