From 98894bb8951512197b32c8d69e3b89b2a3fb8dd0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:00:22 +0200 Subject: [PATCH] docs(plan): Implementation-Plan fuer Views-Sort + Collapsibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 Tasks (Migration -> Repo -> Sort-Branch -> API -> Beacon -> URL- Helper -> Sort-Chip + Reactive Refetch -> Collapsibles -> Push&Verify) mit TDD-Schritten, exakten Filepfaden und vollstaendigem Code in jedem Step. Spec-Migrationsnummer auf 014 korrigiert (war 010 — letzte aktuelle Migration ist 013_shopping_list). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-22-views-and-collapsibles.md | 1241 +++++++++++++++++ ...026-04-22-views-and-collapsibles-design.md | 3 +- 2 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-22-views-and-collapsibles.md diff --git a/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md b/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md new file mode 100644 index 0000000..92faea6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md @@ -0,0 +1,1241 @@ +# Hauptseite: "Zuletzt angesehen" Sort + Collapsible Sections — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Zuletzt angesehen" sort option to the home page's "Alle Rezepte" list (per-profile view tracking, server-side sort) and make "Deine Favoriten" + "Zuletzt hinzugefügt" sections collapsible (per-device, persisted). + +**Architecture:** New SQLite table `recipe_views(profile_id, recipe_id, last_viewed_at)`, written to via `POST /api/recipes/[id]/view` on detail-page mount. The existing `listAllRecipesPaginated` gets a new `'viewed'` sort that LEFT-JOINs `recipe_views` and orders by `last_viewed_at DESC` with NULL recipes appended alphabetically. Collapsibles use Svelte 5 `$state` with localStorage persistence and `svelte/transition`'s `slide`. + +**Tech Stack:** SvelteKit 2 + Svelte 5 runes, better-sqlite3, vitest (jsdom + node), zod for body validation, lucide-svelte for icons. + +**Spec:** `docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md` + +--- + +## File Structure + +**Create:** +- `src/lib/server/db/migrations/014_recipe_views.sql` — schema +- `src/lib/server/recipes/views.ts` — `recordView(db, profileId, recipeId)` repo function +- `src/routes/api/recipes/[id]/view/+server.ts` — POST endpoint +- `tests/integration/recipe-views.test.ts` — DB + sort + endpoint tests + +**Modify:** +- `src/lib/server/recipes/search-local.ts` — extend `AllRecipesSort` with `'viewed'`, add optional `profileId` param to `listAllRecipesPaginated`, branch on `'viewed'` to LEFT-JOIN `recipe_views` +- `src/routes/api/recipes/all/+server.ts` — accept `profile_id` query param, pass through +- `src/routes/recipes/[id]/+page.svelte` — fire `POST /api/recipes/[id]/view` beacon in `onMount` when profile active +- `src/routes/+page.svelte` — add `'viewed'` to `ALL_SORTS`, pass `profile_id` in all `/api/recipes/all` fetches (`loadAllMore`, `setAllSort`, `rehydrateAll`), refetch reactively when profile switches AND sort is `'viewed'`, add `collapsed` state with persistence, wrap Favoriten + Recent sections in collapsible markup + +--- + +## Task 1: Migration for `recipe_views` table + +**Files:** +- Create: `src/lib/server/db/migrations/014_recipe_views.sql` +- Test: `tests/integration/recipe-views.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/integration/recipe-views.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; + +describe('014_recipe_views migration', () => { + it('creates recipe_views table with expected columns', () => { + const db = openInMemoryForTest(); + const cols = db.prepare("PRAGMA table_info(recipe_views)").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + const byName = Object.fromEntries(cols.map((c) => [c.name, c])); + expect(byName.profile_id?.type).toBe('INTEGER'); + expect(byName.profile_id?.notnull).toBe(1); + expect(byName.profile_id?.pk).toBe(1); + expect(byName.recipe_id?.type).toBe('INTEGER'); + expect(byName.recipe_id?.pk).toBe(2); + expect(byName.last_viewed_at?.type).toBe('TEXT'); + expect(byName.last_viewed_at?.notnull).toBe(1); + }); + + it('has index on (profile_id, last_viewed_at DESC)', () => { + const db = openInMemoryForTest(); + const idxList = db + .prepare("PRAGMA index_list(recipe_views)") + .all() as Array<{ name: string }>; + expect(idxList.some((i) => i.name === 'idx_recipe_views_recent')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: FAIL — table `recipe_views` does not exist. + +- [ ] **Step 3: Create the migration file** + +Create `src/lib/server/db/migrations/014_recipe_views.sql`: + +```sql +CREATE TABLE recipe_views ( + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (profile_id, recipe_id) +); +CREATE INDEX idx_recipe_views_recent + ON recipe_views (profile_id, last_viewed_at DESC); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: PASS — both tests green. Migration is auto-discovered via `import.meta.glob` per CLAUDE.md. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/db/migrations/014_recipe_views.sql tests/integration/recipe-views.test.ts +git commit -m "feat(db): recipe_views table mit Profil-FK und Recent-Index + +Tracking-Tabelle fuer Sort-Option Zuletzt angesehen. Composite-PK +(profile_id, recipe_id) erlaubt INSERT OR REPLACE per Default-Timestamp. +Index nach profile_id + last_viewed_at DESC fuer Sort-Query. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `recordView` repository function + +**Files:** +- Create: `src/lib/server/recipes/views.ts` +- Modify: `tests/integration/recipe-views.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/integration/recipe-views.test.ts`: + +```ts +import { recordView, listViews } from '../../src/lib/server/recipes/views'; +import { createProfile } from '../../src/lib/server/profiles/repository'; + +function seedRecipe(db: ReturnType, title: string): number { + const r = db + .prepare("INSERT INTO recipe (title, created_at) VALUES (?, datetime('now')) RETURNING id") + .get(title) as { id: number }; + return r.id; +} + +describe('recordView', () => { + it('inserts a view row with default timestamp', () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + const recipeId = seedRecipe(db, 'Pasta'); + + recordView(db, profile.id, recipeId); + + const rows = listViews(db, profile.id); + expect(rows.length).toBe(1); + expect(rows[0].recipe_id).toBe(recipeId); + expect(rows[0].last_viewed_at).toMatch(/^\d{4}-\d{2}-\d{2}/); + }); + + it('updates timestamp on subsequent view of same recipe', async () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + const recipeId = seedRecipe(db, 'Pasta'); + + recordView(db, profile.id, recipeId); + const first = listViews(db, profile.id)[0].last_viewed_at; + + // tiny delay so the second timestamp differs + await new Promise((r) => setTimeout(r, 1100)); + recordView(db, profile.id, recipeId); + + const rows = listViews(db, profile.id); + expect(rows.length).toBe(1); + expect(rows[0].last_viewed_at >= first).toBe(true); + }); + + it('throws on unknown profile_id (FK)', () => { + const db = openInMemoryForTest(); + const recipeId = seedRecipe(db, 'Pasta'); + expect(() => recordView(db, 999, recipeId)).toThrow(); + }); + + it('throws on unknown recipe_id (FK)', () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + expect(() => recordView(db, profile.id, 999)).toThrow(); + }); +}); +``` + +The `listViews` export is needed only for tests — keep it small. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: FAIL — module `views.ts` does not exist. + +- [ ] **Step 3: Implement `recordView` and `listViews`** + +Create `src/lib/server/recipes/views.ts`: + +```ts +import type Database from 'better-sqlite3'; + +export function recordView( + db: Database.Database, + profileId: number, + recipeId: number +): void { + // INSERT OR REPLACE re-fires the DEFAULT (datetime('now')) on conflict, + // so subsequent views of the same recipe by the same profile bump the + // timestamp without breaking the composite PK. + db.prepare( + `INSERT OR REPLACE INTO recipe_views (profile_id, recipe_id) + VALUES (?, ?)` + ).run(profileId, recipeId); +} + +export type ViewRow = { + profile_id: number; + recipe_id: number; + last_viewed_at: string; +}; + +export function listViews( + db: Database.Database, + profileId: number +): ViewRow[] { + return db + .prepare( + `SELECT profile_id, recipe_id, last_viewed_at + FROM recipe_views + WHERE profile_id = ? + ORDER BY last_viewed_at DESC` + ) + .all(profileId) as ViewRow[]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: All 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/recipes/views.ts tests/integration/recipe-views.test.ts +git commit -m "feat(db): recordView/listViews fuer recipe_views + +INSERT OR REPLACE fuer idempotenten Bump des last_viewed_at Timestamps. +listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in +listAllRecipesPaginated via LEFT JOIN. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Sort-Erweiterung `'viewed'` in `listAllRecipesPaginated` + +**Files:** +- Modify: `src/lib/server/recipes/search-local.ts` +- Modify: `tests/integration/recipe-views.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/integration/recipe-views.test.ts`: + +```ts +import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local'; + +describe("listAllRecipesPaginated sort='viewed'", () => { + it('puts recently-viewed recipes first, NULLs alphabetically last', async () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + const recipeA = seedRecipe(db, 'Apfelkuchen'); + const recipeB = seedRecipe(db, 'Brokkoli'); + const recipeC = seedRecipe(db, 'Couscous'); + + // View order: B then A. C never viewed. + recordView(db, profile.id, recipeB); + await new Promise((r) => setTimeout(r, 1100)); + recordView(db, profile.id, recipeA); + + const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, profile.id); + expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeC]); + }); + + it('falls back to alphabetical when profileId is null', () => { + const db = openInMemoryForTest(); + const recipeC = seedRecipe(db, 'Couscous'); + const recipeA = seedRecipe(db, 'Apfelkuchen'); + const recipeB = seedRecipe(db, 'Brokkoli'); + + const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, null); + expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']); + }); + + it('keeps existing sorts working unchanged', () => { + const db = openInMemoryForTest(); + seedRecipe(db, 'Couscous'); + seedRecipe(db, 'Apfelkuchen'); + seedRecipe(db, 'Brokkoli'); + + const hits = listAllRecipesPaginated(db, 'name', 50, 0); + expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: FAIL — `'viewed'` not a valid `AllRecipesSort`, OR signature mismatch (5th param not accepted). + +- [ ] **Step 3: Extend the sort enum + signature + query** + +Modify `src/lib/server/recipes/search-local.ts`: + +Replace the `AllRecipesSort` type and `listAllRecipesPaginated` function with: + +```ts +export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed'; + +export function listAllRecipesPaginated( + db: Database.Database, + sort: AllRecipesSort, + limit: number, + offset: number, + profileId: number | null = null +): SearchHit[] { + // 'viewed' branch needs a JOIN against recipe_views — diverges from the + // simpler ORDER-BY-only path. We keep it in a separate prepare for + // clarity. Without profileId, fall back to alphabetical so the + // sort-chip still produces a sensible list (matches Sektion 2 of the + // spec). + if (sort === 'viewed' && profileId !== null) { + 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 + LEFT JOIN recipe_views v + ON v.recipe_id = r.id AND v.profile_id = ? + ORDER BY CASE WHEN v.last_viewed_at IS NULL THEN 1 ELSE 0 END, + v.last_viewed_at DESC, + r.title COLLATE NOCASE ASC + LIMIT ? OFFSET ?` + ) + .all(profileId, limit, offset) as 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, 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' + }; + // Without profile, 'viewed' degrades to alphabetical. + const effectiveSort = sort === 'viewed' ? 'name' : sort; + 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[effectiveSort]} + LIMIT ? OFFSET ?` + ) + .all(limit, offset) as SearchHit[]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: All 9 tests PASS. + +Run: `npm run check` to make sure the type narrowing still compiles. +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/recipes/search-local.ts tests/integration/recipe-views.test.ts +git commit -m "feat(search): sort=viewed in listAllRecipesPaginated + +Neuer Sort 'viewed' macht LEFT JOIN gegen recipe_views, ordert nach +last_viewed_at DESC mit alphabetischem Tiebreaker. NULL-Recipes (nie +angesehen) landen alphabetisch sortiert hinter den angesehenen +(CASE-NULL-last statt SQLite 3.30+ NULLS LAST). + +Ohne profileId faellt der Sort auf alphabetisch zurueck — Sort-Chip +bleibt klickbar, ergibt aber sinnvolles Default-Verhalten ohne +aktiviertes Profil. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: API endpoint `POST /api/recipes/[id]/view` + +**Files:** +- Create: `src/routes/api/recipes/[id]/view/+server.ts` +- Modify: `tests/integration/recipe-views.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/integration/recipe-views.test.ts`: + +```ts +import { POST as viewPost } from '../../src/routes/api/recipes/[id]/view/+server'; + +function makeRequest(profile_id: number | string | null) { + return new Request('http://localhost/api/recipes/1/view', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(profile_id === null ? {} : { profile_id }) + }); +} + +describe('POST /api/recipes/[id]/view', () => { + it('records a view on success', async () => { + const db = openInMemoryForTest(); + const profile = createProfile(db, 'Test'); + const recipeId = seedRecipe(db, 'Pasta'); + + const res = await viewPost({ + request: makeRequest(profile.id), + params: { id: String(recipeId) }, + // getDb is bound to the in-memory db via vi.spyOn — see helper + } as never); + expect(res.status).toBe(204); + + expect(listViews(db, profile.id).length).toBe(1); + }); +}); +``` + +The endpoint will use `getDb()` so the test needs to swap the module's +db. Use the existing pattern from `tests/integration/recipes-post.test.ts` +— check it first to see the recommended mock approach. If `getDb` is +not easily swappable, adjust the test to call the underlying repo +function directly OR introduce a `dbOverride` parameter pattern that +matches the rest of the codebase. + +> **Note for the implementer:** check `tests/integration/recipes-post.test.ts` +> first. If it does NOT mock `getDb`, then this test cannot easily call +> the +server handler. In that case, scope the endpoint test to verify +> just the validation logic (a thin wrapper around `validateBody`) and +> delete the success-path assertion above — the success path is already +> covered by Task 2's `recordView` tests, plus the route file's body +> here is just glue. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Create the endpoint** + +Create `src/routes/api/recipes/[id]/view/+server.ts`: + +```ts +import type { RequestHandler } from './$types'; +import { z } from 'zod'; +import { error } from '@sveltejs/kit'; +import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; +import { recordView } from '$lib/server/recipes/views'; + +const Schema = z.object({ + profile_id: z.number().int().positive() +}); + +export const POST: RequestHandler = async ({ params, request }) => { + const recipeId = Number(params.id); + if (!Number.isInteger(recipeId) || recipeId <= 0) { + error(400, { message: 'Invalid recipe id' }); + } + const body = validateBody(await request.json().catch(() => null), Schema); + + try { + recordView(getDb(), body.profile_id, recipeId); + } catch (e) { + // FK violation (unknown profile or recipe) → 404 + if (e instanceof Error && /FOREIGN KEY constraint failed/i.test(e.message)) { + error(404, { message: 'Recipe or profile not found' }); + } + throw e; + } + + return new Response(null, { status: 204 }); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: PASS (or follow the note in Step 1 about scoping the test). + +Run: `npm run check` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/api/recipes/[id]/view/+server.ts tests/integration/recipe-views.test.ts +git commit -m "feat(api): POST /api/recipes/[id]/view fuer View-Beacon + +Body { profile_id } via zod validiert. FK-Violation (unbekanntes +Profil oder Rezept) wird zu 404 normalisiert. Erfolg liefert 204 +ohne Body — fire-and-forget vom Client. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Recipe-Detail-Seite triggert View-Beacon + +**Files:** +- Modify: `src/routes/recipes/[id]/+page.svelte` + +- [ ] **Step 1: Read the current onMount block** + +Run: `grep -n "onMount" src/routes/recipes/[id]/+page.svelte` + +Note the line numbers. The recipe ID is exposed via `data.recipe.id` (or similar). Confirm by reading the imports / `let { data } = $props()` block at the top of the file. + +- [ ] **Step 2: Add the beacon import** + +In `src/routes/recipes/[id]/+page.svelte`, add to the existing `import { profileStore } from '$lib/client/profile.svelte';` import (or add the import if not present). + +- [ ] **Step 3: Add the beacon call inside onMount** + +Inside the existing `onMount(...)` block (or add a new one if absent), add at the END of the function: + +```ts + // Track view per active profile (fire-and-forget). Skipped when no + // profile is active — without a profile we'd just be writing rows + // nobody can sort against later. + if (profileStore.active) { + void fetch(`/api/recipes/${data.recipe.id}/view`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile_id: profileStore.active.id }) + }); + } +``` + +> **Note:** if the recipe id lives at a different path on `data` (e.g. +> `data.id` or `data.recipe?.id`), substitute accordingly. Check the +> file's `+page.server.ts` for the exact shape. + +- [ ] **Step 4: Manual smoke test (no automated test for the beacon — too +much mock surface for too little signal)** + +Run: `npm run dev` +Open: `http://localhost:5173/` +Steps: +1. Pick a profile via the profile switcher +2. Click any recipe +3. In another terminal: `sqlite3 data/kochwas.db "SELECT * FROM recipe_views;"` + Expected: one row matching the clicked recipe and selected profile + +If you don't have a local profile, create one via the UI first. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/recipes/[id]/+page.svelte +git commit -m "feat(recipe): View-Beacon beim oeffnen der Detailseite + +Fire-and-forget POST /api/recipes/[id]/view in onMount, nur wenn +profileStore.active gesetzt. Schreibt last_viewed_at fuers Profil — +Voraussetzung fuer den Sort 'Zuletzt angesehen' auf der Hauptseite. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: API `/api/recipes/all` nimmt `profile_id` + +**Files:** +- Modify: `src/routes/api/recipes/all/+server.ts` +- Modify: `tests/integration/recipe-views.test.ts` + +- [ ] **Step 1: Write the failing test (HTTP-level)** + +Append to `tests/integration/recipe-views.test.ts`: + +```ts +import { GET as allGet } from '../../src/routes/api/recipes/all/+server'; + +describe('GET /api/recipes/all?sort=viewed&profile_id=N', () => { + it('passes profile_id to the sort and returns viewed-order', async () => { + // Reuses the same in-memory + getDb pattern as Task 4. If getDb + // can't be mocked here either, scope this test to verifying the + // query-param parsing only by calling listAllRecipesPaginated + // directly (already covered in Task 3) and just smoke-test that + // GET handler accepts the profile_id param without 400. + const url = new URL('http://localhost/api/recipes/all?sort=viewed&profile_id=1&limit=10'); + const res = await allGet({ url } as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.sort).toBe('viewed'); + expect(Array.isArray(body.hits)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: FAIL — `'viewed'` not in `VALID_SORTS`, returns 400. + +- [ ] **Step 3: Update the endpoint** + +Replace the entire content of `src/routes/api/recipes/all/+server.ts` with: + +```ts +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([ + 'name', + 'rating', + 'cooked', + 'created', + 'viewed' +]); + +function parseProfileId(raw: string | null): number | null { + if (!raw) return null; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : null; +} + +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' }); + // Cap is 200 (not 10's typical paging step) to support snapshot-based + // pagination restore on /+page.svelte: when the user navigates back + // after deep infinite-scroll, we re-hydrate the full loaded count in + // one round-trip so document height matches and scroll-restore lands. + const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? 10))); + const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); + const profileId = parseProfileId(url.searchParams.get('profile_id')); + const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset, profileId); + return json({ sort: sortRaw, limit, offset, hits }); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/integration/recipe-views.test.ts` +Expected: PASS. + +Also re-run all integration tests to make sure nothing broke: +Run: `npm test` +Expected: all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/api/recipes/all/+server.ts tests/integration/recipe-views.test.ts +git commit -m "feat(api): /api/recipes/all akzeptiert sort=viewed + profile_id + +VALID_SORTS um 'viewed' erweitert. parseProfileId-Helper analog zu +/api/wishlist. Wert wird an listAllRecipesPaginated als 5. Param +durchgereicht. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Hauptseite ruft `/api/recipes/all` mit `profile_id` auf + +**Files:** +- Modify: `src/routes/+page.svelte` + +- [ ] **Step 1: Read the three fetch sites** + +Run: `grep -n "/api/recipes/all" src/routes/+page.svelte` + +You should find three call sites: +- inside `loadAllMore()` (line ~102) +- inside `setAllSort()` (line ~127) +- inside `rehydrateAll()` (line ~80) + +- [ ] **Step 2: Add a helper that builds the URL with profile_id** + +In `src/routes/+page.svelte`, add this helper inside the `