Files
kochwas/src/lib/client/search.svelte.ts
hsiegeln c45ef2a613
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
fix(search): runSearch bricht pending Debounce ab
Enter waehrend Debounce-Fenster feuerte bislang eine zweite Fetch
fuer dieselbe Query. Race-Guard greift nicht, weil q identisch ist.
runSearch clearTimeout am Anfang behebt's, neuer Unit-Test sichert es.

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

226 lines
7.1 KiB
TypeScript

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));
}
runDebounced(): void {
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> {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = null;
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;
}
}
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;
}
}
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;
}
}