From f9c67e3f7f20ec680434350aebfc74ebaec3b445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 12:34:01 +0300 Subject: [PATCH 01/10] [WKP-81] Polyfill localStorage in vitest tests for Node 22+ 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) --- wordkeep-client/angular.json | 5 +- .../src/test-setup/local-storage.ts | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 wordkeep-client/src/test-setup/local-storage.ts diff --git a/wordkeep-client/angular.json b/wordkeep-client/angular.json index 78d8059..873e4fd 100644 --- a/wordkeep-client/angular.json +++ b/wordkeep-client/angular.json @@ -86,7 +86,10 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "setupFiles": ["src/test-setup/local-storage.ts"] + } } } } diff --git a/wordkeep-client/src/test-setup/local-storage.ts b/wordkeep-client/src/test-setup/local-storage.ts new file mode 100644 index 0000000..13cdebc --- /dev/null +++ b/wordkeep-client/src/test-setup/local-storage.ts @@ -0,0 +1,60 @@ +// Node 22+ ships experimental WebStorage that defines `localStorage` on +// `globalThis` but resolves to `undefined` without `--localstorage-file`. +// Vitest's jsdom env (populateGlobal) only installs a jsdom-backed getter +// for window keys NOT already on `global` AND in its hardcoded KEYS list; +// localStorage/sessionStorage are in neither, so Node's broken stub wins +// over jsdom's working one. +// +// On Node <22 jsdom's localStorage works fine — do nothing then. +// On Node 22+ install an in-memory Storage on globalThis/window with methods +// on `Storage.prototype` so `vi.spyOn(Storage.prototype, ...)` patterns in +// test utilities keep working. + +function isWorking(s: unknown): s is Storage { + if (!s || typeof s !== 'object') return false; + try { + const probe = '__wk_probe__'; + (s as Storage).setItem(probe, '1'); + const v = (s as Storage).getItem(probe); + (s as Storage).removeItem(probe); + return v === '1'; + } catch { + return false; + } +} + +if (!isWorking((globalThis as { localStorage?: Storage }).localStorage)) { + const StorageCtor = (globalThis as { Storage?: { prototype: Record } }).Storage + ?? Object.assign(function FakeStorage() {} as unknown, { prototype: {} as Record }); + if (!(globalThis as { Storage?: unknown }).Storage) { + (globalThis as { Storage?: unknown }).Storage = StorageCtor; + } + + type Backed = { __store: Map }; + const proto = StorageCtor.prototype as Record; + proto.getItem = function (this: Backed, key: string): string | null { return this.__store.get(key) ?? null; }; + proto.setItem = function (this: Backed, key: string, value: string): void { this.__store.set(key, String(value)); }; + proto.removeItem = function (this: Backed, key: string): void { this.__store.delete(key); }; + proto.clear = function (this: Backed): void { this.__store.clear(); }; + proto.key = function (this: Backed, index: number): string | null { return [...this.__store.keys()][index] ?? null; }; + + function makeStorage(): Storage { + const instance = Object.create(proto); + Object.defineProperty(instance, '__store', { value: new Map(), writable: false }); + Object.defineProperty(instance, 'length', { get(this: Backed): number { return this.__store.size; } }); + return instance as Storage; + } + + function install(target: object): void { + for (const name of ['localStorage', 'sessionStorage'] as const) { + Object.defineProperty(target, name, { + value: makeStorage(), + writable: false, + configurable: true, + }); + } + } + + install(globalThis); + if (typeof window !== 'undefined') install(window); +} From e349089e45bbce7d64868c8244b68046661e638e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 12:34:28 +0300 Subject: [PATCH 02/10] [WKP-81] Mirror new plan-mode memory to docs Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/claude-code-memories/MEMORY.md | 1 + .../ask-plan-open-questions-before-exit.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 docs/claude-code-memories/ask-plan-open-questions-before-exit.md diff --git a/docs/claude-code-memories/MEMORY.md b/docs/claude-code-memories/MEMORY.md index 81fb8a8..058137c 100644 --- a/docs/claude-code-memories/MEMORY.md +++ b/docs/claude-code-memories/MEMORY.md @@ -17,3 +17,4 @@ - [Load frontend-design skill](load-frontend-design-skill.md) — load it before any frontend task - [Single README per project](single-readme-per-project.md) — one README at repo root only - [AuthService spec mocks](auth-service-spec-mocks.md) — mock `user` as `vi.fn()` in LayoutComponent specs +- [Ask plan open questions before exit](ask-plan-open-questions-before-exit.md) — resolve every open question via AskUserQuestion before ExitPlanMode diff --git a/docs/claude-code-memories/ask-plan-open-questions-before-exit.md b/docs/claude-code-memories/ask-plan-open-questions-before-exit.md new file mode 100644 index 0000000..cec99b9 --- /dev/null +++ b/docs/claude-code-memories/ask-plan-open-questions-before-exit.md @@ -0,0 +1,14 @@ +--- +name: ask-plan-open-questions-before-exit +description: Before calling ExitPlanMode, surface every open/unresolved question via AskUserQuestion so the plan is fully decided before approval. +metadata: + type: feedback +--- + +When a plan file lists open or unresolved questions, ask them via `AskUserQuestion` BEFORE calling `ExitPlanMode`. Update the plan to reflect the answers, then exit. + +**Why:** User rejected ExitPlanMode twice in WKP-81 planning because the plan still had unresolved questions — they want all decisions made before approval, not deferred into implementation. + +**How to apply:** If the final plan section contains "Unresolved questions" / "Open questions" / similar, treat that as a blocker on ExitPlanMode. Loop: ask via AskUserQuestion → update plan with answer → check for remaining open items → repeat. Only ExitPlanMode when the plan is fully resolved (no pending choices). + +Mirror to `docs/claude-code-memories/` per [[mirror-memories-to-docs]]. From 55a7ca4d6c72e5f8326ed61430a56c00397e4d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 12:35:10 +0300 Subject: [PATCH 03/10] [WKP-81] Persist theme/palette/layout server-side 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) --- .../Api/UserPreferencesController.php | 43 +++ .../Requests/UpdateUserPreferencesRequest.php | 22 ++ app/Http/Resources/UserPreferenceResource.php | 18 ++ app/Models/User.php | 6 + app/Models/UserPreference.php | 19 ++ ...7_084743_create_user_preferences_table.php | 26 ++ routes/api.php | 5 + .../Feature/UserPreferencesControllerTest.php | 161 +++++++++++ .../src/app/services/layout.service.ts | 30 +-- .../src/app/services/theme.service.ts | 48 +--- .../services/user-preferences.service.spec.ts | 251 ++++++++++++++++++ .../app/services/user-preferences.service.ts | 118 ++++++++ 12 files changed, 686 insertions(+), 61 deletions(-) create mode 100644 app/Http/Controllers/Api/UserPreferencesController.php create mode 100644 app/Http/Requests/UpdateUserPreferencesRequest.php create mode 100644 app/Http/Resources/UserPreferenceResource.php create mode 100644 app/Models/UserPreference.php create mode 100644 database/migrations/2026_05_27_084743_create_user_preferences_table.php create mode 100644 tests/Feature/UserPreferencesControllerTest.php create mode 100644 wordkeep-client/src/app/services/user-preferences.service.spec.ts create mode 100644 wordkeep-client/src/app/services/user-preferences.service.ts diff --git a/app/Http/Controllers/Api/UserPreferencesController.php b/app/Http/Controllers/Api/UserPreferencesController.php new file mode 100644 index 0000000..58eb860 --- /dev/null +++ b/app/Http/Controllers/Api/UserPreferencesController.php @@ -0,0 +1,43 @@ +preference; + + return response()->json([ + 'data' => $preference ? new UserPreferenceResource($preference) : null, + ]); + } + + /** + * Update user preferences. + * + * Partial update — only fields present in the request are written. + * Creates the preference row on first call. + */ + public function update(UpdateUserPreferencesRequest $request): JsonResponse + { + $preference = Auth::user()->preference()->updateOrCreate( + ['user_id' => Auth::id()], + $request->validated() + ); + + return response()->json(['data' => new UserPreferenceResource($preference)]); + } +} diff --git a/app/Http/Requests/UpdateUserPreferencesRequest.php b/app/Http/Requests/UpdateUserPreferencesRequest.php new file mode 100644 index 0000000..b813976 --- /dev/null +++ b/app/Http/Requests/UpdateUserPreferencesRequest.php @@ -0,0 +1,22 @@ + ['sometimes', 'string', 'in:auto,light,dark'], + 'palette' => ['sometimes', 'string', 'in:reading-room,cobalt,monastic,grove,ferrous,plum'], + 'layout' => ['sometimes', 'string', 'in:reading-room,workbench,compendium,glass,scriptorium,console'], + ]; + } +} diff --git a/app/Http/Resources/UserPreferenceResource.php b/app/Http/Resources/UserPreferenceResource.php new file mode 100644 index 0000000..2e68596 --- /dev/null +++ b/app/Http/Resources/UserPreferenceResource.php @@ -0,0 +1,18 @@ + $this->theme_mode, + 'palette' => $this->palette, + 'layout' => $this->layout, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3bf9049..f31f526 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Hash; @@ -29,6 +30,11 @@ public function dictionaryWords(): HasMany return $this->hasMany(UserDictionaryWord::class); } + public function preference(): HasOne + { + return $this->hasOne(UserPreference::class); + } + public function isVerified(): bool { return !is_null($this->email_verified_at); diff --git a/app/Models/UserPreference.php b/app/Models/UserPreference.php new file mode 100644 index 0000000..de49a5f --- /dev/null +++ b/app/Models/UserPreference.php @@ -0,0 +1,19 @@ +belongsTo(User::class); + } +} diff --git a/database/migrations/2026_05_27_084743_create_user_preferences_table.php b/database/migrations/2026_05_27_084743_create_user_preferences_table.php new file mode 100644 index 0000000..bb46064 --- /dev/null +++ b/database/migrations/2026_05_27_084743_create_user_preferences_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('theme_mode', 16)->nullable(); + $table->string('palette', 32)->nullable(); + $table->string('layout', 32)->nullable(); + $table->timestamps(); + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_preferences'); + } +}; diff --git a/routes/api.php b/routes/api.php index 8333e8f..df59b37 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\FootnoteController; use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\UserDictionaryController; +use App\Http\Controllers\Api\UserPreferencesController; use App\Http\Controllers\Api\BookViewController; use App\Http\Controllers\Api\PageLinkingController; use App\Http\Controllers\Api\TranslationController; @@ -115,6 +116,10 @@ Route::post('dictionary', [UserDictionaryController::class, 'store']); Route::delete('dictionary/{word}', [UserDictionaryController::class, 'destroy']); + // User preferences (personalisation) + Route::get('preferences', [UserPreferencesController::class, 'show']); + Route::patch('preferences', [UserPreferencesController::class, 'update']); + // Translation pricing config Route::get('translation/pricing', [TranslationController::class, 'pricing']); diff --git a/tests/Feature/UserPreferencesControllerTest.php b/tests/Feature/UserPreferencesControllerTest.php new file mode 100644 index 0000000..dea2326 --- /dev/null +++ b/tests/Feature/UserPreferencesControllerTest.php @@ -0,0 +1,161 @@ +getJson('/api/preferences')->assertStatus(401); + $this->patchJson('/api/preferences', [])->assertStatus(401); + } + + // ==================== Show ==================== + + public function test_show_returns_null_when_no_row(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/preferences') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + public function test_show_returns_existing_preferences(): void + { + $user = User::factory()->create(); + UserPreference::create([ + 'user_id' => $user->id, + 'theme_mode' => 'dark', + 'palette' => 'cobalt', + 'layout' => 'workbench', + ]); + + $this->actingAs($user) + ->getJson('/api/preferences') + ->assertOk() + ->assertJsonPath('data.theme_mode', 'dark') + ->assertJsonPath('data.palette', 'cobalt') + ->assertJsonPath('data.layout', 'workbench'); + } + + public function test_show_does_not_return_other_users_preferences(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + UserPreference::create(['user_id' => $other->id, 'theme_mode' => 'dark']); + + $this->actingAs($user) + ->getJson('/api/preferences') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + // ==================== Update ==================== + + public function test_update_creates_row_on_first_call(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['theme_mode' => 'light']) + ->assertOk() + ->assertJsonPath('data.theme_mode', 'light'); + + $this->assertDatabaseHas('user_preferences', [ + 'user_id' => $user->id, + 'theme_mode' => 'light', + ]); + } + + public function test_update_is_partial_and_preserves_other_fields(): void + { + $user = User::factory()->create(); + UserPreference::create([ + 'user_id' => $user->id, + 'theme_mode' => 'dark', + 'palette' => 'cobalt', + 'layout' => 'workbench', + ]); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['palette' => 'plum']) + ->assertOk() + ->assertJsonPath('data.theme_mode', 'dark') + ->assertJsonPath('data.palette', 'plum') + ->assertJsonPath('data.layout', 'workbench'); + } + + public function test_update_does_not_create_duplicate_row(): void + { + $user = User::factory()->create(); + UserPreference::create(['user_id' => $user->id, 'theme_mode' => 'dark']); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['theme_mode' => 'light']) + ->assertOk(); + + $this->assertDatabaseCount('user_preferences', 1); + } + + public function test_update_rejects_invalid_theme_mode(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['theme_mode' => 'sepia']) + ->assertStatus(422) + ->assertJsonValidationErrors(['theme_mode']); + } + + public function test_update_rejects_invalid_palette(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['palette' => 'rainbow']) + ->assertStatus(422) + ->assertJsonValidationErrors(['palette']); + } + + public function test_update_rejects_invalid_layout(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/preferences', ['layout' => 'spaceship']) + ->assertStatus(422) + ->assertJsonValidationErrors(['layout']); + } + + public function test_update_accepts_empty_payload(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/preferences', []) + ->assertOk(); + + $this->assertDatabaseHas('user_preferences', ['user_id' => $user->id]); + } + + public function test_user_preferences_deleted_when_user_deleted(): void + { + $user = User::factory()->create(); + UserPreference::create(['user_id' => $user->id, 'theme_mode' => 'dark']); + + $user->delete(); + + $this->assertDatabaseMissing('user_preferences', ['user_id' => $user->id]); + } +} diff --git a/wordkeep-client/src/app/services/layout.service.ts b/wordkeep-client/src/app/services/layout.service.ts index 385d8a5..d295f18 100644 --- a/wordkeep-client/src/app/services/layout.service.ts +++ b/wordkeep-client/src/app/services/layout.service.ts @@ -1,4 +1,5 @@ -import { Injectable, signal, effect } from '@angular/core'; +import { Injectable, effect, inject } from '@angular/core'; +import { UserPreferencesService } from './user-preferences.service'; export type Layout = | 'reading-room' @@ -29,39 +30,22 @@ export const LAYOUTS: ReadonlyArray = [ blurb: 'Terminal — prompt line, mono rows, slash palette.' }, ]; -const LAYOUT_KEY = 'wk:layout'; -const DEFAULT_LAYOUT: Layout = 'reading-room'; - @Injectable({ providedIn: 'root' }) export class LayoutService { - private layoutSignal = signal(this.readStored()); - readonly layout = this.layoutSignal.asReadonly(); + private prefs = inject(UserPreferencesService); + + readonly layout = this.prefs.layout; constructor() { effect(() => { - const l = this.layoutSignal(); + const l = this.prefs.layout(); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-layout', l); } - try { - localStorage.setItem(LAYOUT_KEY, l); - } catch { - // localStorage unavailable — ignore. - } }); } setLayout(l: Layout): void { - this.layoutSignal.set(l); - } - - private readStored(): Layout { - try { - const v = localStorage.getItem(LAYOUT_KEY); - if (v && LAYOUTS.some(l => l.id === v)) return v as Layout; - } catch { - // localStorage unavailable. - } - return DEFAULT_LAYOUT; + this.prefs.setLayout(l); } } diff --git a/wordkeep-client/src/app/services/theme.service.ts b/wordkeep-client/src/app/services/theme.service.ts index 99ec6a9..7550ffd 100644 --- a/wordkeep-client/src/app/services/theme.service.ts +++ b/wordkeep-client/src/app/services/theme.service.ts @@ -1,4 +1,5 @@ -import { Injectable, signal, computed, effect } from '@angular/core'; +import { Injectable, signal, computed, effect, inject } from '@angular/core'; +import { UserPreferencesService } from './user-preferences.service'; export type ThemeMode = 'auto' | 'light' | 'dark'; export type ResolvedTheme = 'light' | 'dark'; @@ -27,24 +28,21 @@ export const PALETTES: ReadonlyArray = [ swatch: { bg: '#F4EFE6', accent: '#5B2B5C', detail: '#B8893A' } }, ]; -const MODE_KEY = 'wk:theme'; -const PALETTE_KEY = 'wk:palette'; - @Injectable({ providedIn: 'root' }) export class ThemeService { + private prefs = inject(UserPreferencesService); + private readonly mq: MediaQueryList | null = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null; private systemDark = signal(this.mq?.matches ?? false); - private modeSignal = signal(this.readStoredMode()); - private paletteSignal = signal(this.readStoredPalette()); - readonly mode = this.modeSignal.asReadonly(); - readonly palette = this.paletteSignal.asReadonly(); + readonly mode = this.prefs.themeMode; + readonly palette = this.prefs.palette; readonly resolved = computed(() => { - const m = this.modeSignal(); + const m = this.prefs.themeMode(); if (m === 'auto') return this.systemDark() ? 'dark' : 'light'; return m; }); @@ -54,45 +52,19 @@ export class ThemeService { effect(() => { const theme = this.resolved(); - const palette = this.paletteSignal(); + const palette = this.prefs.palette(); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-palette', palette); } - try { - localStorage.setItem(MODE_KEY, this.modeSignal()); - localStorage.setItem(PALETTE_KEY, palette); - } catch { - // localStorage unavailable — ignore. - } }); } setMode(m: ThemeMode): void { - this.modeSignal.set(m); + this.prefs.setThemeMode(m); } setPalette(p: Palette): void { - this.paletteSignal.set(p); - } - - private readStoredMode(): ThemeMode { - try { - const v = localStorage.getItem(MODE_KEY); - if (v === 'light' || v === 'dark' || v === 'auto') return v; - } catch { - // localStorage unavailable. - } - return 'auto'; - } - - private readStoredPalette(): Palette { - try { - const v = localStorage.getItem(PALETTE_KEY); - if (v && PALETTES.some(p => p.id === v)) return v as Palette; - } catch { - // localStorage unavailable. - } - return 'reading-room'; + this.prefs.setPalette(p); } } diff --git a/wordkeep-client/src/app/services/user-preferences.service.spec.ts b/wordkeep-client/src/app/services/user-preferences.service.spec.ts new file mode 100644 index 0000000..f44be8e --- /dev/null +++ b/wordkeep-client/src/app/services/user-preferences.service.spec.ts @@ -0,0 +1,251 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { UserPreferencesService } from './user-preferences.service'; +import { AuthService } from './auth.service'; +import { environment } from '../../environments/environment'; +import { mockAuthResponse, setupLocalStorageMock, createMockRouter } from '../test-utils'; + +const API_URL = `${environment.apiUrl}/preferences`; + +describe('UserPreferencesService', () => { + let service: UserPreferencesService; + let auth: AuthService; + let httpMock: HttpTestingController; + let storage: ReturnType; + + beforeEach(() => { + storage = setupLocalStorageMock(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: Router, useValue: createMockRouter() }, + ], + }); + httpMock = TestBed.inject(HttpTestingController); + auth = TestBed.inject(AuthService); + }); + + afterEach(() => { + httpMock.verify(); + vi.restoreAllMocks(); + }); + + // ==================== Defaults ==================== + + describe('defaults', () => { + it('initializes signals from localStorage when present', () => { + storage.setItem('wk:theme', 'dark'); + storage.setItem('wk:palette', 'cobalt'); + storage.setItem('wk:layout', 'workbench'); + + service = TestBed.inject(UserPreferencesService); + + expect(service.themeMode()).toBe('dark'); + expect(service.palette()).toBe('cobalt'); + expect(service.layout()).toBe('workbench'); + }); + + it('falls back to defaults when localStorage empty', () => { + service = TestBed.inject(UserPreferencesService); + + expect(service.themeMode()).toBe('auto'); + expect(service.palette()).toBe('reading-room'); + expect(service.layout()).toBe('reading-room'); + }); + + it('ignores invalid localStorage values', () => { + storage.setItem('wk:theme', 'bogus'); + storage.setItem('wk:palette', 'rainbow'); + storage.setItem('wk:layout', 'spaceship'); + + service = TestBed.inject(UserPreferencesService); + + expect(service.themeMode()).toBe('auto'); + expect(service.palette()).toBe('reading-room'); + expect(service.layout()).toBe('reading-room'); + }); + }); + + // ==================== load() on auth ==================== + + describe('load() triggered by authentication', () => { + function authenticate(): void { + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + } + + it('GETs /preferences and hydrates signals from server', () => { + service = TestBed.inject(UserPreferencesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: { theme_mode: 'light', palette: 'plum', layout: 'glass' } }); + + expect(service.themeMode()).toBe('light'); + expect(service.palette()).toBe('plum'); + expect(service.layout()).toBe('glass'); + }); + + it('leaves signals at localStorage values when server returns null', () => { + storage.setItem('wk:theme', 'dark'); + service = TestBed.inject(UserPreferencesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: null }); + + expect(service.themeMode()).toBe('dark'); + }); + + it('mirrors server values to localStorage', () => { + service = TestBed.inject(UserPreferencesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: { theme_mode: 'dark', palette: 'cobalt', layout: 'workbench' } }); + + expect(localStorage.getItem('wk:theme')).toBe('dark'); + expect(localStorage.getItem('wk:palette')).toBe('cobalt'); + expect(localStorage.getItem('wk:layout')).toBe('workbench'); + }); + + it('does not GET when unauthenticated', () => { + service = TestBed.inject(UserPreferencesService); + httpMock.expectNone(API_URL); + }); + + it('ignores invalid values in server response', () => { + service = TestBed.inject(UserPreferencesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: { theme_mode: 'bogus', palette: null, layout: 'spaceship' } }); + + expect(service.themeMode()).toBe('auto'); + expect(service.palette()).toBe('reading-room'); + expect(service.layout()).toBe('reading-room'); + }); + + it('survives a server error', () => { + storage.setItem('wk:theme', 'dark'); + service = TestBed.inject(UserPreferencesService); + authenticate(); + + httpMock.expectOne(API_URL).flush(null, { status: 500, statusText: 'Server Error' }); + + expect(service.themeMode()).toBe('dark'); + }); + }); + + // ==================== setters / patch ==================== + + describe('setters PATCH server when authenticated', () => { + function authenticateAndConsume(): void { + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + const initial = httpMock.match(API_URL); + initial.forEach(req => req.flush({ data: null })); + } + + it('setThemeMode updates signal optimistically', () => { + service = TestBed.inject(UserPreferencesService); + authenticateAndConsume(); + + service.setThemeMode('dark'); + expect(service.themeMode()).toBe('dark'); + + const req = httpMock.expectOne(API_URL); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ theme_mode: 'dark' }); + req.flush({ data: { theme_mode: 'dark', palette: null, layout: null } }); + }); + + it('setPalette PATCHes only palette', () => { + service = TestBed.inject(UserPreferencesService); + authenticateAndConsume(); + + service.setPalette('grove'); + + const req = httpMock.expectOne(API_URL); + expect(req.request.body).toEqual({ palette: 'grove' }); + req.flush({ data: { theme_mode: null, palette: 'grove', layout: null } }); + }); + + it('setLayout PATCHes only layout', () => { + service = TestBed.inject(UserPreferencesService); + authenticateAndConsume(); + + service.setLayout('console'); + + const req = httpMock.expectOne(API_URL); + expect(req.request.body).toEqual({ layout: 'console' }); + req.flush({ data: { theme_mode: null, palette: null, layout: 'console' } }); + }); + + it('mirrors changes to localStorage', () => { + service = TestBed.inject(UserPreferencesService); + authenticateAndConsume(); + + service.setThemeMode('light'); + + expect(localStorage.getItem('wk:theme')).toBe('light'); + httpMock.expectOne(API_URL).flush({ data: { theme_mode: 'light', palette: null, layout: null } }); + }); + }); + + describe('setters when unauthenticated', () => { + it('still updates local signal and localStorage', () => { + service = TestBed.inject(UserPreferencesService); + + service.setThemeMode('dark'); + + expect(service.themeMode()).toBe('dark'); + expect(localStorage.getItem('wk:theme')).toBe('dark'); + httpMock.expectNone(API_URL); + }); + }); + + // ==================== cross-tab storage event ==================== + + describe('cross-tab storage event', () => { + it('re-hydrates theme signal when another tab writes wk:theme', () => { + service = TestBed.inject(UserPreferencesService); + + window.dispatchEvent(new StorageEvent('storage', { key: 'wk:theme', newValue: 'dark' })); + + expect(service.themeMode()).toBe('dark'); + }); + + it('re-hydrates palette signal when another tab writes wk:palette', () => { + service = TestBed.inject(UserPreferencesService); + + window.dispatchEvent(new StorageEvent('storage', { key: 'wk:palette', newValue: 'plum' })); + + expect(service.palette()).toBe('plum'); + }); + + it('re-hydrates layout signal when another tab writes wk:layout', () => { + service = TestBed.inject(UserPreferencesService); + + window.dispatchEvent(new StorageEvent('storage', { key: 'wk:layout', newValue: 'scriptorium' })); + + expect(service.layout()).toBe('scriptorium'); + }); + + it('ignores invalid storage event values', () => { + service = TestBed.inject(UserPreferencesService); + + window.dispatchEvent(new StorageEvent('storage', { key: 'wk:theme', newValue: 'bogus' })); + + expect(service.themeMode()).toBe('auto'); + }); + + it('ignores unrelated storage keys', () => { + service = TestBed.inject(UserPreferencesService); + + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated', newValue: 'dark' })); + + expect(service.themeMode()).toBe('auto'); + }); + }); +}); diff --git a/wordkeep-client/src/app/services/user-preferences.service.ts b/wordkeep-client/src/app/services/user-preferences.service.ts new file mode 100644 index 0000000..e6adfbd --- /dev/null +++ b/wordkeep-client/src/app/services/user-preferences.service.ts @@ -0,0 +1,118 @@ +import { Injectable, inject, signal, effect, untracked } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { catchError, of, tap } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { AuthService } from './auth.service'; +import type { ThemeMode, Palette } from './theme.service'; +import type { Layout } from './layout.service'; + +const THEME_KEY = 'wk:theme'; +const PALETTE_KEY = 'wk:palette'; +const LAYOUT_KEY = 'wk:layout'; + +const THEME_VALUES = new Set(['auto', 'light', 'dark']); +const PALETTE_VALUES = new Set(['reading-room', 'cobalt', 'monastic', 'grove', 'ferrous', 'plum']); +const LAYOUT_VALUES = new Set(['reading-room', 'workbench', 'compendium', 'glass', 'scriptorium', 'console']); + +interface UserPreferencesData { + theme_mode: ThemeMode | null; + palette: Palette | null; + layout: Layout | null; +} + +@Injectable({ providedIn: 'root' }) +export class UserPreferencesService { + private http = inject(HttpClient); + private auth = inject(AuthService); + private apiUrl = `${environment.apiUrl}/preferences`; + + private _themeMode = signal(readStorage(THEME_KEY, THEME_VALUES, 'auto')); + private _palette = signal(readStorage(PALETTE_KEY, PALETTE_VALUES, 'reading-room')); + private _layout = signal(readStorage(LAYOUT_KEY, LAYOUT_VALUES, 'reading-room')); + + readonly themeMode = this._themeMode.asReadonly(); + readonly palette = this._palette.asReadonly(); + readonly layout = this._layout.asReadonly(); + + constructor() { + if (typeof window !== 'undefined') { + window.addEventListener('storage', e => this.handleStorageEvent(e)); + } + effect(() => { + const authed = this.auth.authenticated(); + if (authed) untracked(() => this.load()); + }); + } + + setThemeMode(m: ThemeMode): void { this.applyAndPatch({ theme_mode: m }); } + setPalette(p: Palette): void { this.applyAndPatch({ palette: p }); } + setLayout(l: Layout): void { this.applyAndPatch({ layout: l }); } + + load(): void { + this.http.get<{ data: UserPreferencesData | null }>(this.apiUrl).pipe( + tap(res => { if (res.data) this.applyFromServer(res.data); }), + catchError(() => of(null)), + ).subscribe(); + } + + private applyAndPatch(partial: Partial): void { + if (partial.theme_mode !== undefined && partial.theme_mode !== null) { + this._themeMode.set(partial.theme_mode); + writeStorage(THEME_KEY, partial.theme_mode); + } + if (partial.palette !== undefined && partial.palette !== null) { + this._palette.set(partial.palette); + writeStorage(PALETTE_KEY, partial.palette); + } + if (partial.layout !== undefined && partial.layout !== null) { + this._layout.set(partial.layout); + writeStorage(LAYOUT_KEY, partial.layout); + } + if (this.auth.authenticated()) { + this.http.patch<{ data: UserPreferencesData }>(this.apiUrl, partial).pipe( + catchError(() => of(null)), + ).subscribe(); + } + } + + private applyFromServer(data: UserPreferencesData): void { + if (data.theme_mode && THEME_VALUES.has(data.theme_mode)) { + this._themeMode.set(data.theme_mode); + writeStorage(THEME_KEY, data.theme_mode); + } + if (data.palette && PALETTE_VALUES.has(data.palette)) { + this._palette.set(data.palette); + writeStorage(PALETTE_KEY, data.palette); + } + if (data.layout && LAYOUT_VALUES.has(data.layout)) { + this._layout.set(data.layout); + writeStorage(LAYOUT_KEY, data.layout); + } + } + + private handleStorageEvent(e: StorageEvent): void { + if (e.key === THEME_KEY && e.newValue && THEME_VALUES.has(e.newValue as ThemeMode)) { + this._themeMode.set(e.newValue as ThemeMode); + } else if (e.key === PALETTE_KEY && e.newValue && PALETTE_VALUES.has(e.newValue as Palette)) { + this._palette.set(e.newValue as Palette); + } else if (e.key === LAYOUT_KEY && e.newValue && LAYOUT_VALUES.has(e.newValue as Layout)) { + this._layout.set(e.newValue as Layout); + } + } +} + +function readStorage(key: string, allowed: Set, fallback: T): T { + try { + const v = localStorage.getItem(key); + if (v && allowed.has(v as T)) return v as T; + } catch { + // localStorage unavailable. + } + return fallback; +} + +function writeStorage(key: string, value: string): void { + try { localStorage.setItem(key, value); } catch { + // localStorage unavailable. + } +} From 523236a0bd6394555b3ba5a820e49f2adf7c0b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 12:38:09 +0300 Subject: [PATCH 04/10] [WKP-81] Exclude test-setup from app tsconfig 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) --- wordkeep-client/tsconfig.app.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wordkeep-client/tsconfig.app.json b/wordkeep-client/tsconfig.app.json index 264f459..fd8f002 100644 --- a/wordkeep-client/tsconfig.app.json +++ b/wordkeep-client/tsconfig.app.json @@ -10,6 +10,7 @@ "src/**/*.ts" ], "exclude": [ - "src/**/*.spec.ts" + "src/**/*.spec.ts", + "src/test-setup/**" ] } From 08c7014b093e479602b5638525c5637877a3d87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 12:52:58 +0300 Subject: [PATCH 05/10] [WKP-81] Persist list sort orders server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Api/UserViewStatesController.php | 43 ++++ .../Requests/UpdateUserViewStatesRequest.php | 33 +++ app/Http/Resources/UserViewStateResource.php | 20 ++ app/Models/User.php | 5 + app/Models/UserViewState.php | 34 +++ ...7_094034_create_user_view_states_table.php | 28 ++ routes/api.php | 5 + .../Feature/UserViewStatesControllerTest.php | 165 ++++++++++++ .../src/app/pages/book-view/book-view.spec.ts | 28 +- .../src/app/pages/book-view/book-view.ts | 11 +- .../src/app/pages/documents/documents.ts | 11 +- .../src/app/pages/extraction/extraction.ts | 11 +- .../app/pages/page-linking/page-linking.ts | 11 +- .../app/pages/translation/translation.spec.ts | 29 ++- .../src/app/pages/translation/translation.ts | 11 +- .../services/user-view-states.service.spec.ts | 241 ++++++++++++++++++ .../app/services/user-view-states.service.ts | 115 +++++++++ 17 files changed, 740 insertions(+), 61 deletions(-) create mode 100644 app/Http/Controllers/Api/UserViewStatesController.php create mode 100644 app/Http/Requests/UpdateUserViewStatesRequest.php create mode 100644 app/Http/Resources/UserViewStateResource.php create mode 100644 app/Models/UserViewState.php create mode 100644 database/migrations/2026_05_27_094034_create_user_view_states_table.php create mode 100644 tests/Feature/UserViewStatesControllerTest.php create mode 100644 wordkeep-client/src/app/services/user-view-states.service.spec.ts create mode 100644 wordkeep-client/src/app/services/user-view-states.service.ts diff --git a/app/Http/Controllers/Api/UserViewStatesController.php b/app/Http/Controllers/Api/UserViewStatesController.php new file mode 100644 index 0000000..edb47d8 --- /dev/null +++ b/app/Http/Controllers/Api/UserViewStatesController.php @@ -0,0 +1,43 @@ +viewState; + + return response()->json([ + 'data' => $state ? new UserViewStateResource($state) : null, + ]); + } + + /** + * Update user view states. + * + * Partial update — only sort columns present in the request are written. + * Creates the row on first call. + */ + public function update(UpdateUserViewStatesRequest $request): JsonResponse + { + $state = Auth::user()->viewState()->updateOrCreate( + ['user_id' => Auth::id()], + $request->validated() + ); + + return response()->json(['data' => new UserViewStateResource($state)]); + } +} diff --git a/app/Http/Requests/UpdateUserViewStatesRequest.php b/app/Http/Requests/UpdateUserViewStatesRequest.php new file mode 100644 index 0000000..5db765a --- /dev/null +++ b/app/Http/Requests/UpdateUserViewStatesRequest.php @@ -0,0 +1,33 @@ + $this->sort_documents, + 'sort_book_view' => $this->sort_book_view, + 'sort_translation' => $this->sort_translation, + 'sort_extraction' => $this->sort_extraction, + 'sort_page_linking' => $this->sort_page_linking, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f31f526..afa0d49 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -35,6 +35,11 @@ public function preference(): HasOne return $this->hasOne(UserPreference::class); } + public function viewState(): HasOne + { + return $this->hasOne(UserViewState::class); + } + public function isVerified(): bool { return !is_null($this->email_verified_at); diff --git a/app/Models/UserViewState.php b/app/Models/UserViewState.php new file mode 100644 index 0000000..2fd4b23 --- /dev/null +++ b/app/Models/UserViewState.php @@ -0,0 +1,34 @@ + 'array', + 'sort_book_view' => 'array', + 'sort_translation' => 'array', + 'sort_extraction' => 'array', + 'sort_page_linking' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/migrations/2026_05_27_094034_create_user_view_states_table.php b/database/migrations/2026_05_27_094034_create_user_view_states_table.php new file mode 100644 index 0000000..28c61f5 --- /dev/null +++ b/database/migrations/2026_05_27_094034_create_user_view_states_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->json('sort_documents')->nullable(); + $table->json('sort_book_view')->nullable(); + $table->json('sort_translation')->nullable(); + $table->json('sort_extraction')->nullable(); + $table->json('sort_page_linking')->nullable(); + $table->timestamps(); + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_view_states'); + } +}; diff --git a/routes/api.php b/routes/api.php index df59b37..c365aec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\UserDictionaryController; use App\Http\Controllers\Api\UserPreferencesController; +use App\Http\Controllers\Api\UserViewStatesController; use App\Http\Controllers\Api\BookViewController; use App\Http\Controllers\Api\PageLinkingController; use App\Http\Controllers\Api\TranslationController; @@ -120,6 +121,10 @@ Route::get('preferences', [UserPreferencesController::class, 'show']); Route::patch('preferences', [UserPreferencesController::class, 'update']); + // User view states (per-page sort orders) + Route::get('view-states', [UserViewStatesController::class, 'show']); + Route::patch('view-states', [UserViewStatesController::class, 'update']); + // Translation pricing config Route::get('translation/pricing', [TranslationController::class, 'pricing']); diff --git a/tests/Feature/UserViewStatesControllerTest.php b/tests/Feature/UserViewStatesControllerTest.php new file mode 100644 index 0000000..10e95d8 --- /dev/null +++ b/tests/Feature/UserViewStatesControllerTest.php @@ -0,0 +1,165 @@ + 'title', 'dir' => 'asc']; + private const SORT_DESC = ['sort' => 'updated_at', 'dir' => 'desc']; + + // ==================== Authentication ==================== + + public function test_unauthenticated_cannot_access_endpoints(): void + { + $this->getJson('/api/view-states')->assertStatus(401); + $this->patchJson('/api/view-states', [])->assertStatus(401); + } + + // ==================== Show ==================== + + public function test_show_returns_null_when_no_row(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/view-states') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + public function test_show_returns_existing_state(): void + { + $user = User::factory()->create(); + UserViewState::create([ + 'user_id' => $user->id, + 'sort_documents' => self::SORT_ASC, + 'sort_book_view' => self::SORT_DESC, + ]); + + $this->actingAs($user) + ->getJson('/api/view-states') + ->assertOk() + ->assertJsonPath('data.sort_documents', self::SORT_ASC) + ->assertJsonPath('data.sort_book_view', self::SORT_DESC) + ->assertJsonPath('data.sort_translation', null); + } + + public function test_show_does_not_return_other_users_state(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + UserViewState::create(['user_id' => $other->id, 'sort_documents' => self::SORT_ASC]); + + $this->actingAs($user) + ->getJson('/api/view-states') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + // ==================== Update ==================== + + public function test_update_creates_row_on_first_call(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_documents' => self::SORT_ASC]) + ->assertOk() + ->assertJsonPath('data.sort_documents', self::SORT_ASC); + + $this->assertDatabaseHas('user_view_states', ['user_id' => $user->id]); + } + + public function test_update_is_partial_and_preserves_other_columns(): void + { + $user = User::factory()->create(); + UserViewState::create([ + 'user_id' => $user->id, + 'sort_documents' => self::SORT_ASC, + 'sort_book_view' => self::SORT_DESC, + ]); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_translation' => self::SORT_ASC]) + ->assertOk() + ->assertJsonPath('data.sort_documents', self::SORT_ASC) + ->assertJsonPath('data.sort_book_view', self::SORT_DESC) + ->assertJsonPath('data.sort_translation', self::SORT_ASC); + } + + public function test_update_does_not_create_duplicate_row(): void + { + $user = User::factory()->create(); + UserViewState::create(['user_id' => $user->id, 'sort_documents' => self::SORT_ASC]); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_documents' => self::SORT_DESC]) + ->assertOk(); + + $this->assertDatabaseCount('user_view_states', 1); + } + + public function test_update_rejects_invalid_dir(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_documents' => ['sort' => 'title', 'dir' => 'sideways']]) + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_documents.dir']); + } + + public function test_update_rejects_missing_sort(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_documents' => ['dir' => 'asc']]) + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_documents.sort']); + } + + public function test_update_rejects_extra_keys_in_sort_payload(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/view-states', ['sort_documents' => ['sort' => 'title', 'dir' => 'asc', 'evil' => 1]]) + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_documents']); + } + + public function test_update_accepts_all_five_columns_at_once(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->patchJson('/api/view-states', [ + 'sort_documents' => self::SORT_ASC, + 'sort_book_view' => self::SORT_DESC, + 'sort_translation' => self::SORT_ASC, + 'sort_extraction' => self::SORT_DESC, + 'sort_page_linking' => self::SORT_ASC, + ]) + ->assertOk(); + + $this->assertDatabaseHas('user_view_states', ['user_id' => $user->id]); + } + + public function test_user_view_state_deleted_when_user_deleted(): void + { + $user = User::factory()->create(); + UserViewState::create(['user_id' => $user->id, 'sort_documents' => self::SORT_ASC]); + + $user->delete(); + + $this->assertDatabaseMissing('user_view_states', ['user_id' => $user->id]); + } +} diff --git a/wordkeep-client/src/app/pages/book-view/book-view.spec.ts b/wordkeep-client/src/app/pages/book-view/book-view.spec.ts index 189f397..1693389 100644 --- a/wordkeep-client/src/app/pages/book-view/book-view.spec.ts +++ b/wordkeep-client/src/app/pages/book-view/book-view.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; +import { signal } from '@angular/core'; import { of, throwError } from 'rxjs'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { BookViewComponent } from './book-view'; @@ -7,6 +8,8 @@ import { DocumentService } from '../../services/document.service'; import { BookViewService } from '../../services/book-view.service'; import { AuthService } from '../../services/auth.service'; import { ConfirmationService } from '../../services/confirmation.service'; +import { UserViewStatesService } from '../../services/user-view-states.service'; +import { SortOptions } from '../../models/document.model'; import { mockBookViewDocument, mockBookViewPages } from '../../test-utils'; const mockPaginated = { @@ -24,11 +27,16 @@ describe('BookViewComponent', () => { let mockBookViewService: any; let mockAuthService: any; let mockConfirmationService: any; + let mockViewStates: any; + let sortSignal: ReturnType>; let router: Router; function setup() { - // Sort persistence reads/writes localStorage; clear between tests. - localStorage.clear(); + sortSignal = signal({ sort: 'updated_at', dir: 'desc' }); + mockViewStates = { + sortFor: vi.fn().mockReturnValue(sortSignal.asReadonly()), + setSort: vi.fn((_key: string, opts: SortOptions) => sortSignal.set(opts)), + }; mockDocumentService = { getDocumentsForBookView: vi.fn().mockReturnValue(of(mockPaginated)), getBookViewPages: vi.fn().mockReturnValue(of(mockBookViewPages)), @@ -57,6 +65,7 @@ describe('BookViewComponent', () => { { provide: BookViewService, useValue: mockBookViewService }, { provide: AuthService, useValue: mockAuthService }, { provide: ConfirmationService, useValue: mockConfirmationService }, + { provide: UserViewStatesService, useValue: mockViewStates }, ], }); @@ -251,26 +260,21 @@ describe('BookViewComponent', () => { // ==================== Sort persistence ==================== describe('sort persistence', () => { - it('restores_stored_sort_on_init', () => { - // Run setup first (which clears localStorage), then seed and re-create - // a fresh component so its constructor reads our stored value. + it('uses_service_sort_on_init', () => { setup(); - localStorage.setItem('wk:sort:book-view', JSON.stringify({ sort: 'title', dir: 'asc' })); - const f = TestBed.createComponent(BookViewComponent); - f.detectChanges(); + sortSignal.set({ sort: 'title', dir: 'asc' }); + fixture.detectChanges(); expect(mockDocumentService.getDocumentsForBookView).toHaveBeenCalledWith(1, { sort: 'title', dir: 'asc' }); }); - it('writes_chosen_sort_to_localStorage', () => { + it('delegates_sort_change_to_service', () => { setup(); fixture.detectChanges(); component.onSortChange({ sort: 'title', dir: 'desc' }); - expect(localStorage.getItem('wk:sort:book-view')).toBe( - JSON.stringify({ sort: 'title', dir: 'desc' }) - ); + expect(mockViewStates.setSort).toHaveBeenCalledWith('book-view', { sort: 'title', dir: 'desc' }); }); }); }); diff --git a/wordkeep-client/src/app/pages/book-view/book-view.ts b/wordkeep-client/src/app/pages/book-view/book-view.ts index a54af32..dffb6aa 100644 --- a/wordkeep-client/src/app/pages/book-view/book-view.ts +++ b/wordkeep-client/src/app/pages/book-view/book-view.ts @@ -10,10 +10,7 @@ import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; import { LANGUAGE_NAMES as LANGUAGES } from '../../utils/language-names'; -import { readStoredSort, writeStoredSort } from '../../utils/sort-persistence'; - -const SORT_KEY = 'wk:sort:book-view'; -const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; +import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-book-view', @@ -27,6 +24,7 @@ export class BookViewComponent implements OnInit, OnDestroy { private bookViewService = inject(BookViewService); private confirmationService = inject(ConfirmationService); private router = inject(Router); + private viewStates = inject(UserViewStatesService); documents = signal([]); loading = signal(true); @@ -34,7 +32,7 @@ export class BookViewComponent implements OnInit, OnDestroy { currentPage = signal(1); lastPage = signal(1); total = signal(0); - sort = signal(readStoredSort(SORT_KEY, DEFAULT_SORT)); + sort = this.viewStates.sortFor('book-view'); exportDropdownOpen = signal(null); exportingDocs = new Set(); @@ -50,8 +48,7 @@ export class BookViewComponent implements OnInit, OnDestroy { } onSortChange(opts: SortOptions) { - this.sort.set(opts); - writeStoredSort(SORT_KEY, opts); + this.viewStates.setSort('book-view', opts); this.loadDocuments(1); } diff --git a/wordkeep-client/src/app/pages/documents/documents.ts b/wordkeep-client/src/app/pages/documents/documents.ts index 1ba9941..5dc14ac 100644 --- a/wordkeep-client/src/app/pages/documents/documents.ts +++ b/wordkeep-client/src/app/pages/documents/documents.ts @@ -8,10 +8,7 @@ import { RenameService } from '../../services/rename.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; -import { readStoredSort, writeStoredSort } from '../../utils/sort-persistence'; - -const SORT_KEY = 'wk:sort:documents'; -const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; +import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-documents', @@ -24,6 +21,7 @@ export class DocumentsComponent implements OnInit { private documentService = inject(DocumentService); private confirmationService = inject(ConfirmationService); private renameService = inject(RenameService); + private viewStates = inject(UserViewStatesService); documents = signal([]); loading = signal(true); @@ -36,15 +34,14 @@ export class DocumentsComponent implements OnInit { currentPage = signal(1); lastPage = signal(1); total = signal(0); - sort = signal(readStoredSort(SORT_KEY, DEFAULT_SORT)); + sort = this.viewStates.sortFor('documents'); ngOnInit() { this.loadDocuments(); } onSortChange(opts: SortOptions) { - this.sort.set(opts); - writeStoredSort(SORT_KEY, opts); + this.viewStates.setSort('documents', opts); this.loadDocuments(1); } diff --git a/wordkeep-client/src/app/pages/extraction/extraction.ts b/wordkeep-client/src/app/pages/extraction/extraction.ts index da783e9..640e314 100644 --- a/wordkeep-client/src/app/pages/extraction/extraction.ts +++ b/wordkeep-client/src/app/pages/extraction/extraction.ts @@ -9,10 +9,7 @@ import { ConfirmationService } from '../../services/confirmation.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; -import { readStoredSort, writeStoredSort } from '../../utils/sort-persistence'; - -const SORT_KEY = 'wk:sort:extraction'; -const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; +import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-extraction', @@ -25,6 +22,7 @@ export class ExtractionComponent implements OnInit, OnDestroy { private documentService = inject(DocumentService); private bulkOcrService = inject(BulkOcrService); private confirmationService = inject(ConfirmationService); + private viewStates = inject(UserViewStatesService); private pollSubscription?: Subscription; documents = signal([]); @@ -34,7 +32,7 @@ export class ExtractionComponent implements OnInit, OnDestroy { currentPage = signal(1); lastPage = signal(1); total = signal(0); - sort = signal(readStoredSort(SORT_KEY, DEFAULT_SORT)); + sort = this.viewStates.sortFor('extraction'); ngOnInit() { this.loadDocuments(); @@ -45,8 +43,7 @@ export class ExtractionComponent implements OnInit, OnDestroy { } onSortChange(opts: SortOptions) { - this.sort.set(opts); - writeStoredSort(SORT_KEY, opts); + this.viewStates.setSort('extraction', opts); this.loadDocuments(1); } diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.ts b/wordkeep-client/src/app/pages/page-linking/page-linking.ts index 186feae..a7041aa 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.ts +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.ts @@ -5,10 +5,7 @@ import { DocumentService } from '../../services/document.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; -import { readStoredSort, writeStoredSort } from '../../utils/sort-persistence'; - -const SORT_KEY = 'wk:sort:page-linking'; -const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; +import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-page-linking', @@ -19,6 +16,7 @@ const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; }) export class PageLinkingComponent implements OnInit { private documentService = inject(DocumentService); + private viewStates = inject(UserViewStatesService); documents = signal([]); loading = signal(true); @@ -27,15 +25,14 @@ export class PageLinkingComponent implements OnInit { currentPage = signal(1); lastPage = signal(1); total = signal(0); - sort = signal(readStoredSort(SORT_KEY, DEFAULT_SORT)); + sort = this.viewStates.sortFor('page-linking'); ngOnInit() { this.loadDocuments(); } onSortChange(opts: SortOptions) { - this.sort.set(opts); - writeStoredSort(SORT_KEY, opts); + this.viewStates.setSort('page-linking', opts); this.loadDocuments(1); } diff --git a/wordkeep-client/src/app/pages/translation/translation.spec.ts b/wordkeep-client/src/app/pages/translation/translation.spec.ts index dea3f24..d192d05 100644 --- a/wordkeep-client/src/app/pages/translation/translation.spec.ts +++ b/wordkeep-client/src/app/pages/translation/translation.spec.ts @@ -8,7 +8,8 @@ import { TranslationService } from '../../services/translation.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { NewTranslationDialogService } from '../../services/new-translation-dialog.service'; import { AuthService } from '../../services/auth.service'; -import { DocumentTranslation } from '../../models/document.model'; +import { UserViewStatesService } from '../../services/user-view-states.service'; +import { DocumentTranslation, SortOptions } from '../../models/document.model'; const makeTranslation = (overrides: Partial = {}): DocumentTranslation => ({ id: 1, @@ -43,12 +44,16 @@ describe('TranslationComponent', () => { let mockConfirmationService: any; let mockNewTranslationDialog: any; let mockAuthService: any; + let mockViewStates: any; + let sortSignal: ReturnType>; let router: Router; function setup() { - // Sort persistence writes to localStorage on change — clear between tests - // so a sibling spec's onSortChange doesn't bleed into the next init read. - localStorage.clear(); + sortSignal = signal({ sort: 'updated_at', dir: 'desc' }); + mockViewStates = { + sortFor: vi.fn().mockReturnValue(sortSignal.asReadonly()), + setSort: vi.fn((_key: string, opts: SortOptions) => sortSignal.set(opts)), + }; mockTranslationService = { getUserTranslations: vi.fn().mockReturnValue(of(mockPaginated)), estimateBulkTranslation: vi.fn().mockReturnValue(of({ pages_to_translate: 3, total_chars: 12000 })), @@ -81,6 +86,7 @@ describe('TranslationComponent', () => { { provide: ConfirmationService, useValue: mockConfirmationService }, { provide: NewTranslationDialogService, useValue: mockNewTranslationDialog }, { provide: AuthService, useValue: mockAuthService }, + { provide: UserViewStatesService, useValue: mockViewStates }, ], }); @@ -321,23 +327,18 @@ describe('TranslationComponent', () => { // ==================== Sort persistence ==================== describe('sort persistence', () => { - it('restores_stored_sort_on_init', () => { - // Run setup first (which clears localStorage), then seed and re-create - // a fresh component so its constructor reads our stored value. + it('uses_service_sort_on_init', () => { setup(); - localStorage.setItem('wk:sort:translation', JSON.stringify({ sort: 'title', dir: 'asc' })); - const f = TestBed.createComponent(TranslationComponent); - f.detectChanges(); + sortSignal.set({ sort: 'title', dir: 'asc' }); + fixture.detectChanges(); expect(mockTranslationService.getUserTranslations).toHaveBeenCalledWith(1, { sort: 'title', dir: 'asc' }); }); - it('writes_chosen_sort_to_localStorage', () => { + it('delegates_sort_change_to_service', () => { component.onSortChange({ sort: 'title', dir: 'desc' }); - expect(localStorage.getItem('wk:sort:translation')).toBe( - JSON.stringify({ sort: 'title', dir: 'desc' }) - ); + expect(mockViewStates.setSort).toHaveBeenCalledWith('translation', { sort: 'title', dir: 'desc' }); }); }); }); diff --git a/wordkeep-client/src/app/pages/translation/translation.ts b/wordkeep-client/src/app/pages/translation/translation.ts index bac07b5..4aedc98 100644 --- a/wordkeep-client/src/app/pages/translation/translation.ts +++ b/wordkeep-client/src/app/pages/translation/translation.ts @@ -10,10 +10,7 @@ import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; import { estimateTranslationCost } from '../../utils/translation-cost'; import { languageDisplayName } from '../../utils/language-names'; -import { readStoredSort, writeStoredSort } from '../../utils/sort-persistence'; - -const SORT_KEY = 'wk:sort:translation'; -const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; +import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-translation', @@ -27,6 +24,7 @@ export class TranslationComponent implements OnInit, OnDestroy { private confirmationService = inject(ConfirmationService); private newTranslationDialog = inject(NewTranslationDialogService); private router = inject(Router); + private viewStates = inject(UserViewStatesService); private pollSubscription?: Subscription; private dispatchedIds = new Set(); @@ -37,7 +35,7 @@ export class TranslationComponent implements OnInit, OnDestroy { currentPage = signal(1); lastPage = signal(1); total = signal(0); - sort = signal(readStoredSort(SORT_KEY, DEFAULT_SORT)); + sort = this.viewStates.sortFor('translation'); ngOnInit() { this.loadTranslations(); @@ -49,8 +47,7 @@ export class TranslationComponent implements OnInit, OnDestroy { } onSortChange(opts: SortOptions) { - this.sort.set(opts); - writeStoredSort(SORT_KEY, opts); + this.viewStates.setSort('translation', opts); this.loadTranslations(1); } diff --git a/wordkeep-client/src/app/services/user-view-states.service.spec.ts b/wordkeep-client/src/app/services/user-view-states.service.spec.ts new file mode 100644 index 0000000..b9a8df2 --- /dev/null +++ b/wordkeep-client/src/app/services/user-view-states.service.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { UserViewStatesService } from './user-view-states.service'; +import { AuthService } from './auth.service'; +import { environment } from '../../environments/environment'; +import { mockAuthResponse, setupLocalStorageMock, createMockRouter } from '../test-utils'; +import { SortOptions } from '../models/document.model'; + +const API_URL = `${environment.apiUrl}/view-states`; +const SORT_ASC: SortOptions = { sort: 'title', dir: 'asc' }; +const SORT_DESC: SortOptions = { sort: 'updated_at', dir: 'desc' }; + +describe('UserViewStatesService', () => { + let service: UserViewStatesService; + let auth: AuthService; + let httpMock: HttpTestingController; + let storage: ReturnType; + + beforeEach(() => { + storage = setupLocalStorageMock(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: Router, useValue: createMockRouter() }, + ], + }); + httpMock = TestBed.inject(HttpTestingController); + auth = TestBed.inject(AuthService); + }); + + afterEach(() => { + httpMock.verify(); + vi.restoreAllMocks(); + }); + + // ==================== Defaults ==================== + + describe('defaults', () => { + it('initializes signals from localStorage when present', () => { + storage.setItem('wk:sort:documents', JSON.stringify(SORT_ASC)); + + service = TestBed.inject(UserViewStatesService); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + }); + + it('falls back to default sort when localStorage empty', () => { + service = TestBed.inject(UserViewStatesService); + + expect(service.sortFor('documents')()).toEqual({ sort: 'updated_at', dir: 'desc' }); + }); + + it('ignores invalid localStorage shape', () => { + storage.setItem('wk:sort:documents', JSON.stringify({ sort: 'title', dir: 'sideways' })); + + service = TestBed.inject(UserViewStatesService); + + expect(service.sortFor('documents')()).toEqual({ sort: 'updated_at', dir: 'desc' }); + }); + + it('keeps per-page sorts independent', () => { + storage.setItem('wk:sort:documents', JSON.stringify(SORT_ASC)); + storage.setItem('wk:sort:translation', JSON.stringify(SORT_DESC)); + + service = TestBed.inject(UserViewStatesService); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + expect(service.sortFor('translation')()).toEqual(SORT_DESC); + }); + }); + + // ==================== load() on auth ==================== + + describe('load() triggered by authentication', () => { + function authenticate(): void { + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + } + + it('GETs /view-states and hydrates signals from server', () => { + service = TestBed.inject(UserViewStatesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ + data: { + sort_documents: SORT_ASC, + sort_book_view: SORT_DESC, + sort_translation: SORT_ASC, + sort_extraction: SORT_DESC, + sort_page_linking: SORT_ASC, + }, + }); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + expect(service.sortFor('book-view')()).toEqual(SORT_DESC); + expect(service.sortFor('translation')()).toEqual(SORT_ASC); + expect(service.sortFor('extraction')()).toEqual(SORT_DESC); + expect(service.sortFor('page-linking')()).toEqual(SORT_ASC); + }); + + it('leaves signals at defaults when server returns null', () => { + storage.setItem('wk:sort:documents', JSON.stringify(SORT_ASC)); + service = TestBed.inject(UserViewStatesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: null }); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + }); + + it('mirrors server values to localStorage', () => { + service = TestBed.inject(UserViewStatesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ data: { sort_documents: SORT_ASC, sort_book_view: null, sort_translation: null, sort_extraction: null, sort_page_linking: null } }); + + expect(localStorage.getItem('wk:sort:documents')).toBe(JSON.stringify(SORT_ASC)); + }); + + it('does not GET when unauthenticated', () => { + service = TestBed.inject(UserViewStatesService); + httpMock.expectNone(API_URL); + }); + + it('ignores invalid server values', () => { + service = TestBed.inject(UserViewStatesService); + authenticate(); + + httpMock.expectOne(API_URL).flush({ + data: { sort_documents: { sort: 'title', dir: 'sideways' }, sort_book_view: null, sort_translation: null, sort_extraction: null, sort_page_linking: null }, + }); + + expect(service.sortFor('documents')()).toEqual({ sort: 'updated_at', dir: 'desc' }); + }); + + it('survives server error', () => { + storage.setItem('wk:sort:documents', JSON.stringify(SORT_ASC)); + service = TestBed.inject(UserViewStatesService); + authenticate(); + + httpMock.expectOne(API_URL).flush(null, { status: 500, statusText: 'Server Error' }); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + }); + }); + + // ==================== setSort ==================== + + describe('setSort()', () => { + function authenticateAndConsume(): void { + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + httpMock.match(API_URL).forEach(req => req.flush({ data: null })); + } + + it('updates the signal optimistically', () => { + service = TestBed.inject(UserViewStatesService); + authenticateAndConsume(); + + service.setSort('documents', SORT_ASC); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + const req = httpMock.expectOne(API_URL); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ sort_documents: SORT_ASC }); + req.flush({ data: { sort_documents: SORT_ASC, sort_book_view: null, sort_translation: null, sort_extraction: null, sort_page_linking: null } }); + }); + + it('mirrors to localStorage', () => { + service = TestBed.inject(UserViewStatesService); + authenticateAndConsume(); + + service.setSort('book-view', SORT_DESC); + + expect(localStorage.getItem('wk:sort:book-view')).toBe(JSON.stringify(SORT_DESC)); + httpMock.expectOne(API_URL).flush({ data: { sort_documents: null, sort_book_view: SORT_DESC, sort_translation: null, sort_extraction: null, sort_page_linking: null } }); + }); + + it('PATCHes only the touched column', () => { + service = TestBed.inject(UserViewStatesService); + authenticateAndConsume(); + + service.setSort('extraction', SORT_ASC); + + const req = httpMock.expectOne(API_URL); + expect(req.request.body).toEqual({ sort_extraction: SORT_ASC }); + req.flush({ data: { sort_documents: null, sort_book_view: null, sort_translation: null, sort_extraction: SORT_ASC, sort_page_linking: null } }); + }); + + it('does not PATCH when unauthenticated', () => { + service = TestBed.inject(UserViewStatesService); + + service.setSort('documents', SORT_ASC); + + expect(service.sortFor('documents')()).toEqual(SORT_ASC); + expect(localStorage.getItem('wk:sort:documents')).toBe(JSON.stringify(SORT_ASC)); + httpMock.expectNone(API_URL); + }); + }); + + // ==================== cross-tab storage event ==================== + + describe('cross-tab storage event', () => { + it('re-hydrates a signal when another tab writes the corresponding key', () => { + service = TestBed.inject(UserViewStatesService); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'wk:sort:translation', + newValue: JSON.stringify(SORT_ASC), + })); + + expect(service.sortFor('translation')()).toEqual(SORT_ASC); + }); + + it('ignores unrelated storage keys', () => { + service = TestBed.inject(UserViewStatesService); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'wk:theme', + newValue: 'dark', + })); + + expect(service.sortFor('documents')()).toEqual({ sort: 'updated_at', dir: 'desc' }); + }); + + it('ignores malformed JSON in storage event', () => { + service = TestBed.inject(UserViewStatesService); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'wk:sort:documents', + newValue: 'not-json', + })); + + expect(service.sortFor('documents')()).toEqual({ sort: 'updated_at', dir: 'desc' }); + }); + }); +}); diff --git a/wordkeep-client/src/app/services/user-view-states.service.ts b/wordkeep-client/src/app/services/user-view-states.service.ts new file mode 100644 index 0000000..9536e64 --- /dev/null +++ b/wordkeep-client/src/app/services/user-view-states.service.ts @@ -0,0 +1,115 @@ +import { Injectable, inject, signal, effect, untracked, Signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { catchError, of } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { AuthService } from './auth.service'; +import { SortOptions } from '../models/document.model'; +import { readStoredSort, writeStoredSort } from '../utils/sort-persistence'; + +export type ViewStateKey = + | 'documents' + | 'book-view' + | 'translation' + | 'extraction' + | 'page-linking'; + +const STORAGE_KEYS: Record = { + 'documents': 'wk:sort:documents', + 'book-view': 'wk:sort:book-view', + 'translation': 'wk:sort:translation', + 'extraction': 'wk:sort:extraction', + 'page-linking': 'wk:sort:page-linking', +}; + +const COLUMNS: Record = { + 'documents': 'sort_documents', + 'book-view': 'sort_book_view', + 'translation': 'sort_translation', + 'extraction': 'sort_extraction', + 'page-linking': 'sort_page_linking', +}; + +interface UserViewStatesData { + sort_documents: SortOptions | null; + sort_book_view: SortOptions | null; + sort_translation: SortOptions | null; + sort_extraction: SortOptions | null; + sort_page_linking: SortOptions | null; +} + +const DEFAULT_SORT: SortOptions = { sort: 'updated_at', dir: 'desc' }; + +@Injectable({ providedIn: 'root' }) +export class UserViewStatesService { + private http = inject(HttpClient); + private auth = inject(AuthService); + private apiUrl = `${environment.apiUrl}/view-states`; + + private signals: Record>> = { + 'documents': signal(readStoredSort(STORAGE_KEYS['documents'], DEFAULT_SORT)), + 'book-view': signal(readStoredSort(STORAGE_KEYS['book-view'], DEFAULT_SORT)), + 'translation': signal(readStoredSort(STORAGE_KEYS['translation'], DEFAULT_SORT)), + 'extraction': signal(readStoredSort(STORAGE_KEYS['extraction'], DEFAULT_SORT)), + 'page-linking': signal(readStoredSort(STORAGE_KEYS['page-linking'], DEFAULT_SORT)), + }; + + constructor() { + if (typeof window !== 'undefined') { + window.addEventListener('storage', e => this.handleStorageEvent(e)); + } + effect(() => { + if (this.auth.authenticated()) untracked(() => this.load()); + }); + } + + sortFor(key: ViewStateKey): Signal { + return this.signals[key].asReadonly(); + } + + setSort(key: ViewStateKey, opts: SortOptions): void { + this.signals[key].set(opts); + writeStoredSort(STORAGE_KEYS[key], opts); + if (this.auth.authenticated()) { + const body = { [COLUMNS[key]]: opts }; + this.http.patch<{ data: UserViewStatesData }>(this.apiUrl, body).pipe( + catchError(() => of(null)), + ).subscribe(); + } + } + + load(): void { + this.http.get<{ data: UserViewStatesData | null }>(this.apiUrl).pipe( + catchError(() => of(null)), + ).subscribe(res => { + if (!res?.data) return; + for (const key of Object.keys(STORAGE_KEYS) as ViewStateKey[]) { + const value = res.data[COLUMNS[key]]; + if (isValidSort(value)) { + this.signals[key].set(value); + writeStoredSort(STORAGE_KEYS[key], value); + } + } + }); + } + + private handleStorageEvent(e: StorageEvent): void { + if (!e.key || !e.newValue) return; + for (const key of Object.keys(STORAGE_KEYS) as ViewStateKey[]) { + if (e.key === STORAGE_KEYS[key]) { + try { + const parsed = JSON.parse(e.newValue); + if (isValidSort(parsed)) this.signals[key].set(parsed); + } catch { + // ignore malformed storage event + } + return; + } + } + } +} + +function isValidSort(v: unknown): v is SortOptions { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o['sort'] === 'string' && (o['dir'] === 'asc' || o['dir'] === 'desc'); +} From f8470b3da50db8e95e3310e4817a5e34888cc377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 13:13:03 +0300 Subject: [PATCH 06/10] [WKP-81] Persist spell-check ignored words server-side 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) --- .../Api/UserIgnoredWordsController.php | 60 ++++++ .../Requests/StoreUserIgnoredWordRequest.php | 28 +++ .../Resources/UserIgnoredWordResource.php | 19 ++ app/Models/User.php | 5 + app/Models/UserIgnoredWord.php | 19 ++ ...095414_create_user_ignored_words_table.php | 26 +++ routes/api.php | 6 + .../UserIgnoredWordsControllerTest.php | 191 ++++++++++++++++++ .../extraction-reader/extraction-reader.ts | 2 +- .../translation-reader/translation-reader.ts | 2 +- .../app/services/spell-check.service.spec.ts | 56 +++-- .../src/app/services/spell-check.service.ts | 33 ++- .../user-ignored-words.service.spec.ts | 165 +++++++++++++++ .../services/user-ignored-words.service.ts | 86 ++++++++ 14 files changed, 661 insertions(+), 37 deletions(-) create mode 100644 app/Http/Controllers/Api/UserIgnoredWordsController.php create mode 100644 app/Http/Requests/StoreUserIgnoredWordRequest.php create mode 100644 app/Http/Resources/UserIgnoredWordResource.php create mode 100644 app/Models/UserIgnoredWord.php create mode 100644 database/migrations/2026_05_27_095414_create_user_ignored_words_table.php create mode 100644 tests/Feature/UserIgnoredWordsControllerTest.php create mode 100644 wordkeep-client/src/app/services/user-ignored-words.service.spec.ts create mode 100644 wordkeep-client/src/app/services/user-ignored-words.service.ts diff --git a/app/Http/Controllers/Api/UserIgnoredWordsController.php b/app/Http/Controllers/Api/UserIgnoredWordsController.php new file mode 100644 index 0000000..d1b08ac --- /dev/null +++ b/app/Http/Controllers/Api/UserIgnoredWordsController.php @@ -0,0 +1,60 @@ +ignoredWords()->orderBy('word')->get(); + + return UserIgnoredWordResource::collection($words); + } + + /** + * Add a word to the ignored list. + * + * Words are automatically lowercased. Returns 201 for new entries, 200 for duplicates. + */ + public function store(StoreUserIgnoredWordRequest $request): JsonResponse + { + $word = $request->validated('word'); + $language = $request->validated('language'); + + $entry = Auth::user()->ignoredWords()->firstOrCreate(['word' => $word, 'language' => $language]); + + return response()->json( + ['data' => new UserIgnoredWordResource($entry)], + $entry->wasRecentlyCreated ? 201 : 200 + ); + } + + /** + * Remove an ignored word. + * + * Removes a word from the user's ignored list. + */ + public function destroy(UserIgnoredWord $word): JsonResponse + { + if ($word->user_id !== Auth::id()) { + return response()->json(['error' => 'Not found'], 404); + } + + $word->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Requests/StoreUserIgnoredWordRequest.php b/app/Http/Requests/StoreUserIgnoredWordRequest.php new file mode 100644 index 0000000..57604d1 --- /dev/null +++ b/app/Http/Requests/StoreUserIgnoredWordRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:100'], + 'language' => ['required', 'string', 'in:en,fr,de,es,it,la,ro'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('word')) { + $this->merge(['word' => mb_strtolower(trim($this->word))]); + } + } +} diff --git a/app/Http/Resources/UserIgnoredWordResource.php b/app/Http/Resources/UserIgnoredWordResource.php new file mode 100644 index 0000000..07fbc59 --- /dev/null +++ b/app/Http/Resources/UserIgnoredWordResource.php @@ -0,0 +1,19 @@ + $this->id, + 'word' => $this->word, + 'language' => $this->language, + 'created_at' => $this->created_at->toISOString(), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index afa0d49..13f64fa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,6 +30,11 @@ public function dictionaryWords(): HasMany return $this->hasMany(UserDictionaryWord::class); } + public function ignoredWords(): HasMany + { + return $this->hasMany(UserIgnoredWord::class); + } + public function preference(): HasOne { return $this->hasOne(UserPreference::class); diff --git a/app/Models/UserIgnoredWord.php b/app/Models/UserIgnoredWord.php new file mode 100644 index 0000000..7a358ae --- /dev/null +++ b/app/Models/UserIgnoredWord.php @@ -0,0 +1,19 @@ +belongsTo(User::class); + } +} diff --git a/database/migrations/2026_05_27_095414_create_user_ignored_words_table.php b/database/migrations/2026_05_27_095414_create_user_ignored_words_table.php new file mode 100644 index 0000000..eca25a8 --- /dev/null +++ b/database/migrations/2026_05_27_095414_create_user_ignored_words_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('word', 100); + $table->string('language', 10); + $table->timestamps(); + $table->unique(['user_id', 'word', 'language']); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_ignored_words'); + } +}; diff --git a/routes/api.php b/routes/api.php index c365aec..2525de4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\FootnoteController; use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\UserDictionaryController; +use App\Http\Controllers\Api\UserIgnoredWordsController; use App\Http\Controllers\Api\UserPreferencesController; use App\Http\Controllers\Api\UserViewStatesController; use App\Http\Controllers\Api\BookViewController; @@ -117,6 +118,11 @@ Route::post('dictionary', [UserDictionaryController::class, 'store']); Route::delete('dictionary/{word}', [UserDictionaryController::class, 'destroy']); + // User ignored words (spell-check) + Route::get('ignored-words', [UserIgnoredWordsController::class, 'index']); + Route::post('ignored-words', [UserIgnoredWordsController::class, 'store']); + Route::delete('ignored-words/{word}', [UserIgnoredWordsController::class, 'destroy']); + // User preferences (personalisation) Route::get('preferences', [UserPreferencesController::class, 'show']); Route::patch('preferences', [UserPreferencesController::class, 'update']); diff --git a/tests/Feature/UserIgnoredWordsControllerTest.php b/tests/Feature/UserIgnoredWordsControllerTest.php new file mode 100644 index 0000000..40687f4 --- /dev/null +++ b/tests/Feature/UserIgnoredWordsControllerTest.php @@ -0,0 +1,191 @@ + User::factory()->create()->id, 'word' => 'foo', 'language' => 'en']); + + $this->getJson('/api/ignored-words')->assertStatus(401); + $this->postJson('/api/ignored-words')->assertStatus(401); + $this->deleteJson("/api/ignored-words/{$word->id}")->assertStatus(401); + } + + // ==================== Index ==================== + + public function test_index_returns_empty_list_when_no_words(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/ignored-words') + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + public function test_index_returns_words_sorted_alphabetically(): void + { + $user = User::factory()->create(); + UserIgnoredWord::create(['user_id' => $user->id, 'word' => 'zebra', 'language' => 'en']); + UserIgnoredWord::create(['user_id' => $user->id, 'word' => 'apple', 'language' => 'en']); + + $this->actingAs($user) + ->getJson('/api/ignored-words') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.word', 'apple') + ->assertJsonPath('data.1.word', 'zebra'); + } + + public function test_index_does_not_return_other_users_words(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + UserIgnoredWord::create(['user_id' => $other->id, 'word' => 'secret', 'language' => 'en']); + + $this->actingAs($user) + ->getJson('/api/ignored-words') + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + // ==================== Store ==================== + + public function test_store_creates_word_with_language(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => 'foo', 'language' => 'en']) + ->assertStatus(201) + ->assertJsonPath('data.word', 'foo') + ->assertJsonPath('data.language', 'en'); + } + + public function test_store_rejects_missing_language(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => 'foo']) + ->assertStatus(422) + ->assertJsonValidationErrors(['language']); + } + + public function test_store_returns_200_for_duplicate(): void + { + $user = User::factory()->create(); + UserIgnoredWord::create(['user_id' => $user->id, 'word' => 'foo', 'language' => 'en']); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => 'foo', 'language' => 'en']) + ->assertStatus(200); + } + + public function test_store_same_word_different_language_creates_separate_entry(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->postJson('/api/ignored-words', ['word' => 'foo', 'language' => 'en'])->assertStatus(201); + $this->actingAs($user)->postJson('/api/ignored-words', ['word' => 'foo', 'language' => 'fr'])->assertStatus(201); + + $this->assertDatabaseCount('user_ignored_words', 2); + } + + public function test_store_lowercases_word(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => 'FOO', 'language' => 'en']) + ->assertStatus(201) + ->assertJsonPath('data.word', 'foo'); + } + + public function test_store_rejects_missing_word(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['language' => 'en']) + ->assertStatus(422) + ->assertJsonValidationErrors(['word']); + } + + public function test_store_rejects_unsupported_language(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => 'foo', 'language' => 'xx']) + ->assertStatus(422) + ->assertJsonValidationErrors(['language']); + } + + public function test_store_rejects_word_over_100_chars(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/ignored-words', ['word' => str_repeat('a', 101), 'language' => 'en']) + ->assertStatus(422) + ->assertJsonValidationErrors(['word']); + } + + // ==================== Destroy ==================== + + public function test_destroy_deletes_own_word(): void + { + $user = User::factory()->create(); + $word = UserIgnoredWord::create(['user_id' => $user->id, 'word' => 'foo', 'language' => 'en']); + + $this->actingAs($user) + ->deleteJson("/api/ignored-words/{$word->id}") + ->assertStatus(204); + + $this->assertDatabaseMissing('user_ignored_words', ['id' => $word->id]); + } + + public function test_destroy_returns_404_for_other_users_word(): void + { + $user = User::factory()->create(); + $other = User::factory()->create(); + $word = UserIgnoredWord::create(['user_id' => $other->id, 'word' => 'foo', 'language' => 'en']); + + $this->actingAs($user) + ->deleteJson("/api/ignored-words/{$word->id}") + ->assertStatus(404); + + $this->assertDatabaseHas('user_ignored_words', ['id' => $word->id]); + } + + public function test_destroy_returns_404_for_nonexistent_word(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->deleteJson('/api/ignored-words/99999') + ->assertStatus(404); + } + + public function test_user_ignored_words_deleted_when_user_deleted(): void + { + $user = User::factory()->create(); + UserIgnoredWord::create(['user_id' => $user->id, 'word' => 'foo', 'language' => 'en']); + + $user->delete(); + + $this->assertDatabaseMissing('user_ignored_words', ['user_id' => $user->id]); + } +} diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts index 706f175..1980453 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts @@ -930,7 +930,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { y: click.y, onReplace: (suggestion) => this.replaceWord(click.view, click.from, click.to, suggestion), onIgnore: () => { - this.spellService.ignore(click.word); + this.spellService.ignore(click.word, this.currentLang ?? ''); this.triggerSpellRecheck(); }, onAddToDictionary: () => { diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts index d31bb31..786bc29 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts @@ -971,7 +971,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { y: click.y, onReplace: (suggestion: string) => this.replaceWord(click.view, click.from, click.to, suggestion), onIgnore: () => { - this.spellService.ignore(click.word); + this.spellService.ignore(click.word, this.currentLang ?? ''); this.triggerSpellRecheck(); }, onAddToDictionary: () => { diff --git a/wordkeep-client/src/app/services/spell-check.service.spec.ts b/wordkeep-client/src/app/services/spell-check.service.spec.ts index 0b696b7..8d46aac 100644 --- a/wordkeep-client/src/app/services/spell-check.service.spec.ts +++ b/wordkeep-client/src/app/services/spell-check.service.spec.ts @@ -14,6 +14,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { SpellCheckService, NSPELL_FACTORY } from './spell-check.service'; import { UserDictionaryService } from './user-dictionary.service'; +import { UserIgnoredWordsService, IgnoredWord } from './user-ignored-words.service'; import { mockDictionaryWord, setupLocalStorageMock } from '../test-utils'; describe('SpellCheckService', () => { @@ -24,6 +25,12 @@ describe('SpellCheckService', () => { wordList: ReturnType; add: ReturnType; }; + let mockIgnoredWordsService: { + words: ReturnType; + isIgnored: ReturnType; + add: ReturnType; + remove: ReturnType; + }; beforeEach(() => { setupLocalStorageMock(); @@ -41,12 +48,31 @@ describe('SpellCheckService', () => { add: vi.fn().mockReturnValue(of(mockDictionaryWord)), }; + const ignoredStore: IgnoredWord[] = []; + mockIgnoredWordsService = { + words: vi.fn().mockImplementation(() => ignoredStore), + isIgnored: vi.fn().mockImplementation((word: string, lang: string) => + ignoredStore.some(w => w.word === word.toLowerCase() && w.language === lang) + ), + add: vi.fn().mockImplementation((word: string, language: string) => { + const entry: IgnoredWord = { id: ignoredStore.length + 1, word, language, created_at: '' }; + ignoredStore.push(entry); + return of(entry); + }), + remove: vi.fn().mockImplementation((entry: IgnoredWord) => { + const idx = ignoredStore.findIndex(w => w.id === entry.id); + if (idx >= 0) ignoredStore.splice(idx, 1); + return of(undefined); + }), + }; + TestBed.configureTestingModule({ providers: [ provideHttpClient(), provideHttpClientTesting(), SpellCheckService, { provide: UserDictionaryService, useValue: mockUserDictService }, + { provide: UserIgnoredWordsService, useValue: mockIgnoredWordsService }, { provide: NSPELL_FACTORY, useValue: mockNspell }, ], }); @@ -152,7 +178,7 @@ describe('SpellCheckService', () => { describe('isCorrect()', () => { it('returns true for words in ignored set', () => { - service.ignore('ignoredword'); + service.ignore('ignoredword', 'en'); expect(service.isCorrect(mockChecker, 'ignoredword', 'en')).toBe(true); expect(mockChecker.correct).not.toHaveBeenCalled(); }); @@ -173,20 +199,25 @@ describe('SpellCheckService', () => { // ==================== ignore() / removeFromIgnored() ==================== describe('ignore() / removeFromIgnored()', () => { - it('ignore() adds word to ignored set (lowercased)', () => { - service.ignore('Hello'); - expect(service.isCorrect(mockChecker, 'hello', 'en')).toBe(true); + it('ignore() delegates to ignoredWordsService.add with lowercase word and lang', () => { + service.ignore('Hello', 'en'); + expect(mockIgnoredWordsService.add).toHaveBeenCalledWith('hello', 'en'); }); - it('ignore() saves to localStorage', () => { - service.ignore('hello'); - const stored = JSON.parse(localStorage.getItem('spellcheck_ignored') ?? '[]'); - expect(stored).toContain('hello'); + it('ignore() triggers recheck on success', () => { + let recheckEmitted = false; + service.recheckAll$.subscribe(() => { recheckEmitted = true; }); + service.ignore('hello', 'en'); + expect(recheckEmitted).toBe(true); }); - it('removeFromIgnored() removes from ignored set', () => { - service.ignore('hello'); + it('removeFromIgnored() removes all entries matching the word', () => { + service.ignore('hello', 'en'); + service.ignore('hello', 'fr'); + service.removeFromIgnored('hello'); + + expect(mockIgnoredWordsService.remove).toHaveBeenCalledTimes(2); mockChecker.correct.mockReturnValue(false); expect(service.isCorrect(mockChecker, 'hello', 'en')).toBe(false); }); @@ -195,11 +226,6 @@ describe('SpellCheckService', () => { // ==================== addToCustomDictionary() ==================== describe('addToCustomDictionary()', () => { - it('immediately adds to ignored set so isCorrect returns true before API', () => { - service.addToCustomDictionary('myterm', 'en'); - expect(service.isCorrect(mockChecker, 'myterm', 'en')).toBe(true); - }); - it('calls userDictService.add(lower, lang)', () => { service.addToCustomDictionary('MyTerm', 'en'); expect(mockUserDictService.add).toHaveBeenCalledWith('myterm', 'en'); diff --git a/wordkeep-client/src/app/services/spell-check.service.ts b/wordkeep-client/src/app/services/spell-check.service.ts index b754d95..d6004e4 100644 --- a/wordkeep-client/src/app/services/spell-check.service.ts +++ b/wordkeep-client/src/app/services/spell-check.service.ts @@ -4,6 +4,7 @@ import { Observable, Subject, forkJoin, of } from 'rxjs'; import { map, catchError, shareReplay, take } from 'rxjs/operators'; import nspell from 'nspell'; import { UserDictionaryService } from './user-dictionary.service'; +import { UserIgnoredWordsService } from './user-ignored-words.service'; export const NSPELL_FACTORY = new InjectionToken('nspell', { providedIn: 'root', @@ -13,7 +14,6 @@ export const NSPELL_FACTORY = new InjectionToken('nspell', { export type NspellChecker = ReturnType; const SUPPORTED_LANGS = new Set(['en', 'fr', 'de', 'es', 'it', 'la', 'ro']); -const STORAGE_KEY_IGNORED = 'spellcheck_ignored'; // Characters to try substituting when nspell's ranking buries diacritic suggestions. // Language-agnostic: checker.correct() validates against the loaded dictionary, @@ -44,12 +44,12 @@ const DIACRITIC_MAP: Record = { export class SpellCheckService { private http = inject(HttpClient); private userDictService = inject(UserDictionaryService); + private ignoredWordsService = inject(UserIgnoredWordsService); private nspellFn = inject(NSPELL_FACTORY); // permanent — files only fetched once per language per session private rawFilesCache = new Map>(); // invalidatable — rebuilt whenever custom words change private checkerCache = new Map>(); - private ignoredWords = new Set(this.loadFromStorage(STORAGE_KEY_IGNORED)); readonly recheckAll$ = new Subject(); @@ -106,12 +106,15 @@ export class SpellCheckService { } removeFromIgnored(word: string): void { - this.ignoredWords.delete(word.toLowerCase()); + const lower = word.toLowerCase(); + const matches = this.ignoredWordsService.words().filter(w => w.word === lower); + for (const entry of matches) { + this.ignoredWordsService.remove(entry).subscribe(); + } } isCorrect(checker: NspellChecker, word: string, lang: string): boolean { - const lower = word.toLowerCase(); - if (this.ignoredWords.has(lower) || this.userDictService.wordList(lang).includes(lower)) return true; + if (this.ignoredWordsService.isIgnored(word, lang) || this.userDictService.wordList(lang).includes(word.toLowerCase())) return true; return checker.correct(word); } @@ -141,28 +144,18 @@ export class SpellCheckService { return [...new Set([...diacritic, ...base])].slice(0, 20); } - ignore(word: string): void { - this.ignoredWords.add(word.toLowerCase()); - this.saveToStorage(STORAGE_KEY_IGNORED, this.ignoredWords); + ignore(word: string, lang: string): void { + this.ignoredWordsService.add(word.toLowerCase(), lang).subscribe({ + next: () => this.triggerRecheckAll(), + }); } addToCustomDictionary(word: string, lang: string): void { - const lower = word.toLowerCase(); - this.ignoredWords.add(lower); - this.userDictService.add(lower, lang).subscribe({ + this.userDictService.add(word.toLowerCase(), lang).subscribe({ next: () => { this.invalidateChecker(lang); this.triggerRecheckAll(); }, }); } - - private loadFromStorage(key: string): string[] { - try { return JSON.parse(localStorage.getItem(key) ?? '[]'); } - catch { return []; } - } - - private saveToStorage(key: string, set: Set): void { - localStorage.setItem(key, JSON.stringify([...set])); - } } diff --git a/wordkeep-client/src/app/services/user-ignored-words.service.spec.ts b/wordkeep-client/src/app/services/user-ignored-words.service.spec.ts new file mode 100644 index 0000000..2cf9de1 --- /dev/null +++ b/wordkeep-client/src/app/services/user-ignored-words.service.spec.ts @@ -0,0 +1,165 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { UserIgnoredWordsService, IgnoredWord } from './user-ignored-words.service'; +import { environment } from '../../environments/environment'; +import { setupLocalStorageMock } from '../test-utils'; + +const API_URL = `${environment.apiUrl}/ignored-words`; + +const mockEntry: IgnoredWord = { + id: 1, + word: 'foo', + language: 'en', + created_at: '2026-01-01T00:00:00Z', +}; + +describe('UserIgnoredWordsService', () => { + let service: UserIgnoredWordsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + setupLocalStorageMock(); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + UserIgnoredWordsService, + ], + }); + + service = TestBed.inject(UserIgnoredWordsService); + httpMock = TestBed.inject(HttpTestingController); + httpMock.expectOne(API_URL).flush({ data: [] }); + }); + + afterEach(() => { + httpMock.verify(); + }); + + // ==================== load() ==================== + + describe('load()', () => { + it('GETs /ignored-words and sets signal', () => { + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [mockEntry] }); + + expect(service.words()).toEqual([mockEntry]); + }); + + it('saves to localStorage', () => { + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [mockEntry] }); + + const stored = JSON.parse(localStorage.getItem('spellcheck_ignored') ?? '[]'); + expect(stored).toHaveLength(1); + }); + + it('falls back to localStorage cache on HTTP error', () => { + localStorage.setItem('spellcheck_ignored', JSON.stringify([mockEntry])); + + service.load().subscribe(); + httpMock.expectOne(API_URL).flush(null, { status: 500, statusText: 'Server Error' }); + + expect(service.words()).toHaveLength(1); + expect(service.words()[0].word).toBe('foo'); + }); + + it('emits ready$ after success', () => { + let emitted = false; + service.ready$.subscribe(() => { emitted = true; }); + + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [] }); + + expect(emitted).toBe(true); + }); + }); + + // ==================== isIgnored() ==================== + + describe('isIgnored()', () => { + function seed(entries: IgnoredWord[]): void { + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: entries }); + } + + it('matches exact lang', () => { + seed([{ ...mockEntry, language: 'en' }]); + expect(service.isIgnored('foo', 'en')).toBe(true); + }); + + it('does not match different lang', () => { + seed([{ ...mockEntry, language: 'en' }]); + expect(service.isIgnored('foo', 'fr')).toBe(false); + }); + + it('is case-insensitive', () => { + seed([{ ...mockEntry, word: 'foo' }]); + expect(service.isIgnored('FOO', 'en')).toBe(true); + }); + + it('returns false when word not in list', () => { + expect(service.isIgnored('bar', 'en')).toBe(false); + }); + }); + + // ==================== add() ==================== + + describe('add()', () => { + it('POSTs { word, language }', () => { + service.add('foo', 'en').subscribe(); + + const req = httpMock.expectOne(API_URL); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ word: 'foo', language: 'en' }); + req.flush({ data: mockEntry }); + }); + + it('adds entry to signal sorted alphabetically', () => { + const zebra = { ...mockEntry, id: 2, word: 'zebra' }; + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [zebra] }); + + service.add('apple', 'en').subscribe(); + httpMock.expectOne(API_URL).flush({ data: { ...mockEntry, id: 3, word: 'apple' } }); + + expect(service.words()[0].word).toBe('apple'); + expect(service.words()[1].word).toBe('zebra'); + }); + + it('does not duplicate when same id is returned', () => { + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [mockEntry] }); + + service.add('foo', 'en').subscribe(); + httpMock.expectOne(API_URL).flush({ data: mockEntry }); + + expect(service.words()).toHaveLength(1); + }); + }); + + // ==================== remove() ==================== + + describe('remove()', () => { + it('DELETEs /ignored-words/{id}', () => { + service.remove(mockEntry).subscribe(); + + const req = httpMock.expectOne(`${API_URL}/1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + + it('removes entry from signal', () => { + service.load().subscribe(); + httpMock.expectOne(API_URL).flush({ data: [mockEntry] }); + + service.remove(mockEntry).subscribe(); + httpMock.expectOne(`${API_URL}/1`).flush(null); + + expect(service.words()).toHaveLength(0); + }); + }); +}); diff --git a/wordkeep-client/src/app/services/user-ignored-words.service.ts b/wordkeep-client/src/app/services/user-ignored-words.service.ts new file mode 100644 index 0000000..df213e8 --- /dev/null +++ b/wordkeep-client/src/app/services/user-ignored-words.service.ts @@ -0,0 +1,86 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, ReplaySubject, of, tap } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; + +export interface IgnoredWord { + id: number; + word: string; + language: string; + created_at: string; +} + +const STORAGE_KEY = 'spellcheck_ignored'; + +@Injectable({ providedIn: 'root' }) +export class UserIgnoredWordsService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/ignored-words`; + + private _words = signal([]); + readonly words = this._words.asReadonly(); + + private _ready$ = new ReplaySubject(1); + readonly ready$ = this._ready$.asObservable(); + + constructor() { + this.load().subscribe(); + } + + /** Returns true if `word` is ignored for the given lang. */ + isIgnored(word: string, lang: string): boolean { + const lower = word.toLowerCase(); + return this._words().some(w => w.word === lower && w.language === lang); + } + + /** Fetch the user's ignored words from the API and sync to localStorage cache. */ + load(): Observable { + return this.http.get<{ data: IgnoredWord[] }>(this.apiUrl).pipe( + tap(response => { + const words = response.data; + this._words.set(words); + this.saveToStorage(words); + this._ready$.next(); + }), + map(() => undefined), + catchError(() => { + const cached = this.loadFromStorage(); + this._words.set(cached); + this._ready$.next(); + return of(undefined); + }) + ); + } + + add(word: string, language: string): Observable { + return this.http.post<{ data: IgnoredWord }>(this.apiUrl, { word, language }).pipe( + tap(response => { + const entry = response.data; + if (!this._words().some(w => w.id === entry.id)) { + this._words.update(words => [...words, entry].sort((a, b) => a.word.localeCompare(b.word))); + } + this.saveToStorage(this._words()); + }), + map(response => response.data) + ); + } + + remove(entry: IgnoredWord): Observable { + return this.http.delete(`${this.apiUrl}/${entry.id}`).pipe( + tap(() => { + this._words.update(words => words.filter(w => w.id !== entry.id)); + this.saveToStorage(this._words()); + }) + ); + } + + private loadFromStorage(): IgnoredWord[] { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } + catch { return []; } + } + + private saveToStorage(words: IgnoredWord[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(words)); + } +} From 72b4485cc45ba6e4b515aa676a73e2a6ba3f8490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 13:13:13 +0300 Subject: [PATCH 07/10] [WKP-81] Mirror never-migrate-fresh memory to docs Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/claude-code-memories/MEMORY.md | 1 + .../never-migrate-fresh-without-approval.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 docs/claude-code-memories/never-migrate-fresh-without-approval.md diff --git a/docs/claude-code-memories/MEMORY.md b/docs/claude-code-memories/MEMORY.md index 058137c..585cbd4 100644 --- a/docs/claude-code-memories/MEMORY.md +++ b/docs/claude-code-memories/MEMORY.md @@ -18,3 +18,4 @@ - [Single README per project](single-readme-per-project.md) — one README at repo root only - [AuthService spec mocks](auth-service-spec-mocks.md) — mock `user` as `vi.fn()` in LayoutComponent specs - [Ask plan open questions before exit](ask-plan-open-questions-before-exit.md) — resolve every open question via AskUserQuestion before ExitPlanMode +- [Never migrate:fresh without approval](never-migrate-fresh-without-approval.md) — destructive DB commands require explicit approval diff --git a/docs/claude-code-memories/never-migrate-fresh-without-approval.md b/docs/claude-code-memories/never-migrate-fresh-without-approval.md new file mode 100644 index 0000000..20aa2fc --- /dev/null +++ b/docs/claude-code-memories/never-migrate-fresh-without-approval.md @@ -0,0 +1,17 @@ +--- +name: never-migrate-fresh-without-approval +description: Never run `php artisan migrate:fresh` (or `migrate:rollback`, or any destructive DB command) without explicit user approval. +metadata: + type: feedback +--- + +Never run `php artisan migrate:fresh`, `migrate:rollback`, `migrate:reset`, or `db:wipe` without explicit user approval — they drop tables and wipe data. + +**Why:** I ran `php artisan migrate:fresh --seed` to apply a schema change (making a column NOT NULL) without asking. That wipes the user's dev database. Even on a dev box with seeders, the data the user accumulated is gone. + +**How to apply:** When a schema change requires re-running a migration: +- Prefer creating a new migration (e.g., `change_x_column_to_not_null`) and running `php artisan migrate`. +- If the migration is unpublished (only on this branch, no production data), it's OK to edit the existing migration file in place — but then either ask "OK to migrate:fresh?" before running it, or roll back just that one migration with `migrate:rollback --step=1` (still ask first, but more surgical). +- Default: ask before any destructive DB command. + +Mirror to `docs/claude-code-memories/` per [[mirror-memories-to-docs]]. Related: [[git-commit-push-explicit-only]] — same "destructive default = ask first" principle. From 0af167ea0a7840b553e7f3f47abcc986aa331a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 13:44:38 +0300 Subject: [PATCH 08/10] [WKP-81] Fix spell-check E2E test ordering 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) --- wordkeep-client/e2e/spell-check.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/wordkeep-client/e2e/spell-check.spec.ts b/wordkeep-client/e2e/spell-check.spec.ts index 4b5e757..af7723a 100644 --- a/wordkeep-client/e2e/spell-check.spec.ts +++ b/wordkeep-client/e2e/spell-check.spec.ts @@ -115,21 +115,27 @@ test.describe('spell check in editor', () => { }); }); - test.afterAll(async ({ request }) => { - if (documentId && authToken) { - // Clean up dictionary entry if added - const dictRes = await request.get(`${API_URL}/dictionary`, { + // Reset per-user spell-check state between tests so order-dependent + // backend mutations (ignore / add-to-dictionary) don't leak. + test.afterEach(async ({ request }) => { + if (!authToken) return; + for (const endpoint of ['ignored-words', 'dictionary'] as const) { + const res = await request.get(`${API_URL}/${endpoint}`, { headers: { Authorization: `Bearer ${authToken}` }, }); - const { data } = await dictRes.json(); + const { data } = await res.json(); for (const entry of data) { if (entry.word === 'recieve') { - await request.delete(`${API_URL}/dictionary/${entry.id}`, { + await request.delete(`${API_URL}/${endpoint}/${entry.id}`, { headers: { Authorization: `Bearer ${authToken}` }, }); } } + } + }); + test.afterAll(async ({ request }) => { + if (documentId && authToken) { await request.delete(`${API_URL}/documents/${documentId}`, { headers: { Authorization: `Bearer ${authToken}` }, }); From da59d02eb9322cc302a3da4fd4c38ab0a6255b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 14:11:04 +0300 Subject: [PATCH 09/10] [WKP-81] Add factories for the three new models 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) --- database/factories/UserIgnoredWordFactory.php | 18 +++++++++++++ database/factories/UserPreferenceFactory.php | 19 ++++++++++++++ database/factories/UserViewStateFactory.php | 26 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 database/factories/UserIgnoredWordFactory.php create mode 100644 database/factories/UserPreferenceFactory.php create mode 100644 database/factories/UserViewStateFactory.php diff --git a/database/factories/UserIgnoredWordFactory.php b/database/factories/UserIgnoredWordFactory.php new file mode 100644 index 0000000..b0e365c --- /dev/null +++ b/database/factories/UserIgnoredWordFactory.php @@ -0,0 +1,18 @@ + User::factory(), + 'word' => fake()->unique()->word(), + 'language' => fake()->randomElement(['en', 'fr', 'de', 'es', 'it', 'la', 'ro']), + ]; + } +} diff --git a/database/factories/UserPreferenceFactory.php b/database/factories/UserPreferenceFactory.php new file mode 100644 index 0000000..fd6ff97 --- /dev/null +++ b/database/factories/UserPreferenceFactory.php @@ -0,0 +1,19 @@ + User::factory(), + 'theme_mode' => fake()->randomElement(['auto', 'light', 'dark']), + 'palette' => fake()->randomElement(['reading-room', 'cobalt', 'monastic', 'grove', 'ferrous', 'plum']), + 'layout' => fake()->randomElement(['reading-room', 'workbench', 'compendium', 'glass', 'scriptorium', 'console']), + ]; + } +} diff --git a/database/factories/UserViewStateFactory.php b/database/factories/UserViewStateFactory.php new file mode 100644 index 0000000..a2f542b --- /dev/null +++ b/database/factories/UserViewStateFactory.php @@ -0,0 +1,26 @@ + [ + 'sort' => fake()->randomElement(['title', 'updated_at']), + 'dir' => fake()->randomElement(['asc', 'desc']), + ]; + + return [ + 'user_id' => User::factory(), + 'sort_documents' => $sort(), + 'sort_book_view' => $sort(), + 'sort_translation' => $sort(), + 'sort_extraction' => $sort(), + 'sort_page_linking' => $sort(), + ]; + } +} From ac655bce8edc10060205d38dccf7afd94ce11ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Wed, 27 May 2026 14:11:26 +0300 Subject: [PATCH 10/10] [WKP-81] E2E coverage for server-side preference hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- wordkeep-client/e2e/personalisation.spec.ts | 33 +++++++++++++++++++-- wordkeep-client/e2e/sort-dropdown.spec.ts | 28 +++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/wordkeep-client/e2e/personalisation.spec.ts b/wordkeep-client/e2e/personalisation.spec.ts index 0f661de..5adb182 100644 --- a/wordkeep-client/e2e/personalisation.spec.ts +++ b/wordkeep-client/e2e/personalisation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { test, expect, API_URL } from './fixtures'; // Wait until reaches the expected value. Angular's // effect() that writes the attribute runs in a microtask after the @@ -66,6 +66,28 @@ test.describe('personalisation', () => { await expectLocalStorage(page, 'wk:theme', 'dark'); }); + test('selections hydrate from server-side preferences when localStorage is empty', async ({ page, auth, request }) => { + // Seed server-side then verify a fresh-cache page picks them up. + await request.patch(`${API_URL}/preferences`, { + headers: { Authorization: `Bearer ${auth.token}` }, + data: { theme_mode: 'light', palette: 'grove', layout: 'workbench' }, + }); + + // Drop the personalisation cache keys before app boot so the only path + // to the seeded values is the auth-triggered GET /api/preferences. + await page.addInitScript(() => { + localStorage.removeItem('wk:theme'); + localStorage.removeItem('wk:palette'); + localStorage.removeItem('wk:layout'); + }); + + await page.goto('/profile/personalisation'); + + await expectHtmlAttr(page, 'data-layout', 'workbench'); + await expectHtmlAttr(page, 'data-palette', 'grove'); + await expectHtmlAttr(page, 'data-theme', 'light'); + }); + test('selections survive a hard reload', async ({ page }) => { await page.goto('/profile/personalisation'); @@ -90,12 +112,17 @@ test.describe('personalisation', () => { await expectLocalStorage(page, 'wk:theme', 'dark'); }); - test.afterEach(async ({ page }) => { - // Reset to defaults so other tests aren't affected by lingering picks. + test.afterEach(async ({ page, request, auth }) => { + // Reset to defaults so other tests aren't affected by lingering picks + // — both the client cache AND the new server-side preferences row. await page.evaluate(() => { localStorage.removeItem('wk:layout'); localStorage.removeItem('wk:palette'); localStorage.removeItem('wk:theme'); }); + await request.patch(`${API_URL}/preferences`, { + headers: { Authorization: `Bearer ${auth.token}` }, + data: { theme_mode: 'auto', palette: 'reading-room', layout: 'reading-room' }, + }); }); }); diff --git a/wordkeep-client/e2e/sort-dropdown.spec.ts b/wordkeep-client/e2e/sort-dropdown.spec.ts index 6074989..138f81b 100644 --- a/wordkeep-client/e2e/sort-dropdown.spec.ts +++ b/wordkeep-client/e2e/sort-dropdown.spec.ts @@ -80,4 +80,32 @@ test.describe('sort dropdown', () => { await request.delete(`${API_URL}/documents/${docA.id}`, { headers: authHeaders(token) }); } }); + + test('dropdown hydrates from server-side view-state when localStorage is empty', async ({ page, auth, request }) => { + // Seed server with title:asc, then prove the page reflects it even with + // no localStorage cache to bootstrap from. + await request.patch(`${API_URL}/view-states`, { + headers: authHeaders(auth.token), + data: { sort_documents: { sort: 'title', dir: 'asc' } }, + }); + + try { + // Drop the client cache for this key before the app boots. + await page.addInitScript(() => { + localStorage.removeItem('wk:sort:documents'); + }); + + await page.goto('/documents'); + await page.waitForSelector('app-sort-dropdown select', { state: 'visible' }); + + // After the auth-triggered GET /api/view-states resolves, the service + // re-hydrates the signal and the dropdown re-renders with the server value. + await expect(page.locator('app-sort-dropdown select')).toHaveValue('title:asc'); + } finally { + await request.patch(`${API_URL}/view-states`, { + headers: authHeaders(auth.token), + data: { sort_documents: { sort: 'updated_at', dir: 'desc' } }, + }); + } + }); });