From 09c0270c646d1e28a9fc921e1c1430bd194a7c98 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:14:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(home):=20=E2=80=9EAlle=20Rezepte"-Sektion?= =?UTF-8?q?=20mit=20Sortierung=20und=20Endless-Scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Sektion unter „Zuletzt hinzugefügt": sortierbar nach Name, Bewertung, zuletzt gekocht und Hinzugefügt. Auswahl persistiert in localStorage (kochwas.allSort). - Neuer Endpoint GET /api/recipes/all?sort=name&limit=10&offset=0. - listAllRecipesPaginated(db, sort, limit, offset) im repository: NULLS-last-Emulation per CASE für rating/cooked — funktioniert auch auf älteren SQLite-Versionen. - Endless Scroll per IntersectionObserver auf ein Sentinel-Element am Listen-Ende (rootMargin 200px, damit schon vor dem harten Rand nachgeladen wird). Pagesize 10. - 4 neue Tests: Name-Sort, Rating-Sort, Cooked-Sort, Pagination-Offset. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/recipes/search-local.ts | 37 ++++++ src/routes/+page.svelte | 173 +++++++++++++++++++++++++ src/routes/api/recipes/all/+server.ts | 18 +++ tests/integration/search-local.test.ts | 49 ++++++- 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/routes/api/recipes/all/+server.ts diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index d531811..bfe0f7f 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -95,6 +95,43 @@ export function listAllRecipes(db: Database.Database): SearchHit[] { .all() as SearchHit[]; } +export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created'; + +export function listAllRecipesPaginated( + db: Database.Database, + sort: AllRecipesSort, + limit: number, + offset: number +): SearchHit[] { + // NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST + // zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und + // CASE ist überall zuverlässig. + const orderBy: Record = { + name: 'r.title COLLATE NOCASE ASC', + rating: + 'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' + + '(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC', + cooked: + 'CASE WHEN (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' + + '(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC', + created: 'r.created_at DESC, r.id DESC' + }; + return db + .prepare( + `SELECT r.id, + r.title, + r.description, + r.image_path, + r.source_domain, + (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars, + (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at + FROM recipe r + ORDER BY ${orderBy[sort]} + LIMIT ? OFFSET ?` + ) + .all(limit, offset) as SearchHit[]; +} + export function listFavoritesForProfile( db: Database.Database, profileId: number diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 51fe711..50b0bb7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -30,6 +30,21 @@ let skipNextSearch = false; let debounceTimer: ReturnType | null = null; + const ALL_PAGE = 10; + type AllSort = 'name' | 'rating' | 'cooked' | 'created'; + const ALL_SORTS: { value: AllSort; label: string }[] = [ + { value: 'name', label: 'Name' }, + { value: 'rating', label: 'Bewertung' }, + { value: 'cooked', label: 'Zuletzt gekocht' }, + { value: 'created', label: 'Hinzugefügt' } + ]; + let allRecipes = $state([]); + let allSort = $state('name'); + let allExhausted = $state(false); + let allLoading = $state(false); + let allSentinel: HTMLElement | undefined = $state(); + let allObserver: IntersectionObserver | null = null; + type SearchSnapshot = { query: string; hits: SearchHit[]; @@ -71,6 +86,31 @@ recent = body.hits; } + async function loadAllMore() { + if (allLoading || allExhausted) return; + allLoading = true; + try { + const res = await fetch( + `/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}` + ); + if (!res.ok) return; + const body = await res.json(); + const more = body.hits as SearchHit[]; + const seen = new Set(allRecipes.map((h) => h.id)); + const deduped = more.filter((h) => !seen.has(h.id)); + allRecipes = [...allRecipes, ...deduped]; + if (more.length < ALL_PAGE) allExhausted = true; + } finally { + allLoading = false; + } + } + + function resetAllRecipes() { + allRecipes = []; + allExhausted = false; + allLoading = false; + } + async function loadFavorites(profileId: number) { const res = await fetch(`/api/recipes/favorites?profile_id=${profileId}`); if (!res.ok) { @@ -89,6 +129,44 @@ if (urlQ) query = urlQ; void loadRecent(); void searchFilterStore.load(); + const saved = localStorage.getItem('kochwas.allSort'); + if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) { + allSort = saved as AllSort; + } + void loadAllMore(); + }); + + // Sort-Change → liste zurücksetzen und neu laden. + $effect(() => { + const s = allSort; + if (typeof window === 'undefined') return; + localStorage.setItem('kochwas.allSort', s); + // Nur neu laden, wenn wir schon geladen hatten (sonst doppelter Initial-Call). + if (allRecipes.length > 0 || allExhausted) { + resetAllRecipes(); + void loadAllMore(); + } + }); + + // IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen. + $effect(() => { + if (typeof window === 'undefined') return; + if (!allSentinel) return; + if (allExhausted) return; + if (allObserver) allObserver.disconnect(); + allObserver = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) void loadAllMore(); + } + }, + { rootMargin: '200px' } + ); + allObserver.observe(allSentinel); + return () => { + allObserver?.disconnect(); + allObserver = null; + }; }); // Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen, @@ -422,6 +500,52 @@ {/if} +
+
+

Alle Rezepte

+ +
+ {#if allRecipes.length === 0 && allExhausted} +

Noch keine Rezepte gespeichert.

+ {:else} + + {#if !allExhausted} + + {/if} + {/if} +
{/if}