[WKP-81] Persist user preferences across devices#56
Merged
SteadfastKnight merged 10 commits intoJun 2, 2026
Conversation
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>
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
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
UserDictionaryServicepattern.Three groups, three tables:
user_preferencesGET/PATCH /api/preferencesuser_view_statesGET/PATCH /api/view-statesuser_ignored_wordsGET /api/ignored-words,POST,DELETE/{id}Design choices
GETreturns{data: null}(or empty list) when no row exists — nofirstOrCreate. Prevents Device B from clobbering Device A's settings with defaults on first load.PATCH(notPUT) for partial updates — quick toggles don't clobber sibling fields.index.htmlkeeps working.window.addEventListener('storage', ...).Bonus fix: vitest tests on Node 22+
Found and fixed an unrelated test infrastructure bug: Node 22+ defines
localStorageon globalThis as an experimental stub that resolves toundefined. Vitest's jsdom env'spopulateGlobalskips overriding existing globals not in itsKEYSlist, 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 onStorage.prototypeso existingvi.spyOnpatterns keep working. Skipped on Node <22 where jsdom's storage already works.Test plan
php artisan migratethen run frontend (1060 specs) + backend (40 new feature tests) — all green.nullresponse); change theme → server now has a row.🤖 Generated with Claude Code