Skip to content

[WKP-81] Persist user preferences across devices#56

Merged
SteadfastKnight merged 10 commits into
mainfrom
feature/WKP-81-User-preference-persistence
Jun 2, 2026
Merged

[WKP-81] Persist user preferences across devices#56
SteadfastKnight merged 10 commits into
mainfrom
feature/WKP-81-User-preference-persistence

Conversation

@SteadfastKnight

@SteadfastKnight SteadfastKnight commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

Audit found 9 user preferences stored in localStorage only (per-device, per-browser). This PR moves all of them to backend-backed storage with localStorage cache, so they sync across devices and browsers on login. Mirrors the existing UserDictionaryService pattern.

Three groups, three tables:

Group Table Endpoint
Personalisation (theme, palette, layout) user_preferences GET/PATCH /api/preferences
List sort orders (documents, book-view, translation, extraction, page-linking) user_view_states GET/PATCH /api/view-states
Spell-check ignored words user_ignored_words GET /api/ignored-words, POST, DELETE/{id}

Design choices

  • GET returns {data: null} (or empty list) when no row exists — no firstOrCreate. Prevents Device B from clobbering Device A's settings with defaults on first load.
  • PATCH (not PUT) for partial updates — quick toggles don't clobber sibling fields.
  • localStorage stays as a cache so the FOUC-prevention inline script in index.html keeps working.
  • Cross-tab sync via window.addEventListener('storage', ...).
  • No localStorage→server migration: app not yet launched; devs re-pick once after deploy.
  • Don't clear localStorage on logout — keeps login page themed.

Bonus fix: vitest tests on Node 22+

Found and fixed an unrelated test infrastructure bug: Node 22+ defines localStorage on globalThis as an experimental stub that resolves to undefined. Vitest's jsdom env's populateGlobal skips overriding existing globals not in its KEYS list, so Node's broken stub shadowed jsdom's working localStorage — making 218 tests fail. Added a tiny polyfill (src/test-setup/local-storage.ts) that installs methods on Storage.prototype so existing vi.spyOn patterns keep working. Skipped on Node <22 where jsdom's storage already works.

Test plan

  • php artisan migrate then run frontend (1060 specs) + backend (40 new feature tests) — all green.
  • Log in Browser A → change theme/palette/layout, sort a list, ignore a word in the editor.
  • Open Browser B (different profile/incognito) → log in same account → confirm all settings carry over.
  • Log out of Browser A → login page stays themed.
  • Open two tabs in Browser A → change palette in tab 1 → tab 2 picks it up (storage event).
  • Devtools offline → toggle theme → signal updates, localStorage updated; PATCH retries silently fail.
  • Fresh browser (no auth) → set theme as guest → log in to account with no server prefs → guest values preserved (server null response); change theme → server now has a row.
  • Same as above but log in to account with existing server prefs → guest values silently replaced by server.

🤖 Generated with Claude Code

SteadfastKnight and others added 10 commits May 27, 2026 12:34
Node 22+ defines `localStorage` on globalThis as an experimental stub that
resolves to undefined without `--localstorage-file`. Vitest's jsdom env
populateGlobal skips overriding existing globals not in its KEYS list, so
Node's broken stub shadows jsdom's working localStorage. Add an in-memory
Storage polyfill (methods on `Storage.prototype` so `vi.spyOn` patterns keep
working); skipped on Node <22 where jsdom's storage already works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `user_preferences` table (one row per user, nullable theme_mode/
palette/layout). GET /api/preferences returns `{data: null}` when no row
exists so a second device doesn't clobber the first; PATCH does
updateOrCreate. UserPreferencesService owns the signals, hydrates from
localStorage on boot (FOUC script still reads them), loads from server when
auth flips true, optimistically PATCHes on change, and re-syncs across
tabs via the storage event. ThemeService and LayoutService now delegate
to it. No localStorage->server migration: app not launched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polyfill TS uses index-signature/unknown patterns that fail the stricter
app build. Tests still include it via tsconfig.spec.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `user_view_states` table — one row per user, one nullable JSON column
per page (documents, book view, translation, extraction, page linking).
GET /api/view-states returns `{data: null}` when no row; PATCH does
updateOrCreate with partial column updates. UserViewStatesService owns
per-page sort signals (initialized from localStorage, hydrated from server
on auth, kept in sync across tabs via storage event). Five list pages
inject the service and delegate sort reads/writes to it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `user_ignored_words` table mirroring user_dictionary_words (id, user_id,
word, language, unique on the triple). UserIgnoredWordsService follows the
UserDictionaryService pattern: signal of entries, GET on construction with
localStorage offline fallback, optimistic add/remove. SpellCheckService now
delegates ignore/removeFromIgnored/isCorrect through the new service instead
of a private in-memory Set + localStorage. `ignore(word, lang)` now takes a
required lang (matches the dictionary path); callers in extraction-reader
and translation-reader pass currentLang.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "ignore" test now persists `recieve` to user_ignored_words server-side
(previously per-tab localStorage), so subsequent tests in the suite never
saw `.spell-error` for `recieve` and timed out. Replace the dictionary-only
afterAll cleanup with an afterEach that wipes both /ignored-words and
/dictionary entries for `recieve`, keeping tests order-independent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserPreferenceFactory, UserViewStateFactory, UserIgnoredWordFactory.
Match the UserDictionaryWordFactory pattern. Not used by the existing
feature tests (those use ::create() directly) but available for future tests
that need to seed user-scoped preference state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests that seed values via the API (PATCH /api/preferences and
PATCH /api/view-states), drop the corresponding localStorage cache before
the app boots, then assert the page renders the seeded values — proving
the auth-triggered GET path actually hydrates the UI on a fresh client.

personalisation.spec: extend afterEach to also reset the server-side
preferences row so picks don't leak across tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SteadfastKnight SteadfastKnight merged commit 6471eeb into main Jun 2, 2026
3 checks passed
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