diff --git a/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md b/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md index 92faea6..5bb64ae 100644 --- a/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md +++ b/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md @@ -4,7 +4,7 @@ **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`. +**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. @@ -15,23 +15,23 @@ ## File Structure **Create:** -- `src/lib/server/db/migrations/014_recipe_views.sql` — schema +- `src/lib/server/db/migrations/014_recipe_view.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/lib/server/recipes/search-local.ts` — extend `AllRecipesSort` with `'viewed'`, add optional `profileId` param to `listAllRecipesPaginated`, branch on `'viewed'` to LEFT-JOIN `recipe_view` - `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 +## Task 1: Migration for `recipe_view` table **Files:** -- Create: `src/lib/server/db/migrations/014_recipe_views.sql` +- Create: `src/lib/server/db/migrations/014_recipe_view.sql` - Test: `tests/integration/recipe-views.test.ts` - [ ] **Step 1: Write the failing test** @@ -42,10 +42,10 @@ Create `tests/integration/recipe-views.test.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', () => { +describe('014_recipe_view migration', () => { + it('creates recipe_view table with expected columns', () => { const db = openInMemoryForTest(); - const cols = db.prepare("PRAGMA table_info(recipe_views)").all() as Array<{ + const cols = db.prepare("PRAGMA table_info(recipe_view)").all() as Array<{ name: string; type: string; notnull: number; @@ -64,9 +64,9 @@ describe('014_recipe_views migration', () => { it('has index on (profile_id, last_viewed_at DESC)', () => { const db = openInMemoryForTest(); const idxList = db - .prepare("PRAGMA index_list(recipe_views)") + .prepare("PRAGMA index_list(recipe_view)") .all() as Array<{ name: string }>; - expect(idxList.some((i) => i.name === 'idx_recipe_views_recent')).toBe(true); + expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true); }); }); ``` @@ -74,21 +74,21 @@ describe('014_recipe_views migration', () => { - [ ] **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. +Expected: FAIL — table `recipe_view` does not exist. - [ ] **Step 3: Create the migration file** -Create `src/lib/server/db/migrations/014_recipe_views.sql`: +Create `src/lib/server/db/migrations/014_recipe_view.sql`: ```sql -CREATE TABLE recipe_views ( +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_views_recent - ON recipe_views (profile_id, last_viewed_at DESC); +CREATE INDEX idx_recipe_view_recent + ON recipe_view (profile_id, last_viewed_at DESC); ``` - [ ] **Step 4: Run test to verify it passes** @@ -99,8 +99,8 @@ Expected: PASS — both tests green. Migration is auto-discovered via `import.me - [ ] **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 +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. @@ -200,7 +200,7 @@ export function recordView( // 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) + `INSERT OR REPLACE INTO recipe_view (profile_id, recipe_id) VALUES (?, ?)` ).run(profileId, recipeId); } @@ -218,7 +218,7 @@ export function listViews( return db .prepare( `SELECT profile_id, recipe_id, last_viewed_at - FROM recipe_views + FROM recipe_view WHERE profile_id = ? ORDER BY last_viewed_at DESC` ) @@ -235,7 +235,7 @@ Expected: All 6 tests PASS. ```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 +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 @@ -319,7 +319,7 @@ export function listAllRecipesPaginated( offset: number, profileId: number | null = null ): SearchHit[] { - // 'viewed' branch needs a JOIN against recipe_views — diverges from the + // '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 @@ -335,7 +335,7 @@ export function listAllRecipesPaginated( (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 + 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, @@ -391,7 +391,7 @@ Expected: 0 errors. 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 +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). @@ -568,7 +568,7 @@ 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;"` +3. 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. @@ -1219,7 +1219,7 @@ If something's off, fix in a small follow-up commit. | Spec Requirement | Implemented in | |---|---| -| Migration `recipe_views` | Task 1 | +| Migration `recipe_view` | Task 1 | | `recordView` repo function | Task 2 | | Sort `'viewed'` in DB layer | Task 3 | | `POST /api/recipes/[id]/view` | Task 4 | diff --git a/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md b/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md index 1c1e332..c2ceafe 100644 --- a/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md +++ b/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md @@ -23,18 +23,18 @@ beschäftigte mich" Rezepte ohne Suche. ### Migration -Neue Datei `src/lib/server/db/migrations/014_recipe_views.sql` +Neue Datei `src/lib/server/db/migrations/014_recipe_view.sql` (Numbering: aktuell ist die letzte Migration `013_shopping_list.sql`): ```sql -CREATE TABLE recipe_views ( +CREATE TABLE recipe_view ( profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, recipe_id INTEGER NOT NULL REFERENCES recipes(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); +CREATE INDEX idx_recipe_view_recent + ON recipe_view(profile_id, last_viewed_at DESC); ``` Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben @@ -106,7 +106,7 @@ bekommt einen optionalen `profileId: number | null`-Parameter. Wenn ```sql SELECT r.*, ... FROM recipes r -LEFT JOIN recipe_views v +LEFT JOIN recipe_view v ON v.recipe_id = r.id AND v.profile_id = :profileId ORDER BY v.last_viewed_at DESC NULLS LAST, r.title COLLATE NOCASE ASC @@ -205,7 +205,7 @@ Hauptliste, immer sichtbar — User würde das Scrollen verlieren. ### Schema/Migration - Migrations-Test (existierendes Pattern in `tests/integration`): nach - `applyMigrations` muss `recipe_views` existieren mit erwarteten + `applyMigrations` muss `recipe_view` existieren mit erwarteten Spalten ### View-Endpoint