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