# 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.query; store.runDebounced(); })`. Handles debounce + race-guard. */ runDebounced(): 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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.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(); // 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(); 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(); 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(): void { // Consumer pattern: // $effect(() => { store.query; store.runDebounced(); }); // The bare `store.query` read registers the reactive dep; this method // then reads `this.query` live to kick off / debounce the search. 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 `