Tabellen-Konvention im Repo ist singular — siehe Code-Review-Findings
zu Task 1 (commit 543008b). Plan und Spec angeglichen damit weitere
Tasks nicht mit dem alten Plural arbeiten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
38 KiB
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_view(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_view 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_view.sql— schemasrc/lib/server/recipes/views.ts—recordView(db, profileId, recipeId)repo functionsrc/routes/api/recipes/[id]/view/+server.ts— POST endpointtests/integration/recipe-views.test.ts— DB + sort + endpoint tests
Modify:
src/lib/server/recipes/search-local.ts— extendAllRecipesSortwith'viewed', add optionalprofileIdparam tolistAllRecipesPaginated, branch on'viewed'to LEFT-JOINrecipe_viewsrc/routes/api/recipes/all/+server.ts— acceptprofile_idquery param, pass throughsrc/routes/recipes/[id]/+page.svelte— firePOST /api/recipes/[id]/viewbeacon inonMountwhen profile activesrc/routes/+page.svelte— add'viewed'toALL_SORTS, passprofile_idin all/api/recipes/allfetches (loadAllMore,setAllSort,rehydrateAll), refetch reactively when profile switches AND sort is'viewed', addcollapsedstate with persistence, wrap Favoriten + Recent sections in collapsible markup
Task 1: Migration for recipe_view table
Files:
-
Create:
src/lib/server/db/migrations/014_recipe_view.sql -
Test:
tests/integration/recipe-views.test.ts -
Step 1: Write the failing test
Create tests/integration/recipe-views.test.ts:
import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
describe('014_recipe_view migration', () => {
it('creates recipe_view table with expected columns', () => {
const db = openInMemoryForTest();
const cols = db.prepare("PRAGMA table_info(recipe_view)").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_view)")
.all() as Array<{ name: string }>;
expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/integration/recipe-views.test.ts
Expected: FAIL — table recipe_view does not exist.
- Step 3: Create the migration file
Create src/lib/server/db/migrations/014_recipe_view.sql:
CREATE TABLE recipe_view (
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_view_recent
ON recipe_view (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
git add src/lib/server/db/migrations/014_recipe_view.sql tests/integration/recipe-views.test.ts
git commit -m "feat(db): recipe_view 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:
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
recordViewandlistViews
Create src/lib/server/recipes/views.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_view (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_view
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
git add src/lib/server/recipes/views.ts tests/integration/recipe-views.test.ts
git commit -m "feat(db): recordView/listViews fuer recipe_view
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:
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:
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_view — 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_view 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
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_view, 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:
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.tsfirst. If it does NOT mockgetDb, 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 aroundvalidateBody) and delete the success-path assertion above — the success path is already covered by Task 2'srecordViewtests, 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:
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
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:
// 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.idordata.recipe?.id), substitute accordingly. Check the file's+page.server.tsfor 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:
- Pick a profile via the profile switcher
- Click any recipe
- In another terminal:
sqlite3 data/kochwas.db "SELECT * FROM recipe_view;"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
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:
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:
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
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):
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:
const res = await fetch(
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
);
with:
const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));
In setAllSort(), replace:
const res = await fetch(
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);
with:
const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));
In rehydrateAll(), replace:
const res = await fetch(`/api/recipes/all?sort=${sort}&limit=${count}&offset=0`);
with:
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
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:
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
Replace with:
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
Find:
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:
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:
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
Replace with:
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:
// '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
profileStoreexposes a reactiveactiveIdgetter (numeric or null). If onlyactiveis reactive, useprofileStore.active?.idinstead. The pattern in the existing favorites $effect is the model.
- Step 3: Manual smoke test
Run: npm run dev
Open: http://localhost:5173/
Steps:
- Pick profile A → click 2 different recipes → return to home
- Click the "Zuletzt angesehen" sort chip → expect those 2 recipes at the top
- 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
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:
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):
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):
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):
{#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:
{#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:
{#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:
{#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):
.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:
- Both sections visible and open → click each header → both collapse with slide
- Click again → both expand with slide
- Reload the page → collapsed/expanded state matches what you left
- Clear localStorage in DevTools → reload → both default to open
- 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
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
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:
- Pick a profile, view 3 recipes, return to home
- Click "Zuletzt angesehen" sort chip — those 3 should be at the top in reverse-view-order
- Switch profiles → list refetches; if other profile has different views, they appear
- Click "Deine Favoriten" header — section collapses; click again — expands
- Click "Zuletzt hinzugefügt" header — same
- 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_view |
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)