From 7fd90643c558d50546de3a3423fa50e76545ac5d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:32:50 +0200 Subject: [PATCH] docs(plans): Search-State-Store Implementierungsplan 6-Task-Plan fuer Tier 2 der Post-Review-Roadmap. Extrahiert die duplizierte Such-Logik aus +page.svelte und +layout.svelte in eine gemeinsame SearchStore-Klasse mit TDD (12 Unit-Tests), Header- Dropdown-Migration vor Home-Migration, und UAT-Smoke. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-19-search-state-store.md | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-search-state-store.md diff --git a/docs/superpowers/plans/2026-04-19-search-state-store.md b/docs/superpowers/plans/2026-04-19-search-state-store.md new file mode 100644 index 0000000..0b1fc19 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-search-state-store.md @@ -0,0 +1,967 @@ +# Search-State-Store Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the duplicated live-search state machine from `src/routes/+page.svelte` and `src/routes/+layout.svelte` into a single reusable `SearchStore` class in `src/lib/client/search.svelte.ts`, so both the home search and the header dropdown drive their UI from the same logic. + +**Architecture:** Factory-class store (one instance per consumer, like `new SearchStore()` — not a shared singleton). Holds all `$state` fields currently inlined in the Svelte components (query, hits, webHits, searching flags, error, pagination state), plus imperative methods (`runDebounced`, `loadMore`, `reSearch`, `reset`, `captureSnapshot`, `restoreSnapshot`). Consumers keep UI-specific concerns (URL sync, dropdown open/close, snapshot hookup) in their component — the store owns only fetch/pagination/debounce. + +**Tech Stack:** Svelte 5 runes (`$state` in class fields), TypeScript-strict, Vitest + jsdom, fetch injection for tests. + +--- + +## Design Snapshot + +**API surface (locked before implementation):** + +```ts +// src/lib/client/search.svelte.ts +import type { SearchHit } from '$lib/server/recipes/search-local'; +import type { WebHit } from '$lib/server/search/searxng'; + +export type SearchSnapshot = { + query: string; + hits: SearchHit[]; + webHits: WebHit[]; + searchedFor: string | null; + webError: string | null; + localExhausted: boolean; + webPageno: number; + webExhausted: boolean; +}; + +export type SearchStoreOptions = { + pageSize?: number; // default 30 + debounceMs?: number; // default 300 + filterDebounceMs?: number; // default 150 (shorter for filter-change re-search) + minQueryLength?: number; // default 4 (query.trim().length > 3) + filterParam?: () => string; // e.g. () => searchFilterStore.queryParam → "foo,bar" or "" + fetchImpl?: typeof fetch; // injected for tests +}; + +export class SearchStore { + query = $state(''); + hits = $state([]); + webHits = $state([]); + searching = $state(false); + webSearching = $state(false); + webError = $state(null); + searchedFor = $state(null); + localExhausted = $state(false); + webPageno = $state(0); + webExhausted = $state(false); + loadingMore = $state(false); + + constructor(opts?: SearchStoreOptions); + + /** Call from `$effect(() => store.runDebounced(store.query))`. Handles debounce + race-guard. */ + runDebounced(q: string): void; + /** Immediate (no debounce). Used by form `submit`. */ + runSearch(q: string): Promise; + /** Filter-change re-search — shorter debounce. */ + reSearch(): void; + /** Paginate locally, then fall back to web. Idempotent while in-flight. */ + loadMore(): Promise; + /** Clear query + results + cancel any pending debounce (e.g. `afterNavigate`). */ + reset(): void; + /** For SvelteKit `Snapshot<>` API. */ + captureSnapshot(): SearchSnapshot; + restoreSnapshot(s: SearchSnapshot): void; +} +``` + +**Behavior invariants (copied 1:1 from the current code — do NOT change):** +- Query threshold: `trim().length > 3` triggers search, `<= 3` clears results. +- Race-guard: after every `await fetch(...)`, bail if `this.query.trim() !== q`. +- When `hits.length === 0` after local search → auto-fire web search page 1. +- `loadMore`: first drains local (offset pagination), then switches to web (pageno pagination). +- Dedup: local by `id`, web by `url`. +- `webError`: keep the message text so UI can render it. + +**What stays OUT of the store:** +- URL sync (`history.replaceState` with `?q=`) → stays in `+page.svelte`. +- Dropdown visibility (`navOpen`) → stays in `+layout.svelte`. +- `afterNavigate`-reset wiring → stays in `+layout.svelte`, just calls `store.reset()`. +- SvelteKit `Snapshot<>` wiring → stays in `+page.svelte`, delegates to store. +- Filter-change re-search `$effect` → stays in `+page.svelte`, just calls `store.reSearch()`. + +--- + +## Task 1: Failing Unit Tests for SearchStore + +**Files:** +- Create: `tests/unit/search-store.test.ts` + +- [ ] **Step 1: Write test file with full behavior coverage (runs red until Task 2)** + +```ts +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchStore } from '../../src/lib/client/search.svelte'; + +type FetchMock = ReturnType; + +function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock { + const calls = [...responses]; + return vi.fn(async () => { + const r = calls.shift(); + if (!r) throw new Error('fetch called more times than expected'); + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: async () => r.body + } as Response; + }); +} + +describe('SearchStore', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('keeps results empty while query is <= 3 chars (debounced)', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + store.query = 'abc'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(100); + expect(store.searching).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fires local search after debounce when query > 3 chars', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 }); + store.query = 'pasta'; + store.runDebounced(store.query); + expect(store.searching).toBe(true); + await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/); + expect(store.hits).toHaveLength(1); + expect(store.searchedFor).toBe('pasta'); + expect(store.localExhausted).toBe(true); // 1 hit < pageSize → exhausted + }); + + it('falls back to web search when local returns zero hits', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + store.query = 'pizza'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(store.webHits).toHaveLength(1)); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/); + expect(store.webPageno).toBe(1); + }); + + it('races-guards: stale response discarded when query changed mid-flight', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [{ id: 99, title: 'Stale', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10 }); + store.query = 'stale-query'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + store.query = 'different'; // user kept typing + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); + expect(store.hits).toEqual([]); // stale discarded + }); + + it('loadMore: drains local first (offset pagination)', async () => { + vi.useFakeTimers(); + const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); + const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); + const fetchImpl = mockFetch([ + { body: { hits: page1 } }, + { body: { hits: page2 } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); + store.query = 'meal'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.hits).toHaveLength(30)); + expect(store.localExhausted).toBe(false); + await store.loadMore(); + expect(store.hits).toHaveLength(35); + expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/); + expect(store.localExhausted).toBe(true); + }); + + it('loadMore: switches to web pagination after local exhausted', async () => { + vi.useFakeTimers(); + const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }]; + const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }]; + const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }]; + const fetchImpl = mockFetch([ + { body: { hits: local } }, + { body: { hits: webP1 } }, // auto-fallback? No — local has 1 hit, so no fallback. + { body: { hits: webP2 } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); + store.query = 'soup'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.hits).toHaveLength(1)); + expect(store.localExhausted).toBe(true); + await store.loadMore(); // web pageno=1 + expect(store.webHits).toHaveLength(1); + await store.loadMore(); // web pageno=2 + expect(store.webHits).toHaveLength(2); + expect(store.webPageno).toBe(2); + }); + + it('web search error sets webError and marks webExhausted', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { ok: false, status: 502, body: { message: 'SearXNG unreachable' } } + ]); + const store = new SearchStore({ fetchImpl, debounceMs: 10 }); + store.query = 'anything'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable')); + expect(store.webExhausted).toBe(true); + }); + + it('reset(): clears query, results, and pending debounce', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 100 }); + store.query = 'foobar'; + store.runDebounced(store.query); + store.reset(); + await vi.advanceTimersByTimeAsync(200); + expect(store.query).toBe(''); + expect(store.hits).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([]); + const store = new SearchStore({ fetchImpl, debounceMs: 50 }); + const snap: SearchSnapshot = { + query: 'lasagne', + hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }], + webHits: [], + searchedFor: 'lasagne', + webError: null, + localExhausted: true, + webPageno: 0, + webExhausted: false + }; + store.restoreSnapshot(snap); + expect(store.query).toBe('lasagne'); + expect(store.hits).toHaveLength(1); + store.runDebounced(store.query); // should NOT re-fetch after restore + await vi.advanceTimersByTimeAsync(100); + expect(fetchImpl).not.toHaveBeenCalled(); + const round = store.captureSnapshot(); + expect(round).toEqual(snap); + }); + + it('filterParam option: gets appended to both local and web requests', async () => { + vi.useFakeTimers(); + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [] } } + ]); + const store = new SearchStore({ + fetchImpl, + debounceMs: 10, + filterParam: () => '&domains=chefkoch.de' + }); + store.query = 'curry'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/); + expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/); + }); + + it('reSearch: immediate re-run with current query on filter change', async () => { + vi.useFakeTimers(); + let filter = ''; + const fetchImpl = mockFetch([ + { body: { hits: [] } }, + { body: { hits: [] } }, + { body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + ]); + const store = new SearchStore({ + fetchImpl, + debounceMs: 10, + filterDebounceMs: 5, + filterParam: () => filter + }); + store.query = 'broth'; + store.runDebounced(store.query); + await vi.advanceTimersByTimeAsync(15); + // Simulate filter change + filter = '&domains=chefkoch.de'; + store.reSearch(); + await vi.advanceTimersByTimeAsync(10); + await vi.waitFor(() => expect(store.hits).toHaveLength(1)); + // Last call should have filter param + const last = fetchImpl.mock.calls.at(-1)?.[0] as string; + expect(last).toMatch(/&domains=chefkoch\.de/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify all fail with "SearchStore is not a constructor" or "Cannot find module"** + +```bash +npm test -- search-store.test +``` + +Expected: 12 tests, all failing because `src/lib/client/search.svelte.ts` doesn't exist yet. + +--- + +## Task 2: Implement SearchStore to pass tests + +**Files:** +- Create: `src/lib/client/search.svelte.ts` + +- [ ] **Step 1: Scaffold the class + types** + +Create `src/lib/client/search.svelte.ts` with this content: + +```ts +import type { SearchHit } from '$lib/server/recipes/search-local'; +import type { WebHit } from '$lib/server/search/searxng'; + +export type SearchSnapshot = { + query: string; + hits: SearchHit[]; + webHits: WebHit[]; + searchedFor: string | null; + webError: string | null; + localExhausted: boolean; + webPageno: number; + webExhausted: boolean; +}; + +export type SearchStoreOptions = { + pageSize?: number; + debounceMs?: number; + filterDebounceMs?: number; + minQueryLength?: number; + filterParam?: () => string; + fetchImpl?: typeof fetch; +}; + +export class SearchStore { + query = $state(''); + hits = $state([]); + webHits = $state([]); + searching = $state(false); + webSearching = $state(false); + webError = $state(null); + searchedFor = $state(null); + localExhausted = $state(false); + webPageno = $state(0); + webExhausted = $state(false); + loadingMore = $state(false); + + private readonly pageSize: number; + private readonly debounceMs: number; + private readonly filterDebounceMs: number; + private readonly minQueryLength: number; + private readonly filterParam: () => string; + private readonly fetchImpl: typeof fetch; + private debounceTimer: ReturnType | null = null; + private skipNextDebounce = false; + + constructor(opts: SearchStoreOptions = {}) { + this.pageSize = opts.pageSize ?? 30; + this.debounceMs = opts.debounceMs ?? 300; + this.filterDebounceMs = opts.filterDebounceMs ?? 150; + this.minQueryLength = opts.minQueryLength ?? 4; + this.filterParam = opts.filterParam ?? (() => ''); + this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); + } +} +``` + +- [ ] **Step 2: Implement `runDebounced`, `runSearch`, private `runWebSearch`** + +Add to the class: + +```ts + runDebounced(_q: string): void { + // Parameter is present so consumers can wire `$effect(() => store.runDebounced(store.query))` + // — the `$effect` needs to read `store.query` to track it. We re-read this.query here + // to stay consistent with the actual live value. + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.skipNextDebounce) { + this.skipNextDebounce = false; + return; + } + const q = this.query.trim(); + if (q.length < this.minQueryLength) { + this.resetResults(); + return; + } + this.searching = true; + this.webHits = []; + this.webSearching = false; + this.webError = null; + this.debounceTimer = setTimeout(() => { + void this.runSearch(q); + }, this.debounceMs); + } + + async runSearch(q: string): Promise { + this.localExhausted = false; + this.webPageno = 0; + this.webExhausted = false; + try { + const res = await this.fetchImpl( + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}` + ); + const body = (await res.json()) as { hits: SearchHit[] }; + if (this.query.trim() !== q) return; + this.hits = body.hits; + this.searchedFor = q; + if (this.hits.length < this.pageSize) this.localExhausted = true; + if (this.hits.length === 0) { + await this.runWebSearch(q, 1); + } + } finally { + if (this.query.trim() === q) this.searching = false; + } + } + + private async runWebSearch(q: string, pageno: number): Promise { + this.webSearching = true; + try { + const res = await this.fetchImpl( + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}` + ); + if (this.query.trim() !== q) return; + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.webError = err.message ?? `HTTP ${res.status}`; + this.webExhausted = true; + return; + } + const body = (await res.json()) as { hits: WebHit[] }; + this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits]; + this.webPageno = pageno; + if (body.hits.length === 0) this.webExhausted = true; + } finally { + if (this.query.trim() === q) this.webSearching = false; + } + } +``` + +- [ ] **Step 3: Implement `loadMore`** + +```ts + async loadMore(): Promise { + if (this.loadingMore) return; + const q = this.query.trim(); + if (!q) return; + this.loadingMore = true; + try { + if (!this.localExhausted) { + const res = await this.fetchImpl( + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}` + ); + const body = (await res.json()) as { hits: SearchHit[] }; + if (this.query.trim() !== q) return; + const more = body.hits; + const seen = new Set(this.hits.map((h) => h.id)); + const deduped = more.filter((h) => !seen.has(h.id)); + this.hits = [...this.hits, ...deduped]; + if (more.length < this.pageSize) this.localExhausted = true; + } else if (!this.webExhausted) { + const nextPage = this.webPageno + 1; + const wasEmpty = this.webHits.length === 0; + if (wasEmpty) this.webSearching = true; + try { + const res = await this.fetchImpl( + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}` + ); + if (this.query.trim() !== q) return; + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { message?: string }; + this.webError = err.message ?? `HTTP ${res.status}`; + this.webExhausted = true; + return; + } + const body = (await res.json()) as { hits: WebHit[] }; + const more = body.hits; + const seen = new Set(this.webHits.map((h) => h.url)); + const deduped = more.filter((h) => !seen.has(h.url)); + if (deduped.length === 0) { + this.webExhausted = true; + } else { + this.webHits = [...this.webHits, ...deduped]; + this.webPageno = nextPage; + } + } finally { + if (this.query.trim() === q) this.webSearching = false; + } + } + } finally { + this.loadingMore = false; + } + } +``` + +- [ ] **Step 4: Implement `reSearch`, `reset`, `resetResults`, snapshot methods** + +```ts + reSearch(): void { + const q = this.query.trim(); + if (q.length < this.minQueryLength) return; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.searching = true; + this.webHits = []; + this.webSearching = false; + this.webError = null; + this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs); + } + + reset(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = null; + this.query = ''; + this.resetResults(); + } + + private resetResults(): void { + this.hits = []; + this.webHits = []; + this.searchedFor = null; + this.searching = false; + this.webSearching = false; + this.webError = null; + this.localExhausted = false; + this.webPageno = 0; + this.webExhausted = false; + } + + captureSnapshot(): SearchSnapshot { + return { + query: this.query, + hits: this.hits, + webHits: this.webHits, + searchedFor: this.searchedFor, + webError: this.webError, + localExhausted: this.localExhausted, + webPageno: this.webPageno, + webExhausted: this.webExhausted + }; + } + + restoreSnapshot(s: SearchSnapshot): void { + this.skipNextDebounce = true; + this.query = s.query; + this.hits = s.hits; + this.webHits = s.webHits; + this.searchedFor = s.searchedFor; + this.webError = s.webError; + this.localExhausted = s.localExhausted; + this.webPageno = s.webPageno; + this.webExhausted = s.webExhausted; + } +``` + +- [ ] **Step 5: Run tests, iterate until all green** + +```bash +npm test -- search-store.test +``` + +Expected: all 12 tests pass. + +- [ ] **Step 6: `npm run check`** + +```bash +npm run check +``` + +Expected: 0 errors, 0 warnings in `search.svelte.ts`. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/client/search.svelte.ts tests/unit/search-store.test.ts +git commit -m "feat(search): SearchStore fuer Live-Search mit Web-Fallback + +Extrahiert die duplizierte Such-Logik aus +page.svelte und ++layout.svelte in eine gemeinsame Klasse. Pure Datenschicht +mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown, +Snapshot) bleiben in den Komponenten." +``` + +--- + +## Task 3: Migrate `+layout.svelte` header dropdown + +**Why first:** Smaller surface than `+page.svelte`, no snapshot API, no URL sync. If the store is wrong, here we find out with less code at risk. + +**Files:** +- Modify: `src/routes/+layout.svelte:20-200` + +- [ ] **Step 1: Add import** + +At the top of `