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).
**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 |

View File

@@ -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