Files
kochwas/docs/superpowers/plans/2026-04-19-search-state-store.md
hsiegeln 4b17f19038 docs(plans): Plan-Doc auf runDebounced() ohne Parameter angleichen
Consumer-Patterns (Task 3/4) aktualisiert: $effect liest store.query
explizit und ruft runDebounced() parameterlos — matcht die live Impl
nach Commit 4edddc3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:48:50 +02:00

972 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<SearchHit[]>([]);
webHits = $state<WebHit[]>([]);
searching = $state(false);
webSearching = $state(false);
webError = $state<string | null>(null);
searchedFor = $state<string | null>(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<void>;
/** Filter-change re-search — shorter debounce. */
reSearch(): void;
/** Paginate locally, then fall back to web. Idempotent while in-flight. */
loadMore(): Promise<void>;
/** 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<typeof vi.fn>;
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<SearchHit[]>([]);
webHits = $state<WebHit[]>([]);
searching = $state(false);
webSearching = $state(false);
webError = $state<string | null>(null);
searchedFor = $state<string | null>(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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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 `<script>`:
```ts
import { SearchStore } from '$lib/client/search.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
```
(Latter is already imported — just confirm.)
- [ ] **Step 2: Replace the 11 `$state` declarations (navQuery, navHits, navWebHits, navSearching, navWebSearching, navWebError, navLocalExhausted, navWebPageno, navWebExhausted, navLoadingMore, debounceTimer) with one store instance.**
Keep these (UI-only): `navOpen`, `navContainer`, `menuOpen`, `menuContainer`.
New:
```ts
const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
```
Remove the local `filterParam()` helper — the store owns it now.
- [ ] **Step 3: Replace the big `$effect` (lines 52109) with a 3-line `$effect`**
```ts
$effect(() => {
// Bare reads register the reactive deps; then kick the store.
const q = navStore.query;
navStore.runDebounced();
// navOpen follows query length: open while typing, close when cleared.
navOpen = q.trim().length > 3;
});
```
- [ ] **Step 4: Replace `loadMoreNav` function (lines 111159) with a pass-through**
```ts
function loadMoreNav() {
return navStore.loadMore();
}
```
Or inline `onclick={() => navStore.loadMore()}` at the call-site — pick the less disruptive option when looking at the template.
- [ ] **Step 5: Replace `submitNav` (lines 161167)**
```ts
function submitNav(e: SubmitEvent) {
e.preventDefault();
const q = navStore.query.trim();
if (!q) return;
navOpen = false;
void goto(`/?q=${encodeURIComponent(q)}`);
}
```
- [ ] **Step 6: Replace `pickHit` (lines 185190)**
```ts
function pickHit() {
navOpen = false;
navStore.reset();
}
```
- [ ] **Step 7: Update `afterNavigate` (lines 192+)**
```ts
afterNavigate(() => {
navStore.reset();
navOpen = false;
menuOpen = false;
// ... rest of existing body (wishlist refresh etc.)
});
```
- [ ] **Step 8: Update the template**
Every `navQuery``navStore.query`, every `navHits``navStore.hits`, etc. This is a mechanical rename — use find+replace scoped to `src/routes/+layout.svelte` only.
Mapping:
- `navQuery``navStore.query`
- `navHits``navStore.hits`
- `navWebHits``navStore.webHits`
- `navSearching``navStore.searching`
- `navWebSearching``navStore.webSearching`
- `navWebError``navStore.webError`
- `navLocalExhausted``navStore.localExhausted`
- `navWebPageno``navStore.webPageno` (if referenced in template)
- `navWebExhausted``navStore.webExhausted`
- `navLoadingMore``navStore.loadingMore`
`bind:value={navQuery}` on the `<input>``bind:value={navStore.query}`.
- [ ] **Step 9: Run checks**
```bash
npm run check
npm test
```
Both must be clean.
- [ ] **Step 10: Smoke-test dev server manually**
```bash
npm run dev
```
Open a recipe page → type in header dropdown → verify: dropdown opens, shows local hits, falls back to web for unknown query, "+ weitere Ergebnisse" paginates, clicking a hit closes the dropdown, navigating back/forward clears the dropdown.
- [ ] **Step 11: Commit**
```bash
git add src/routes/+layout.svelte
git commit -m "refactor(layout): Header-Dropdown nutzt SearchStore
Ersetzt die 11 lokalen \$state und den Debounce-Effect durch
eine SearchStore-Instanz. Nav-Open-Toggle bleibt lokal, weil
UI-Concern."
```
---
## Task 4: Migrate `+page.svelte` home
**Why after Task 3:** The store is now field-tested. Home adds snapshot + URL sync + filter-change re-search on top.
**Files:**
- Modify: `src/routes/+page.svelte:1-371`
- [ ] **Step 1: Add imports**
```ts
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
```
- [ ] **Step 2: Remove the duplicated `$state` block (lines 1732)**
Delete: `query`, `hits`, `webHits`, `searching`, `webSearching`, `webError`, `searchedFor`, `localExhausted`, `webPageno`, `webExhausted`, `loadingMore`, `skipNextSearch`, `debounceTimer`.
Keep: `quote`, `recent`, `favorites` (not search-related), and all `all*` state (All-Recipes listing — unrelated to search).
Add:
```ts
const store = new SearchStore({
pageSize: LOCAL_PAGE,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
```
Remove the local `filterParam()` helper (lines 224227).
- [ ] **Step 3: Rewire the `Snapshot<>` API (lines 5083)**
```ts
export const snapshot: Snapshot<SearchSnapshot> = {
capture: () => store.captureSnapshot(),
restore: (s) => store.restoreSnapshot(s)
};
```
Delete the old `SearchSnapshot` local type alias (it's now imported).
- [ ] **Step 4: Replace the two search `$effect`s (filter-change + query-change) with two one-liners**
Remove lines 188199 (filter-change effect) and lines 322347 (query-change effect).
Add:
```ts
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
store.query; // register reactive dep
store.runDebounced();
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
searchFilterStore.active;
store.reSearch();
});
```
- [ ] **Step 5: Keep the URL-sync `$effect` as-is, but read from `store.query`**
```ts
$effect(() => {
if (typeof window === 'undefined') return;
const q = store.query.trim();
const url = new URL(window.location.href);
const current = url.searchParams.get('q') ?? '';
if (q === current) return;
if (q) url.searchParams.set('q', q);
else url.searchParams.delete('q');
history.replaceState(history.state, '', url.toString());
});
```
- [ ] **Step 6: Update `onMount` URL-restore**
```ts
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
if (urlQ) store.query = urlQ;
```
- [ ] **Step 7: Delete `runSearch` and `loadMore` local functions (lines 229320)**
The store provides both. Template references `loadMore` → change to `store.loadMore()`.
- [ ] **Step 8: Update `submit`**
```ts
function submit(e: SubmitEvent) {
e.preventDefault();
const q = store.query.trim();
if (q.length <= 3) return;
void store.runSearch(q);
}
```
- [ ] **Step 9: Update the template (same mechanical rename as Task 3)**
`query``store.query`, `hits``store.hits`, etc. for all 11 fields.
`bind:value={query}``bind:value={store.query}`.
`activeSearch` derived stays: `const activeSearch = $derived(store.query.trim().length > 3);`
- [ ] **Step 10: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 11: Verify file is shorter than before**
```bash
wc -l src/routes/+page.svelte
```
Expected: under 700 lines (was 808). Target from roadmap: under 700 L.
```bash
wc -l src/routes/+layout.svelte
```
Expected: under 600 lines (was 681). Target from roadmap: under 600 L.
- [ ] **Step 12: Smoke-test dev manually**
- Type "lasagne" in home → local hits appear.
- Type "pizza margherita" → web fallback.
- Deep-link `/?q=lasagne` → query restored, results visible.
- Navigate to recipe → back → home query + results preserved (snapshot).
- Change domain filter while query is active → results re-fetch with new filter.
- [ ] **Step 13: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "refactor(home): Live-Search auf SearchStore migriert
Entfernt 11 duplizierte \$state, runSearch, loadMore und beide
Debounce-Effekte. URL-Sync, Snapshot und Filter-Re-Search bleiben
hier — aber alle delegieren an den Store."
```
---
## Task 5: Remote E2E smoke (optional — only if CI deploy happens)
**Trigger:** Only run this task if CI builds the `search-state-store` branch and deploys to `kochwas-dev.siegeln.net`. Otherwise skip to Task 6.
**Files:**
- Run: existing `tests/e2e/remote/search.spec.ts`
- [ ] **Step 1: Run remote suite**
```bash
npm run test:e2e:remote -- search.spec.ts
```
Expected: 4/4 pass (existing coverage is sufficient — no new specs needed for a pure refactor).
---
## Task 6: Self-review + merge prep
**Files:**
- Review: all changed files
- [ ] **Step 1: `npm test` full suite**
```bash
npm test
```
Expected: all pass (previous count + 12 new SearchStore tests).
- [ ] **Step 2: `npm run check` full repo**
```bash
npm run check
```
Expected: 0 errors, 0 warnings.
- [ ] **Step 3: `git diff main...HEAD` review**
```bash
git diff main...HEAD --stat
git log main..HEAD --oneline
```
Expected commits:
1. `feat(search): SearchStore fuer Live-Search mit Web-Fallback`
2. `refactor(layout): Header-Dropdown nutzt SearchStore`
3. `refactor(home): Live-Search auf SearchStore migriert`
- [ ] **Step 4: Push branch**
```bash
git push -u origin search-state-store
```
CI builds branch-tagged image → user tests on `kochwas-dev.siegeln.net` → merges to main when clean.
---
## Risk Notes
- **Svelte 5 `$state` in classes:** Standard pattern in this repo (`SearchFilterStore`, `PWAStore`). Works.
- **Two instances of `SearchStore` simultaneously:** Each has its own timer + state. No shared mutable state between them — verified because the store has no static fields.
- **Snapshot restore racing with `runDebounced`:** Handled via `skipNextDebounce` flag. Same mechanism as the current `skipNextSearch` in `+page.svelte`.
- **Filter change on home while query is empty:** `reSearch()` early-exits when `q.length < minQueryLength`. Safe.
- **`afterNavigate` firing during an in-flight search:** `reset()` clears timer and mutates `query`. Any in-flight fetch will race-guard-fail on the next `if (this.query.trim() !== q) return;`. Results get dropped, which is the desired behavior.
## Deferred — NOT in this plan
- **Search-Store-Tests mit echtem Browser-`$effect`:** Would need `@sveltejs/vite-plugin-svelte` test setup with component mount. Current Vitest setup is Node-only. Skip — the injected-fetch unit tests cover the state machine.
- **Shared store instance (singleton) instead of per-consumer:** Rejected during design — would couple home and header search semantically.
- **Web-Hit-Cache im Store:** Out of scope. The roadmap explicitly scopes this phase to state extraction, not perf work.