feat(preferences): per-device server-side UI preferences#284
Merged
Conversation
Move the 15 UI preferences that previously lived only in localStorage (currentDelay, mode, UMAP toggles, grid thumb size, etc.) into a server-side store keyed by an opaque HttpOnly device cookie. localStorage becomes a synchronous paint cache; the server is the source of truth and the two reconcile on boot. This fixes the iOS/iPad regression where Chrome evicts localStorage after the app is backgrounded for a while, resetting all preferences to defaults on the next visit. The device cookie persists across those evictions, so prefs survive. Backend: - preferences.py: UserPreferences Pydantic model (camelCase wire format, snake_case Python) + PreferencesManager backed by preferences.json next to the YAML config. Atomic writes, RLock for concurrent PATCHes. - routers/preferences.py: GET / PATCH / PUT / DELETE under /preferences/, plus a get_device_id dependency that mints a uuid4 hex cookie on first hit (HttpOnly, SameSite=Lax, Max-Age=1y). - 10 backend tests covering cookie minting, malformed-cookie rotation, PATCH merge, snake_case acceptance, unknown-field drop, validation errors, two-client isolation, PUT replace, DELETE. Frontend: - preferences-client.js: fetchPreferences, debounced (500ms) merged PATCH queue, cancel / flush hooks. visibilitychange + beforeunload flush on the way out so quick "change a setting then close the tab" doesn't lose the change on mobile. - state.js: hybrid restore/save. restoreFromLocalStorage stays as the paint cache; reconcileWithServer runs after stateReady and either pulls newer server state down (server.updatedAt > local) or PATCHes locally-newer values up (covers the one-shot migration of existing users automatically). - Settings dialog: "Reset to Defaults" link at the bottom that confirms, DELETEs /preferences/, clears the LS cache for owned keys, and reloads. Styled as a bold red text link rather than a heavy filled button — the action is rare and destructive but shouldn't read as a primary CTA. Bookmarks, the curation export path, version-dismissed cache, and accordion open/closed state are intentionally left in localStorage — they're not part of the preferences surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
What's new
Backend
photomap/backend/preferences.py—UserPreferencesPydantic model (camelCase wire format, snake_case Python) andPreferencesManagerbacked bypreferences.jsonnext to the YAML config. Atomic writes, RLock for concurrent PATCHes.photomap/backend/routers/preferences.py—GET / PATCH / PUT / DELETE /preferences/. Aget_device_idFastAPI dependency mints auuid4().hexcookie on first hit (HttpOnly,SameSite=Lax,Max-Age=1y).Frontend
preferences-client.js—fetchPreferences, debounced (500 ms) merged PATCH queue, plus cancel / flush hooks.visibilitychange+beforeunloadflush on the way out so a quick "change a setting then close the tab" doesn't lose the change on mobile.state.js— hybrid restore/save.restoreFromLocalStoragestays as the paint cache;reconcileWithServerruns afterstateReadyand either pulls newer server state down (server.updatedAt > local._prefServerUpdatedAt) or PATCHes locally-newer values up. The latter handles the one-shot migration of existing users automatically — no separate migration code path.cancelPendingPatches()→DELETE /preferences/→clearPersistedSettingsCache()→location.reload().Scope notes
Secureby default since local-first deployments are usually plain HTTP on the LAN. Add it via a reverse proxy or a future setting if you serve PhotoMap over HTTPS.Test plan
pytest tests/backend/test_preferences.py— 10 tests cover cookie minting, malformed-cookie rotation, PATCH merge, snake_case input acceptance, unknown-field drop, range/literal 422 errors, two-client isolation, PUT replace, DELETE cookie clear.npm test— 14 new Jest tests inpreferences-client.test.js(fake timers + stubbed fetch). Full suite 334/334.make lintclean (ruff + eslint + prettier).🤖 Generated with Claude Code