docs: plan/spec auf recipe_view (singular) angeglichen

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>
This commit is contained in:
hsiegeln
2026-04-22 14:08:54 +02:00
parent 543008b0f2
commit 866a222265
2 changed files with 31 additions and 31 deletions

View File

@@ -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). **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. **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 ## File Structure
**Create:** **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/lib/server/recipes/views.ts``recordView(db, profileId, recipeId)` repo function
- `src/routes/api/recipes/[id]/view/+server.ts` — POST endpoint - `src/routes/api/recipes/[id]/view/+server.ts` — POST endpoint
- `tests/integration/recipe-views.test.ts` — DB + sort + endpoint tests - `tests/integration/recipe-views.test.ts` — DB + sort + endpoint tests
**Modify:** **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/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/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 - `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:** **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` - Test: `tests/integration/recipe-views.test.ts`
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
@@ -42,10 +42,10 @@ Create `tests/integration/recipe-views.test.ts`:
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db'; import { openInMemoryForTest } from '../../src/lib/server/db';
describe('014_recipe_views migration', () => { describe('014_recipe_view migration', () => {
it('creates recipe_views table with expected columns', () => { it('creates recipe_view table with expected columns', () => {
const db = openInMemoryForTest(); 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; name: string;
type: string; type: string;
notnull: number; notnull: number;
@@ -64,9 +64,9 @@ describe('014_recipe_views migration', () => {
it('has index on (profile_id, last_viewed_at DESC)', () => { it('has index on (profile_id, last_viewed_at DESC)', () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
const idxList = db const idxList = db
.prepare("PRAGMA index_list(recipe_views)") .prepare("PRAGMA index_list(recipe_view)")
.all() as Array<{ name: string }>; .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** - [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/integration/recipe-views.test.ts` 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** - [ ] **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 ```sql
CREATE TABLE recipe_views ( CREATE TABLE recipe_view (
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')), last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, recipe_id) PRIMARY KEY (profile_id, recipe_id)
); );
CREATE INDEX idx_recipe_views_recent CREATE INDEX idx_recipe_view_recent
ON recipe_views (profile_id, last_viewed_at DESC); ON recipe_view (profile_id, last_viewed_at DESC);
``` ```
- [ ] **Step 4: Run test to verify it passes** - [ ] **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** - [ ] **Step 5: Commit**
```bash ```bash
git add src/lib/server/db/migrations/014_recipe_views.sql tests/integration/recipe-views.test.ts git add src/lib/server/db/migrations/014_recipe_view.sql tests/integration/recipe-views.test.ts
git commit -m "feat(db): recipe_views table mit Profil-FK und Recent-Index git commit -m "feat(db): recipe_view table mit Profil-FK und Recent-Index
Tracking-Tabelle fuer Sort-Option Zuletzt angesehen. Composite-PK Tracking-Tabelle fuer Sort-Option Zuletzt angesehen. Composite-PK
(profile_id, recipe_id) erlaubt INSERT OR REPLACE per Default-Timestamp. (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 // so subsequent views of the same recipe by the same profile bump the
// timestamp without breaking the composite PK. // timestamp without breaking the composite PK.
db.prepare( db.prepare(
`INSERT OR REPLACE INTO recipe_views (profile_id, recipe_id) `INSERT OR REPLACE INTO recipe_view (profile_id, recipe_id)
VALUES (?, ?)` VALUES (?, ?)`
).run(profileId, recipeId); ).run(profileId, recipeId);
} }
@@ -218,7 +218,7 @@ export function listViews(
return db return db
.prepare( .prepare(
`SELECT profile_id, recipe_id, last_viewed_at `SELECT profile_id, recipe_id, last_viewed_at
FROM recipe_views FROM recipe_view
WHERE profile_id = ? WHERE profile_id = ?
ORDER BY last_viewed_at DESC` ORDER BY last_viewed_at DESC`
) )
@@ -235,7 +235,7 @@ Expected: All 6 tests PASS.
```bash ```bash
git add src/lib/server/recipes/views.ts tests/integration/recipe-views.test.ts 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. INSERT OR REPLACE fuer idempotenten Bump des last_viewed_at Timestamps.
listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in
@@ -319,7 +319,7 @@ export function listAllRecipesPaginated(
offset: number, offset: number,
profileId: number | null = null profileId: number | null = null
): SearchHit[] { ): 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 // simpler ORDER-BY-only path. We keep it in a separate prepare for
// clarity. Without profileId, fall back to alphabetical so the // clarity. Without profileId, fall back to alphabetical so the
// sort-chip still produces a sensible list (matches Sektion 2 of 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 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 (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r FROM recipe r
LEFT JOIN recipe_views v LEFT JOIN recipe_view v
ON v.recipe_id = r.id AND v.profile_id = ? 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, ORDER BY CASE WHEN v.last_viewed_at IS NULL THEN 1 ELSE 0 END,
v.last_viewed_at DESC, 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 add src/lib/server/recipes/search-local.ts tests/integration/recipe-views.test.ts
git commit -m "feat(search): sort=viewed in listAllRecipesPaginated 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 last_viewed_at DESC mit alphabetischem Tiebreaker. NULL-Recipes (nie
angesehen) landen alphabetisch sortiert hinter den angesehenen angesehen) landen alphabetisch sortiert hinter den angesehenen
(CASE-NULL-last statt SQLite 3.30+ NULLS LAST). (CASE-NULL-last statt SQLite 3.30+ NULLS LAST).
@@ -568,7 +568,7 @@ Open: `http://localhost:5173/`
Steps: Steps:
1. Pick a profile via the profile switcher 1. Pick a profile via the profile switcher
2. Click any recipe 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 Expected: one row matching the clicked recipe and selected profile
If you don't have a local profile, create one via the UI first. 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 | | Spec Requirement | Implemented in |
|---|---| |---|---|
| Migration `recipe_views` | Task 1 | | Migration `recipe_view` | Task 1 |
| `recordView` repo function | Task 2 | | `recordView` repo function | Task 2 |
| Sort `'viewed'` in DB layer | Task 3 | | Sort `'viewed'` in DB layer | Task 3 |
| `POST /api/recipes/[id]/view` | Task 4 | | `POST /api/recipes/[id]/view` | Task 4 |

