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

32 KiB
Raw Blame History

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):

// 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)

// @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"
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:

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:

  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
  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
  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
npm test -- search-store.test

Expected: all 12 tests pass.

  • Step 6: npm run check
npm run check

Expected: 0 errors, 0 warnings in search.svelte.ts.

  • Step 7: Commit
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>:

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:

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
$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
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)
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)
function pickHit() {
  navOpen = false;
  navStore.reset();
}
  • Step 7: Update afterNavigate (lines 192+)
afterNavigate(() => {
  navStore.reset();
  navOpen = false;
  menuOpen = false;
  // ... rest of existing body (wishlist refresh etc.)
});
  • Step 8: Update the template

Every navQuerynavStore.query, every navHitsnavStore.hits, etc. This is a mechanical rename — use find+replace scoped to src/routes/+layout.svelte only.

Mapping:

  • navQuerynavStore.query
  • navHitsnavStore.hits
  • navWebHitsnavStore.webHits
  • navSearchingnavStore.searching
  • navWebSearchingnavStore.webSearching
  • navWebErrornavStore.webError
  • navLocalExhaustednavStore.localExhausted
  • navWebPagenonavStore.webPageno (if referenced in template)
  • navWebExhaustednavStore.webExhausted
  • navLoadingMorenavStore.loadingMore

bind:value={navQuery} on the <input>bind:value={navStore.query}.

  • Step 9: Run checks
npm run check
npm test

Both must be clean.

  • Step 10: Smoke-test dev server manually
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
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

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:

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)
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 $effects (filter-change + query-change) with two one-liners

Remove lines 188199 (filter-change effect) and lines 322347 (query-change effect).

Add:

$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
$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
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
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)

querystore.query, hitsstore.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
npm run check
npm test
  • Step 11: Verify file is shorter than before
wc -l src/routes/+page.svelte

Expected: under 700 lines (was 808). Target from roadmap: under 700 L.

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

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

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

npm test

Expected: all pass (previous count + 12 new SearchStore tests).

  • Step 2: npm run check full repo
npm run check

Expected: 0 errors, 0 warnings.

  • Step 3: git diff main...HEAD review
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
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.