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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user