feat(home): „Alle Rezepte"-Sektion mit Sortierung und Endless-Scroll
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 11:14:44 +02:00
parent a1d91943c6
commit 09c0270c64
4 changed files with 276 additions and 1 deletions

View File

@@ -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<AllRecipesSort, string> = {
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

View File

@@ -30,6 +30,21 @@
let skipNextSearch = false;
let debounceTimer: ReturnType<typeof setTimeout> | 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<SearchHit[]>([]);
let allSort = $state<AllSort>('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 @@
</ul>
</section>
{/if}
<section class="listing">
<div class="listing-head">
<h2>Alle Rezepte</h2>
<label class="sort-select">
Sortieren:
<select bind:value={allSort}>
{#each ALL_SORTS as s (s.value)}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</label>
</div>
{#if allRecipes.length === 0 && allExhausted}
<p class="muted">Noch keine Rezepte gespeichert.</p>
{:else}
<ul class="cards">
{#each allRecipes as r (r.id)}
<li>
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={36} /></div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
<div class="meta-line">
{#if r.avg_stars !== null}
<span class="stars">{r.avg_stars.toFixed(1)}</span>
{/if}
{#if r.source_domain}
<span class="domain">{r.source_domain}</span>
{/if}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if !allExhausted}
<div bind:this={allSentinel} class="sentinel" aria-hidden="true">
{#if allLoading}<span class="loading">Lade …</span>{/if}
</div>
{/if}
{/if}
</section>
{/if}
<style>
@@ -482,6 +606,55 @@
color: #444;
margin: 0 0 0.75rem;
}
.listing-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.listing-head h2 {
margin: 0;
}
.sort-select {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: #666;
}
.sort-select select {
padding: 0.4rem 0.6rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
background: white;
min-height: 36px;
font-size: 0.9rem;
}
.meta-line {
display: flex;
gap: 0.4rem;
font-size: 0.8rem;
color: #888;
margin-top: 0.25rem;
align-items: center;
flex-wrap: wrap;
}
.stars {
color: #2b6a3d;
font-weight: 600;
}
.sentinel {
min-height: 40px;
display: grid;
place-items: center;
padding: 1rem 0;
}
.loading {
color: #888;
font-size: 0.85rem;
}
.muted {
color: #888;
text-align: center;

View File

@@ -0,0 +1,18 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import {
listAllRecipesPaginated,
type AllRecipesSort
} from '$lib/server/recipes/search-local';
const VALID_SORTS = new Set<AllRecipesSort>(['name', 'rating', 'cooked', 'created']);
export const GET: RequestHandler = async ({ url }) => {
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset);
return json({ sort: sortRaw, limit, offset, hits });
};

View File

@@ -4,7 +4,8 @@ import { insertRecipe } from '../../src/lib/server/recipes/repository';
import {
searchLocal,
listRecentRecipes,
listAllRecipes
listAllRecipes,
listAllRecipesPaginated
} from '../../src/lib/server/recipes/search-local';
import type { Recipe } from '../../src/lib/types';
@@ -147,3 +148,49 @@ describe('listAllRecipes', () => {
expect(all.length).toBe(1);
});
});
describe('listAllRecipesPaginated', () => {
it('sorts by name asc case-insensitive', () => {
const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'zucchini' }));
insertRecipe(db, recipe({ title: 'Apfel' }));
insertRecipe(db, recipe({ title: 'birnen' }));
const page = listAllRecipesPaginated(db, 'name', 10, 0);
expect(page.map((h) => h.title)).toEqual(['Apfel', 'birnen', 'zucchini']);
});
it('paginates with limit + offset', () => {
const db = openInMemoryForTest();
for (let i = 0; i < 15; i++) insertRecipe(db, recipe({ title: `R${i.toString().padStart(2, '0')}` }));
const first = listAllRecipesPaginated(db, 'name', 5, 0);
const second = listAllRecipesPaginated(db, 'name', 5, 5);
expect(first.length).toBe(5);
expect(second.length).toBe(5);
const overlap = first.filter((h) => second.some((s) => s.id === h.id));
expect(overlap.length).toBe(0);
});
it('sorts by rating desc, unrated last', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({ title: 'A' }));
const b = insertRecipe(db, recipe({ title: 'B' }));
const c = insertRecipe(db, recipe({ title: 'C' }));
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 3)').run(a);
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5)').run(c);
const page = listAllRecipesPaginated(db, 'rating', 10, 0);
// C (5) > A (3) > B (null)
expect(page.map((h) => h.title)).toEqual(['C', 'A', 'B']);
});
it('sorts by last_cooked_at desc, never-cooked last', () => {
const db = openInMemoryForTest();
const a = insertRecipe(db, recipe({ title: 'A' }));
const b = insertRecipe(db, recipe({ title: 'B' }));
db.prepare('INSERT INTO profile(name) VALUES (?)').run('P');
db.prepare('INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, 1)').run(a);
const page = listAllRecipesPaginated(db, 'cooked', 10, 0);
expect(page[0].title).toBe('A');
expect(page[1].title).toBe('B');
});
});