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

@@ -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');
});
});