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) <noreply@anthropic.com>
This commit is contained in:
967
docs/superpowers/plans/2026-04-19-search-state-store.md
Normal file
967
docs/superpowers/plans/2026-04-19-search-state-store.md
Normal file
@@ -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<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.runDebounced(store.query))`. Handles debounce + race-guard. */
|
||||||
|
runDebounced(q: string): 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(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<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(_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<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 52–109) with a 3-line `$effect`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
$effect(() => {
|
||||||
|
navStore.runDebounced(navStore.query);
|
||||||
|
// navOpen follows query length: open while typing, close when cleared.
|
||||||
|
if (navStore.query.trim().length > 3) navOpen = true;
|
||||||
|
else navOpen = false;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `loadMoreNav` function (lines 111–159) 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 161–167)**
|
||||||
|
|
||||||
|
```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 185–190)**
|
||||||
|
|
||||||
|
```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 17–32)**
|
||||||
|
|
||||||
|
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 224–227).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewire the `Snapshot<>` API (lines 50–83)**
|
||||||
|
|
||||||
|
```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 188–199 (filter-change effect) and lines 322–347 (query-change effect).
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```ts
|
||||||
|
$effect(() => {
|
||||||
|
store.runDebounced(store.query);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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 229–320)**
|
||||||
|
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user