View File

@@ -23,18 +23,18 @@ beschäftigte mich" Rezepte ohne Suche.
### Migration ### 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`): (Numbering: aktuell ist die letzte Migration `013_shopping_list.sql`):
```sql ```sql
CREATE TABLE recipe_views ( CREATE TABLE recipe_view (
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')), last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, recipe_id) PRIMARY KEY (profile_id, recipe_id)
); );
CREATE INDEX idx_recipe_views_recent CREATE INDEX idx_recipe_view_recent
ON recipe_views(profile_id, last_viewed_at DESC); ON recipe_view(profile_id, last_viewed_at DESC);
``` ```
Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben
@@ -106,7 +106,7 @@ bekommt einen optionalen `profileId: number | null`-Parameter. Wenn
```sql ```sql
SELECT r.*, ... SELECT r.*, ...
FROM recipes 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 ON v.recipe_id = r.id AND v.profile_id = :profileId
ORDER BY v.last_viewed_at DESC NULLS LAST, ORDER BY v.last_viewed_at DESC NULLS LAST,
r.title COLLATE NOCASE ASC r.title COLLATE NOCASE ASC
@@ -205,7 +205,7 @@ Hauptliste, immer sichtbar — User würde das Scrollen verlieren.
### Schema/Migration ### Schema/Migration
- Migrations-Test (existierendes Pattern in `tests/integration`): nach - Migrations-Test (existierendes Pattern in `tests/integration`): nach
`applyMigrations` muss `recipe_views` existieren mit erwarteten `applyMigrations` muss `recipe_view` existieren mit erwarteten
Spalten Spalten
### View-Endpoint ### View-Endpoint