Files
kochwas/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md
hsiegeln 98894bb895 docs(plan): Implementation-Plan fuer Views-Sort + Collapsibles
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) <noreply@anthropic.com>
2026-04-22 14:00:22 +02:00

1242 lines
38 KiB
Markdown

# 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) <noreply@anthropic.com>"
```
---
## 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<typeof openInMemoryForTest>, 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) <noreply@anthropic.com>"
```
---
## 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<Exclude<AllRecipesSort, 'viewed'>, 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<AllRecipesSort>([
'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) <noreply@anthropic.com>"
```
---
## 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 `<script>` block (anywhere after the imports):
```ts
function buildAllUrl(sort: AllSort, limit: number, offset: number): string {
const profileId = profileStore.active?.id;
const profilePart = profileId ? `&profile_id=${profileId}` : '';
return `/api/recipes/all?sort=${sort}&limit=${limit}&offset=${offset}${profilePart}`;
}
```
- [ ] **Step 3: Replace the three fetch URLs with the helper**
In `loadAllMore()`, replace:
```ts
const res = await fetch(
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
);
```
with:
```ts
const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));
```
In `setAllSort()`, replace:
```ts
const res = await fetch(
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);
```
with:
```ts
const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));
```
In `rehydrateAll()`, replace:
```ts
const res = await fetch(`/api/recipes/all?sort=${sort}&limit=${count}&offset=0`);
```
with:
```ts
const res = await fetch(buildAllUrl(sort, count, 0));
```
- [ ] **Step 4: Verify no other call sites exist**
Run: `grep -n "/api/recipes/all" src/routes/+page.svelte`
Expected: only the helper itself plus the three replaced lines should remain (the three lines now using `buildAllUrl`).
- [ ] **Step 5: Type + test sanity**
Run: `npm run check`
Expected: 0 errors.
Run: `npm test`
Expected: all green.
- [ ] **Step 6: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "feat(home): profile_id in alle /api/recipes/all-Fetches
buildAllUrl-Helper haengt profile_id an wenn ein Profil aktiv ist;
nutzt es loadAllMore, setAllSort und rehydrateAll. Voraussetzung fuer
sort=viewed (Server braucht profile_id fuer den View-Join).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: `'viewed'` Sort-Chip + Profile-Switch-Refetch
**Files:**
- Modify: `src/routes/+page.svelte`
- [ ] **Step 1: Add the sort option**
In `src/routes/+page.svelte`, find:
```ts
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
```
Replace with:
```ts
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
```
Find:
```ts
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' }
];
```
Replace with:
```ts
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' },
{ value: 'viewed', label: 'Zuletzt angesehen' }
];
```
Also update the localStorage validation list in `onMount`:
Find:
```ts
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
```
Replace with:
```ts
if (saved && ['name', 'rating', 'cooked', 'created', 'viewed'].includes(saved)) {
```
- [ ] **Step 2: Add reactive refetch on profile switch (only for sort='viewed')**
After the existing `$effect` that watches `searchFilterStore.active`, add:
```ts
// 'viewed' sort depends on the active profile. When the user switches
// profiles, refetch with the new profile_id so the list reflects what
// the *current* profile has viewed. Other sorts are profile-agnostic
// and don't need this.
$effect(() => {
// Read profile id reactively so the effect re-runs on switch.
const id = profileStore.activeId;
if (allSort !== 'viewed') return;
if (allLoading) return;
// Re-fetch the first page; rehydrate would re-load the previous
// depth, but a sort-context change should reset to page 1 anyway.
void (async () => {
allLoading = true;
try {
const res = await fetch(buildAllUrl('viewed', ALL_PAGE, 0));
if (!res.ok) return;
const body = await res.json();
const hits = body.hits as SearchHit[];
allRecipes = hits;
allExhausted = hits.length < ALL_PAGE;
} finally {
allLoading = false;
}
// 'id' is referenced so $effect tracks it as a dep:
void id;
})();
});
```
> **Note:** check that `profileStore` exposes a reactive `activeId` getter
> (numeric or null). If only `active` is reactive, use `profileStore.active?.id`
> instead. The pattern in the existing favorites $effect is the model.
- [ ] **Step 3: Manual smoke test**
Run: `npm run dev`
Open: `http://localhost:5173/`
Steps:
1. Pick profile A → click 2 different recipes → return to home
2. Click the "Zuletzt angesehen" sort chip → expect those 2 recipes at the top
3. Switch to profile B → expect the list to refetch and show different (or no) viewed recipes at the top
- [ ] **Step 4: Type + test sanity**
Run: `npm run check`
Expected: 0 errors.
Run: `npm test`
Expected: all green.
- [ ] **Step 5: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "feat(home): Sort-Chip 'Zuletzt angesehen' + Profile-Switch-Refetch
Neuer Wert 'viewed' im AllSort-Enum + ALL_SORTS-Array. localStorage-
Whitelist ergaenzt. Reactive \$effect lauscht auf profileStore.activeId
und refetcht offset=0 nur wenn aktueller Sort 'viewed' ist — andere
Sortierungen sind profilunabhaengig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: Collapsibles für „Deine Favoriten" + „Zuletzt hinzugefügt"
**Files:**
- Modify: `src/routes/+page.svelte`
- [ ] **Step 1: Add state + persistence helpers**
In `src/routes/+page.svelte`, add to the imports near the top:
```ts
import { slide } from 'svelte/transition';
import { ChevronDown } from 'lucide-svelte';
```
(`ChevronDown` may already be imported at the layout level — but each
component needs its own import.)
Add state declarations near the other `$state` lines (after `allObserver`):
```ts
type CollapseKey = 'favorites' | 'recent';
const COLLAPSE_STORAGE_KEY = 'kochwas.collapsed.sections';
let collapsed = $state<Record<CollapseKey, boolean>>({
favorites: false,
recent: false
});
function toggleCollapsed(key: CollapseKey) {
collapsed[key] = !collapsed[key];
if (typeof localStorage !== 'undefined') {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapsed));
}
}
```
Inside `onMount`, add at the end (after the existing localStorage reads):
```ts
const rawCollapsed = localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (rawCollapsed) {
try {
const parsed = JSON.parse(rawCollapsed) as Partial<Record<CollapseKey, boolean>>;
if (typeof parsed.favorites === 'boolean') collapsed.favorites = parsed.favorites;
if (typeof parsed.recent === 'boolean') collapsed.recent = parsed.recent;
} catch {
// Corrupt JSON — keep defaults (both open).
}
}
```
- [ ] **Step 2: Wrap the Favoriten section**
Find the existing favorites block (search for `Deine Favoriten`):
```svelte
{#if profileStore.active && favorites.length > 0}
<section class="listing">
<h2>Deine Favoriten</h2>
<ul class="cards">
{#each favorites as r (r.id)}
...
{/each}
</ul>
</section>
{/if}
```
Replace with:
```svelte
{#if profileStore.active && favorites.length > 0}
<section class="listing">
<button
type="button"
class="section-head"
onclick={() => toggleCollapsed('favorites')}
aria-expanded={!collapsed.favorites}
>
<ChevronDown
size={18}
strokeWidth={2.2}
class={collapsed.favorites ? 'chev rotated' : 'chev'}
/>
<h2>Deine Favoriten</h2>
<span class="count">{favorites.length}</span>
</button>
{#if !collapsed.favorites}
<div transition:slide={{ duration: 180 }}>
<ul class="cards">
{#each favorites as r (r.id)}
<li class="card-wrap">
<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>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</section>
{/if}
```
- [ ] **Step 3: Wrap the „Zuletzt hinzugefügt" section the same way**
Find:
```svelte
{#if recent.length > 0}
<section class="listing">
<h2>Zuletzt hinzugefügt</h2>
<ul class="cards">
{#each recent as r (r.id)}
...
{/each}
</ul>
</section>
{/if}
```
Replace with:
```svelte
{#if recent.length > 0}
<section class="listing">
<button
type="button"
class="section-head"
onclick={() => toggleCollapsed('recent')}
aria-expanded={!collapsed.recent}
>
<ChevronDown
size={18}
strokeWidth={2.2}
class={collapsed.recent ? 'chev rotated' : 'chev'}
/>
<h2>Zuletzt hinzugefügt</h2>
<span class="count">{recent.length}</span>
</button>
{#if !collapsed.recent}
<div transition:slide={{ duration: 180 }}>
<ul class="cards">
{#each recent as r (r.id)}
<li class="card-wrap">
<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>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
<button
class="dismiss"
aria-label="Aus Zuletzt-hinzugefügt entfernen"
onclick={(e) => dismissFromRecent(r.id, e)}
>
<X size={16} strokeWidth={2.5} />
</button>
</li>
{/each}
</ul>
</div>
{/if}
</section>
{/if}
```
- [ ] **Step 4: Add CSS for the section header + chevron**
In the `<style>` block of `+page.svelte`, add (near the existing `.listing` rules):
```css
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.25rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: inherit;
min-height: 44px;
margin-bottom: 0.4rem;
}
.section-head:hover {
background: #f4f8f5;
}
.section-head h2 {
margin: 0;
font-size: 1.05rem;
color: #444;
font-weight: 600;
}
.section-head .count {
margin-left: auto;
color: #888;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
:global(.chev) {
color: #2b6a3d;
flex-shrink: 0;
transition: transform 180ms;
}
:global(.chev.rotated) {
transform: rotate(-90deg);
}
```
(`:global(...)` because lucide-svelte renders an `<svg>` inside its own
component scope; the class lives on that svg.)
- [ ] **Step 5: Manual smoke test**
Run: `npm run dev`
Open: `http://localhost:5173/`
Steps:
1. Both sections visible and open → click each header → both collapse with slide
2. Click again → both expand with slide
3. Reload the page → collapsed/expanded state matches what you left
4. Clear localStorage in DevTools → reload → both default to open
5. Verify "Alle Rezepte" is NOT collapsible (Hauptliste bleibt sichtbar)
- [ ] **Step 6: Type + test sanity**
Run: `npm run check`
Expected: 0 errors.
Run: `npm test`
Expected: all green.
- [ ] **Step 7: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "feat(home): Collapsible Sections fuer Favoriten + Zuletzt hinzugefuegt
Header als <button> mit Chevron + Count-Pill, slide-Transition (180ms).
State in localStorage unter kochwas.collapsed.sections — JSON-Map
{favorites, recent}, default beide offen, corrupt-JSON faellt auf
Default zurueck.
Alle Rezepte bleibt absichtlich nicht-collapsibel — Hauptliste, immer
sichtbar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 10: Push + Verify Deploy
- [ ] **Step 1: Push all commits**
```bash
git push
```
- [ ] **Step 2: Wait for CI build**
Check Gitea Actions: https://gitea.siegeln.net/claude/kochwas/actions
Wait until the latest run for the most recent commit shows `success`.
On the Pi this is typically 3-5 min for arm64.
- [ ] **Step 3: Verify on kochwas-dev**
Open: https://kochwas-dev.siegeln.net/
Manual checks:
1. Pick a profile, view 3 recipes, return to home
2. Click "Zuletzt angesehen" sort chip — those 3 should be at the top in reverse-view-order
3. Switch profiles → list refetches; if other profile has different views, they appear
4. Click "Deine Favoriten" header — section collapses; click again — expands
5. Click "Zuletzt hinzugefügt" header — same
6. Reload — collapsed states restored
If something's off, fix in a small follow-up commit.
---
## Self-Review Notes
**Spec coverage check:**
| Spec Requirement | Implemented in |
|---|---|
| Migration `recipe_views` | Task 1 |
| `recordView` repo function | Task 2 |
| Sort `'viewed'` in DB layer | Task 3 |
| `POST /api/recipes/[id]/view` | Task 4 |
| Client beacon in detail page | Task 5 |
| API accepts `profile_id` | Task 6 |
| Home passes `profile_id` everywhere | Task 7 |
| `ALL_SORTS` adds `'viewed'` + reactive refetch | Task 8 |
| Collapsible Favoriten + Recent + persistence | Task 9 |
| Test strategy (migration, sort logic, endpoint) | Tasks 1, 3, 4, 6 |
| Snapshot rehydrate passes profile_id | Task 7 (`buildAllUrl` is used in `rehydrateAll`) |
All spec requirements have a task. No placeholders. Type names consistent
across tasks (`AllSort`, `CollapseKey`). Profile access goes through
`profileStore.active?.id` consistently.
**Out of scope:**
- "Alle Rezepte" stays uncollapsible (explicit in spec)
- No view tracking when no profile is active (explicit in spec)
- No analytics dashboard for views (not asked for)