Skip to content

feat(preferences): per-device server-side UI preferences#284

Merged
lstein merged 2 commits into
masterfrom
lstein/feature/server-side-preferences
May 26, 2026
Merged

feat(preferences): per-device server-side UI preferences#284
lstein merged 2 commits into
masterfrom
lstein/feature/server-side-preferences

Conversation

@lstein

@lstein lstein commented May 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Move the 15 UI preferences that previously lived only in localStorage 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.
  • Fixes the iOS / iPad regression where Chrome evicts localStorage after the app is backgrounded, resetting all preferences to defaults. The device cookie persists across those evictions.
  • Adds a "Reset to Defaults" link at the bottom of the Settings dialog (DELETE /preferences/, clears the LS cache for owned keys, reloads).

What's new

Backend

  • photomap/backend/preferences.pyUserPreferences Pydantic model (camelCase wire format, snake_case Python) and PreferencesManager backed by preferences.json next to the YAML config. Atomic writes, RLock for concurrent PATCHes.
  • photomap/backend/routers/preferences.pyGET / PATCH / PUT / DELETE /preferences/. A get_device_id FastAPI dependency mints a uuid4().hex cookie on first hit (HttpOnly, SameSite=Lax, Max-Age=1y).

Frontend

  • preferences-client.jsfetchPreferences, debounced (500 ms) merged PATCH queue, plus cancel / flush hooks. visibilitychange + beforeunload flush 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. restoreFromLocalStorage stays as the paint cache; reconcileWithServer runs after stateReady and 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.
  • Settings dialog — bold-red text-link "Reset to Defaults" at the bottom. Confirm → cancelPendingPatches()DELETE /preferences/clearPersistedSettingsCache()location.reload().

Scope notes

  • Bookmarks, the curation export path, version-dismissed cache, and accordion open/closed state are intentionally left in localStorage — they're not part of the UI preferences surface and have separate ownership.
  • Cookie is not Secure by 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

  • Backend: 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.
  • Frontend: npm test — 14 new Jest tests in preferences-client.test.js (fake timers + stubbed fetch). Full suite 334/334.
  • Lint: make lint clean (ruff + eslint + prettier).
  • Manual: open Settings → change a few toggles → close tab → reopen → values persist.
  • Manual on iPad: same flow, then background the tab long enough that localStorage would normally evict → reopen → values still persist via the cookie path.
  • Manual: click "Reset to Defaults" → confirm → page reloads with model defaults; bookmarks and accordion states untouched.
  • Manual: two browsers on the same machine — verify each gets its own device cookie and prefs don't bleed across them.

🤖 Generated with Claude Code

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>
@lstein lstein enabled auto-merge (squash) May 26, 2026 02:25
@lstein lstein merged commit bfb3ce0 into master May 26, 2026
6 checks passed
@lstein lstein deleted the lstein/feature/server-side-preferences branch May 26, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant