10 Tasks (Migration -> Repo -> Sort-Branch -> API -> Beacon -> URL- Helper -> Sort-Chip + Reactive Refetch -> Collapsibles -> Push&Verify) mit TDD-Schritten, exakten Filepfaden und vollstaendigem Code in jedem Step. Spec-Migrationsnummer auf 014 korrigiert (war 010 — letzte aktuelle Migration ist 013_shopping_list). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1242 lines
38 KiB
Markdown
1242 lines
38 KiB
Markdown
# Hauptseite: "Zuletzt angesehen" Sort + Collapsible Sections — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**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`.
|
|
|
|
**Tech Stack:** SvelteKit 2 + Svelte 5 runes, better-sqlite3, vitest (jsdom + node), zod for body validation, lucide-svelte for icons.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create:**
|
|
- `src/lib/server/db/migrations/014_recipe_views.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/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
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/db/migrations/014_recipe_views.sql`
|
|
- Test: `tests/integration/recipe-views.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/integration/recipe-views.test.ts`:
|
|
|
|
```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', () => {
|
|
const db = openInMemoryForTest();
|
|
const cols = db.prepare("PRAGMA table_info(recipe_views)").all() as Array<{
|
|
name: string;
|
|
type: string;
|
|
notnull: number;
|
|
pk: number;
|
|
}>;
|
|
const byName = Object.fromEntries(cols.map((c) => [c.name, c]));
|
|
expect(byName.profile_id?.type).toBe('INTEGER');
|
|
expect(byName.profile_id?.notnull).toBe(1);
|
|
expect(byName.profile_id?.pk).toBe(1);
|
|
expect(byName.recipe_id?.type).toBe('INTEGER');
|
|
expect(byName.recipe_id?.pk).toBe(2);
|
|
expect(byName.last_viewed_at?.type).toBe('TEXT');
|
|
expect(byName.last_viewed_at?.notnull).toBe(1);
|
|
});
|
|
|
|
it('has index on (profile_id, last_viewed_at DESC)', () => {
|
|
const db = openInMemoryForTest();
|
|
const idxList = db
|
|
.prepare("PRAGMA index_list(recipe_views)")
|
|
.all() as Array<{ name: string }>;
|
|
expect(idxList.some((i) => i.name === 'idx_recipe_views_recent')).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **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.
|
|
|
|
- [ ] **Step 3: Create the migration file**
|
|
|
|
Create `src/lib/server/db/migrations/014_recipe_views.sql`:
|
|
|
|
```sql
|
|
CREATE TABLE recipe_views (
|
|
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);
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: PASS — both tests green. Migration is auto-discovered via `import.meta.glob` per CLAUDE.md.
|
|
|
|
- [ ] **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
|
|
|
|
Tracking-Tabelle fuer Sort-Option Zuletzt angesehen. Composite-PK
|
|
(profile_id, recipe_id) erlaubt INSERT OR REPLACE per Default-Timestamp.
|
|
Index nach profile_id + last_viewed_at DESC fuer Sort-Query.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `recordView` repository function
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/recipes/views.ts`
|
|
- Modify: `tests/integration/recipe-views.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `tests/integration/recipe-views.test.ts`:
|
|
|
|
```ts
|
|
import { recordView, listViews } from '../../src/lib/server/recipes/views';
|
|
import { createProfile } from '../../src/lib/server/profiles/repository';
|
|
|
|
function seedRecipe(db: ReturnType<typeof openInMemoryForTest>, title: string): number {
|
|
const r = db
|
|
.prepare("INSERT INTO recipe (title, created_at) VALUES (?, datetime('now')) RETURNING id")
|
|
.get(title) as { id: number };
|
|
return r.id;
|
|
}
|
|
|
|
describe('recordView', () => {
|
|
it('inserts a view row with default timestamp', () => {
|
|
const db = openInMemoryForTest();
|
|
const profile = createProfile(db, 'Test');
|
|
const recipeId = seedRecipe(db, 'Pasta');
|
|
|
|
recordView(db, profile.id, recipeId);
|
|
|
|
const rows = listViews(db, profile.id);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].recipe_id).toBe(recipeId);
|
|
expect(rows[0].last_viewed_at).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
|
});
|
|
|
|
it('updates timestamp on subsequent view of same recipe', async () => {
|
|
const db = openInMemoryForTest();
|
|
const profile = createProfile(db, 'Test');
|
|
const recipeId = seedRecipe(db, 'Pasta');
|
|
|
|
recordView(db, profile.id, recipeId);
|
|
const first = listViews(db, profile.id)[0].last_viewed_at;
|
|
|
|
// tiny delay so the second timestamp differs
|
|
await new Promise((r) => setTimeout(r, 1100));
|
|
recordView(db, profile.id, recipeId);
|
|
|
|
const rows = listViews(db, profile.id);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].last_viewed_at >= first).toBe(true);
|
|
});
|
|
|
|
it('throws on unknown profile_id (FK)', () => {
|
|
const db = openInMemoryForTest();
|
|
const recipeId = seedRecipe(db, 'Pasta');
|
|
expect(() => recordView(db, 999, recipeId)).toThrow();
|
|
});
|
|
|
|
it('throws on unknown recipe_id (FK)', () => {
|
|
const db = openInMemoryForTest();
|
|
const profile = createProfile(db, 'Test');
|
|
expect(() => recordView(db, profile.id, 999)).toThrow();
|
|
});
|
|
});
|
|
```
|
|
|
|
The `listViews` export is needed only for tests — keep it small.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: FAIL — module `views.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement `recordView` and `listViews`**
|
|
|
|
Create `src/lib/server/recipes/views.ts`:
|
|
|
|
```ts
|
|
import type Database from 'better-sqlite3';
|
|
|
|
export function recordView(
|
|
db: Database.Database,
|
|
profileId: number,
|
|
recipeId: number
|
|
): void {
|
|
// INSERT OR REPLACE re-fires the DEFAULT (datetime('now')) on conflict,
|
|
// 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)
|
|
VALUES (?, ?)`
|
|
).run(profileId, recipeId);
|
|
}
|
|
|
|
export type ViewRow = {
|
|
profile_id: number;
|
|
recipe_id: number;
|
|
last_viewed_at: string;
|
|
};
|
|
|
|
export function listViews(
|
|
db: Database.Database,
|
|
profileId: number
|
|
): ViewRow[] {
|
|
return db
|
|
.prepare(
|
|
`SELECT profile_id, recipe_id, last_viewed_at
|
|
FROM recipe_views
|
|
WHERE profile_id = ?
|
|
ORDER BY last_viewed_at DESC`
|
|
)
|
|
.all(profileId) as ViewRow[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: All 6 tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```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
|
|
|
|
INSERT OR REPLACE fuer idempotenten Bump des last_viewed_at Timestamps.
|
|
listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in
|
|
listAllRecipesPaginated via LEFT JOIN.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Sort-Erweiterung `'viewed'` in `listAllRecipesPaginated`
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/server/recipes/search-local.ts`
|
|
- Modify: `tests/integration/recipe-views.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `tests/integration/recipe-views.test.ts`:
|
|
|
|
```ts
|
|
import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local';
|
|
|
|
describe("listAllRecipesPaginated sort='viewed'", () => {
|
|
it('puts recently-viewed recipes first, NULLs alphabetically last', async () => {
|
|
const db = openInMemoryForTest();
|
|
const profile = createProfile(db, 'Test');
|
|
const recipeA = seedRecipe(db, 'Apfelkuchen');
|
|
const recipeB = seedRecipe(db, 'Brokkoli');
|
|
const recipeC = seedRecipe(db, 'Couscous');
|
|
|
|
// View order: B then A. C never viewed.
|
|
recordView(db, profile.id, recipeB);
|
|
await new Promise((r) => setTimeout(r, 1100));
|
|
recordView(db, profile.id, recipeA);
|
|
|
|
const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, profile.id);
|
|
expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeC]);
|
|
});
|
|
|
|
it('falls back to alphabetical when profileId is null', () => {
|
|
const db = openInMemoryForTest();
|
|
const recipeC = seedRecipe(db, 'Couscous');
|
|
const recipeA = seedRecipe(db, 'Apfelkuchen');
|
|
const recipeB = seedRecipe(db, 'Brokkoli');
|
|
|
|
const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, null);
|
|
expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']);
|
|
});
|
|
|
|
it('keeps existing sorts working unchanged', () => {
|
|
const db = openInMemoryForTest();
|
|
seedRecipe(db, 'Couscous');
|
|
seedRecipe(db, 'Apfelkuchen');
|
|
seedRecipe(db, 'Brokkoli');
|
|
|
|
const hits = listAllRecipesPaginated(db, 'name', 50, 0);
|
|
expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: FAIL — `'viewed'` not a valid `AllRecipesSort`, OR signature mismatch (5th param not accepted).
|
|
|
|
- [ ] **Step 3: Extend the sort enum + signature + query**
|
|
|
|
Modify `src/lib/server/recipes/search-local.ts`:
|
|
|
|
Replace the `AllRecipesSort` type and `listAllRecipesPaginated` function with:
|
|
|
|
```ts
|
|
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
|
|
|
|
export function listAllRecipesPaginated(
|
|
db: Database.Database,
|
|
sort: AllRecipesSort,
|
|
limit: number,
|
|
offset: number,
|
|
profileId: number | null = null
|
|
): SearchHit[] {
|
|
// 'viewed' branch needs a JOIN against recipe_views — 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
|
|
// spec).
|
|
if (sort === 'viewed' && profileId !== null) {
|
|
return db
|
|
.prepare(
|
|
`SELECT r.id,
|
|
r.title,
|
|
r.description,
|
|
r.image_path,
|
|
r.source_domain,
|
|
(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
|
|
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,
|
|
r.title COLLATE NOCASE ASC
|
|
LIMIT ? OFFSET ?`
|
|
)
|
|
.all(profileId, limit, offset) as SearchHit[];
|
|
}
|
|
|
|
// NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST
|
|
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
|
|
// CASE ist überall zuverlässig.
|
|
const orderBy: Record<Exclude<AllRecipesSort, 'viewed'>, string> = {
|
|
name: 'r.title COLLATE NOCASE ASC',
|
|
rating:
|
|
'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
|
'(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
|
cooked:
|
|
'CASE WHEN (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
|
|
'(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
|
|
created: 'r.created_at DESC, r.id DESC'
|
|
};
|
|
// Without profile, 'viewed' degrades to alphabetical.
|
|
const effectiveSort = sort === 'viewed' ? 'name' : sort;
|
|
return db
|
|
.prepare(
|
|
`SELECT r.id,
|
|
r.title,
|
|
r.description,
|
|
r.image_path,
|
|
r.source_domain,
|
|
(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
|
|
ORDER BY ${orderBy[effectiveSort]}
|
|
LIMIT ? OFFSET ?`
|
|
)
|
|
.all(limit, offset) as SearchHit[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: All 9 tests PASS.
|
|
|
|
Run: `npm run check` to make sure the type narrowing still compiles.
|
|
Expected: 0 errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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
|
|
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).
|
|
|
|
Ohne profileId faellt der Sort auf alphabetisch zurueck — Sort-Chip
|
|
bleibt klickbar, ergibt aber sinnvolles Default-Verhalten ohne
|
|
aktiviertes Profil.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: API endpoint `POST /api/recipes/[id]/view`
|
|
|
|
**Files:**
|
|
- Create: `src/routes/api/recipes/[id]/view/+server.ts`
|
|
- Modify: `tests/integration/recipe-views.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `tests/integration/recipe-views.test.ts`:
|
|
|
|
```ts
|
|
import { POST as viewPost } from '../../src/routes/api/recipes/[id]/view/+server';
|
|
|
|
function makeRequest(profile_id: number | string | null) {
|
|
return new Request('http://localhost/api/recipes/1/view', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(profile_id === null ? {} : { profile_id })
|
|
});
|
|
}
|
|
|
|
describe('POST /api/recipes/[id]/view', () => {
|
|
it('records a view on success', async () => {
|
|
const db = openInMemoryForTest();
|
|
const profile = createProfile(db, 'Test');
|
|
const recipeId = seedRecipe(db, 'Pasta');
|
|
|
|
const res = await viewPost({
|
|
request: makeRequest(profile.id),
|
|
params: { id: String(recipeId) },
|
|
// getDb is bound to the in-memory db via vi.spyOn — see helper
|
|
} as never);
|
|
expect(res.status).toBe(204);
|
|
|
|
expect(listViews(db, profile.id).length).toBe(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
The endpoint will use `getDb()` so the test needs to swap the module's
|
|
db. Use the existing pattern from `tests/integration/recipes-post.test.ts`
|
|
— check it first to see the recommended mock approach. If `getDb` is
|
|
not easily swappable, adjust the test to call the underlying repo
|
|
function directly OR introduce a `dbOverride` parameter pattern that
|
|
matches the rest of the codebase.
|
|
|
|
> **Note for the implementer:** check `tests/integration/recipes-post.test.ts`
|
|
> first. If it does NOT mock `getDb`, then this test cannot easily call
|
|
> the +server handler. In that case, scope the endpoint test to verify
|
|
> just the validation logic (a thin wrapper around `validateBody`) and
|
|
> delete the success-path assertion above — the success path is already
|
|
> covered by Task 2's `recordView` tests, plus the route file's body
|
|
> here is just glue.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: FAIL — module does not exist.
|
|
|
|
- [ ] **Step 3: Create the endpoint**
|
|
|
|
Create `src/routes/api/recipes/[id]/view/+server.ts`:
|
|
|
|
```ts
|
|
import type { RequestHandler } from './$types';
|
|
import { z } from 'zod';
|
|
import { error } from '@sveltejs/kit';
|
|
import { getDb } from '$lib/server/db';
|
|
import { validateBody } from '$lib/server/api-helpers';
|
|
import { recordView } from '$lib/server/recipes/views';
|
|
|
|
const Schema = z.object({
|
|
profile_id: z.number().int().positive()
|
|
});
|
|
|
|
export const POST: RequestHandler = async ({ params, request }) => {
|
|
const recipeId = Number(params.id);
|
|
if (!Number.isInteger(recipeId) || recipeId <= 0) {
|
|
error(400, { message: 'Invalid recipe id' });
|
|
}
|
|
const body = validateBody(await request.json().catch(() => null), Schema);
|
|
|
|
try {
|
|
recordView(getDb(), body.profile_id, recipeId);
|
|
} catch (e) {
|
|
// FK violation (unknown profile or recipe) → 404
|
|
if (e instanceof Error && /FOREIGN KEY constraint failed/i.test(e.message)) {
|
|
error(404, { message: 'Recipe or profile not found' });
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: PASS (or follow the note in Step 1 about scoping the test).
|
|
|
|
Run: `npm run check`
|
|
Expected: 0 errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/api/recipes/[id]/view/+server.ts tests/integration/recipe-views.test.ts
|
|
git commit -m "feat(api): POST /api/recipes/[id]/view fuer View-Beacon
|
|
|
|
Body { profile_id } via zod validiert. FK-Violation (unbekanntes
|
|
Profil oder Rezept) wird zu 404 normalisiert. Erfolg liefert 204
|
|
ohne Body — fire-and-forget vom Client.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Recipe-Detail-Seite triggert View-Beacon
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/recipes/[id]/+page.svelte`
|
|
|
|
- [ ] **Step 1: Read the current onMount block**
|
|
|
|
Run: `grep -n "onMount" src/routes/recipes/[id]/+page.svelte`
|
|
|
|
Note the line numbers. The recipe ID is exposed via `data.recipe.id` (or similar). Confirm by reading the imports / `let { data } = $props()` block at the top of the file.
|
|
|
|
- [ ] **Step 2: Add the beacon import**
|
|
|
|
In `src/routes/recipes/[id]/+page.svelte`, add to the existing `import { profileStore } from '$lib/client/profile.svelte';` import (or add the import if not present).
|
|
|
|
- [ ] **Step 3: Add the beacon call inside onMount**
|
|
|
|
Inside the existing `onMount(...)` block (or add a new one if absent), add at the END of the function:
|
|
|
|
```ts
|
|
// Track view per active profile (fire-and-forget). Skipped when no
|
|
// profile is active — without a profile we'd just be writing rows
|
|
// nobody can sort against later.
|
|
if (profileStore.active) {
|
|
void fetch(`/api/recipes/${data.recipe.id}/view`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ profile_id: profileStore.active.id })
|
|
});
|
|
}
|
|
```
|
|
|
|
> **Note:** if the recipe id lives at a different path on `data` (e.g.
|
|
> `data.id` or `data.recipe?.id`), substitute accordingly. Check the
|
|
> file's `+page.server.ts` for the exact shape.
|
|
|
|
- [ ] **Step 4: Manual smoke test (no automated test for the beacon — too
|
|
much mock surface for too little signal)**
|
|
|
|
Run: `npm run dev`
|
|
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;"`
|
|
Expected: one row matching the clicked recipe and selected profile
|
|
|
|
If you don't have a local profile, create one via the UI first.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/recipes/[id]/+page.svelte
|
|
git commit -m "feat(recipe): View-Beacon beim oeffnen der Detailseite
|
|
|
|
Fire-and-forget POST /api/recipes/[id]/view in onMount, nur wenn
|
|
profileStore.active gesetzt. Schreibt last_viewed_at fuers Profil —
|
|
Voraussetzung fuer den Sort 'Zuletzt angesehen' auf der Hauptseite.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: API `/api/recipes/all` nimmt `profile_id`
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/api/recipes/all/+server.ts`
|
|
- Modify: `tests/integration/recipe-views.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test (HTTP-level)**
|
|
|
|
Append to `tests/integration/recipe-views.test.ts`:
|
|
|
|
```ts
|
|
import { GET as allGet } from '../../src/routes/api/recipes/all/+server';
|
|
|
|
describe('GET /api/recipes/all?sort=viewed&profile_id=N', () => {
|
|
it('passes profile_id to the sort and returns viewed-order', async () => {
|
|
// Reuses the same in-memory + getDb pattern as Task 4. If getDb
|
|
// can't be mocked here either, scope this test to verifying the
|
|
// query-param parsing only by calling listAllRecipesPaginated
|
|
// directly (already covered in Task 3) and just smoke-test that
|
|
// GET handler accepts the profile_id param without 400.
|
|
const url = new URL('http://localhost/api/recipes/all?sort=viewed&profile_id=1&limit=10');
|
|
const res = await allGet({ url } as never);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.sort).toBe('viewed');
|
|
expect(Array.isArray(body.hits)).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: FAIL — `'viewed'` not in `VALID_SORTS`, returns 400.
|
|
|
|
- [ ] **Step 3: Update the endpoint**
|
|
|
|
Replace the entire content of `src/routes/api/recipes/all/+server.ts` with:
|
|
|
|
```ts
|
|
import type { RequestHandler } from './$types';
|
|
import { json, error } from '@sveltejs/kit';
|
|
import { getDb } from '$lib/server/db';
|
|
import {
|
|
listAllRecipesPaginated,
|
|
type AllRecipesSort
|
|
} from '$lib/server/recipes/search-local';
|
|
|
|
const VALID_SORTS = new Set<AllRecipesSort>([
|
|
'name',
|
|
'rating',
|
|
'cooked',
|
|
'created',
|
|
'viewed'
|
|
]);
|
|
|
|
function parseProfileId(raw: string | null): number | null {
|
|
if (!raw) return null;
|
|
const n = Number(raw);
|
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
}
|
|
|
|
export const GET: RequestHandler = async ({ url }) => {
|
|
const sortRaw = (url.searchParams.get('sort') ?? 'name') as AllRecipesSort;
|
|
if (!VALID_SORTS.has(sortRaw)) error(400, { message: 'Invalid sort' });
|
|
// Cap is 200 (not 10's typical paging step) to support snapshot-based
|
|
// pagination restore on /+page.svelte: when the user navigates back
|
|
// after deep infinite-scroll, we re-hydrate the full loaded count in
|
|
// one round-trip so document height matches and scroll-restore lands.
|
|
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
|
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
|
const profileId = parseProfileId(url.searchParams.get('profile_id'));
|
|
const hits = listAllRecipesPaginated(getDb(), sortRaw, limit, offset, profileId);
|
|
return json({ sort: sortRaw, limit, offset, hits });
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npm test -- tests/integration/recipe-views.test.ts`
|
|
Expected: PASS.
|
|
|
|
Also re-run all integration tests to make sure nothing broke:
|
|
Run: `npm test`
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/api/recipes/all/+server.ts tests/integration/recipe-views.test.ts
|
|
git commit -m "feat(api): /api/recipes/all akzeptiert sort=viewed + profile_id
|
|
|
|
VALID_SORTS um 'viewed' erweitert. parseProfileId-Helper analog zu
|
|
/api/wishlist. Wert wird an listAllRecipesPaginated als 5. Param
|
|
durchgereicht.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Hauptseite ruft `/api/recipes/all` mit `profile_id` auf
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/+page.svelte`
|
|
|
|
- [ ] **Step 1: Read the three fetch sites**
|
|
|
|
Run: `grep -n "/api/recipes/all" src/routes/+page.svelte`
|
|
|
|
You should find three call sites:
|
|
- inside `loadAllMore()` (line ~102)
|
|
- inside `setAllSort()` (line ~127)
|
|
- inside `rehydrateAll()` (line ~80)
|
|
|
|
- [ ] **Step 2: Add a helper that builds the URL with profile_id**
|
|
|
|
In `src/routes/+page.svelte`, add this helper inside the `<script>` block (anywhere after the imports):
|
|
|
|
```ts
|
|
function buildAllUrl(sort: AllSort, limit: number, offset: number): string {
|
|
const profileId = profileStore.active?.id;
|
|
const profilePart = profileId ? `&profile_id=${profileId}` : '';
|
|
return `/api/recipes/all?sort=${sort}&limit=${limit}&offset=${offset}${profilePart}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Replace the three fetch URLs with the helper**
|
|
|
|
In `loadAllMore()`, replace:
|
|
|
|
```ts
|
|
const res = await fetch(
|
|
`/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
|
|
);
|
|
```
|
|
|
|
with:
|
|
|
|
```ts
|
|
const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));
|
|
```
|
|
|
|
In `setAllSort()`, replace:
|
|
|
|
```ts
|
|
const res = await fetch(
|
|
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
|
|
);
|
|
```
|
|
|
|
with:
|
|
|
|
```ts
|
|
const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));
|
|
```
|
|
|
|
In `rehydrateAll()`, replace:
|
|
|
|
```ts
|
|
const res = await fetch(`/api/recipes/all?sort=${sort}&limit=${count}&offset=0`);
|
|
```
|
|
|
|
with:
|
|
|
|
```ts
|
|
const res = await fetch(buildAllUrl(sort, count, 0));
|
|
```
|
|
|
|
- [ ] **Step 4: Verify no other call sites exist**
|
|
|
|
Run: `grep -n "/api/recipes/all" src/routes/+page.svelte`
|
|
Expected: only the helper itself plus the three replaced lines should remain (the three lines now using `buildAllUrl`).
|
|
|
|
- [ ] **Step 5: Type + test sanity**
|
|
|
|
Run: `npm run check`
|
|
Expected: 0 errors.
|
|
|
|
Run: `npm test`
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/routes/+page.svelte
|
|
git commit -m "feat(home): profile_id in alle /api/recipes/all-Fetches
|
|
|
|
buildAllUrl-Helper haengt profile_id an wenn ein Profil aktiv ist;
|
|
nutzt es loadAllMore, setAllSort und rehydrateAll. Voraussetzung fuer
|
|
sort=viewed (Server braucht profile_id fuer den View-Join).
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: `'viewed'` Sort-Chip + Profile-Switch-Refetch
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/+page.svelte`
|
|
|
|
- [ ] **Step 1: Add the sort option**
|
|
|
|
In `src/routes/+page.svelte`, find:
|
|
|
|
```ts
|
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';
|
|
```
|
|
|
|
Find:
|
|
|
|
```ts
|
|
const ALL_SORTS: { value: AllSort; label: string }[] = [
|
|
{ value: 'name', label: 'Name' },
|
|
{ value: 'rating', label: 'Bewertung' },
|
|
{ value: 'cooked', label: 'Zuletzt gekocht' },
|
|
{ value: 'created', label: 'Hinzugefügt' }
|
|
];
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
const ALL_SORTS: { value: AllSort; label: string }[] = [
|
|
{ value: 'name', label: 'Name' },
|
|
{ value: 'rating', label: 'Bewertung' },
|
|
{ value: 'cooked', label: 'Zuletzt gekocht' },
|
|
{ value: 'created', label: 'Hinzugefügt' },
|
|
{ value: 'viewed', label: 'Zuletzt angesehen' }
|
|
];
|
|
```
|
|
|
|
Also update the localStorage validation list in `onMount`:
|
|
|
|
Find:
|
|
|
|
```ts
|
|
if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```ts
|
|
if (saved && ['name', 'rating', 'cooked', 'created', 'viewed'].includes(saved)) {
|
|
```
|
|
|
|
- [ ] **Step 2: Add reactive refetch on profile switch (only for sort='viewed')**
|
|
|
|
After the existing `$effect` that watches `searchFilterStore.active`, add:
|
|
|
|
```ts
|
|
// 'viewed' sort depends on the active profile. When the user switches
|
|
// profiles, refetch with the new profile_id so the list reflects what
|
|
// the *current* profile has viewed. Other sorts are profile-agnostic
|
|
// and don't need this.
|
|
$effect(() => {
|
|
// Read profile id reactively so the effect re-runs on switch.
|
|
const id = profileStore.activeId;
|
|
if (allSort !== 'viewed') return;
|
|
if (allLoading) return;
|
|
// Re-fetch the first page; rehydrate would re-load the previous
|
|
// depth, but a sort-context change should reset to page 1 anyway.
|
|
void (async () => {
|
|
allLoading = true;
|
|
try {
|
|
const res = await fetch(buildAllUrl('viewed', ALL_PAGE, 0));
|
|
if (!res.ok) return;
|
|
const body = await res.json();
|
|
const hits = body.hits as SearchHit[];
|
|
allRecipes = hits;
|
|
allExhausted = hits.length < ALL_PAGE;
|
|
} finally {
|
|
allLoading = false;
|
|
}
|
|
// 'id' is referenced so $effect tracks it as a dep:
|
|
void id;
|
|
})();
|
|
});
|
|
```
|
|
|
|
> **Note:** check that `profileStore` exposes a reactive `activeId` getter
|
|
> (numeric or null). If only `active` is reactive, use `profileStore.active?.id`
|
|
> instead. The pattern in the existing favorites $effect is the model.
|
|
|
|
- [ ] **Step 3: Manual smoke test**
|
|
|
|
Run: `npm run dev`
|
|
Open: `http://localhost:5173/`
|
|
Steps:
|
|
1. Pick profile A → click 2 different recipes → return to home
|
|
2. Click the "Zuletzt angesehen" sort chip → expect those 2 recipes at the top
|
|
3. Switch to profile B → expect the list to refetch and show different (or no) viewed recipes at the top
|
|
|
|
- [ ] **Step 4: Type + test sanity**
|
|
|
|
Run: `npm run check`
|
|
Expected: 0 errors.
|
|
|
|
Run: `npm test`
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/+page.svelte
|
|
git commit -m "feat(home): Sort-Chip 'Zuletzt angesehen' + Profile-Switch-Refetch
|
|
|
|
Neuer Wert 'viewed' im AllSort-Enum + ALL_SORTS-Array. localStorage-
|
|
Whitelist ergaenzt. Reactive \$effect lauscht auf profileStore.activeId
|
|
und refetcht offset=0 nur wenn aktueller Sort 'viewed' ist — andere
|
|
Sortierungen sind profilunabhaengig.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Collapsibles für „Deine Favoriten" + „Zuletzt hinzugefügt"
|
|
|
|
**Files:**
|
|
- Modify: `src/routes/+page.svelte`
|
|
|
|
- [ ] **Step 1: Add state + persistence helpers**
|
|
|
|
In `src/routes/+page.svelte`, add to the imports near the top:
|
|
|
|
```ts
|
|
import { slide } from 'svelte/transition';
|
|
import { ChevronDown } from 'lucide-svelte';
|
|
```
|
|
|
|
(`ChevronDown` may already be imported at the layout level — but each
|
|
component needs its own import.)
|
|
|
|
Add state declarations near the other `$state` lines (after `allObserver`):
|
|
|
|
```ts
|
|
type CollapseKey = 'favorites' | 'recent';
|
|
const COLLAPSE_STORAGE_KEY = 'kochwas.collapsed.sections';
|
|
let collapsed = $state<Record<CollapseKey, boolean>>({
|
|
favorites: false,
|
|
recent: false
|
|
});
|
|
|
|
function toggleCollapsed(key: CollapseKey) {
|
|
collapsed[key] = !collapsed[key];
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(collapsed));
|
|
}
|
|
}
|
|
```
|
|
|
|
Inside `onMount`, add at the end (after the existing localStorage reads):
|
|
|
|
```ts
|
|
const rawCollapsed = localStorage.getItem(COLLAPSE_STORAGE_KEY);
|
|
if (rawCollapsed) {
|
|
try {
|
|
const parsed = JSON.parse(rawCollapsed) as Partial<Record<CollapseKey, boolean>>;
|
|
if (typeof parsed.favorites === 'boolean') collapsed.favorites = parsed.favorites;
|
|
if (typeof parsed.recent === 'boolean') collapsed.recent = parsed.recent;
|
|
} catch {
|
|
// Corrupt JSON — keep defaults (both open).
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap the Favoriten section**
|
|
|
|
Find the existing favorites block (search for `Deine Favoriten`):
|
|
|
|
```svelte
|
|
{#if profileStore.active && favorites.length > 0}
|
|
<section class="listing">
|
|
<h2>Deine Favoriten</h2>
|
|
<ul class="cards">
|
|
{#each favorites as r (r.id)}
|
|
...
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```svelte
|
|
{#if profileStore.active && favorites.length > 0}
|
|
<section class="listing">
|
|
<button
|
|
type="button"
|
|
class="section-head"
|
|
onclick={() => toggleCollapsed('favorites')}
|
|
aria-expanded={!collapsed.favorites}
|
|
>
|
|
<ChevronDown
|
|
size={18}
|
|
strokeWidth={2.2}
|
|
class={collapsed.favorites ? 'chev rotated' : 'chev'}
|
|
/>
|
|
<h2>Deine Favoriten</h2>
|
|
<span class="count">{favorites.length}</span>
|
|
</button>
|
|
{#if !collapsed.favorites}
|
|
<div transition:slide={{ duration: 180 }}>
|
|
<ul class="cards">
|
|
{#each favorites as r (r.id)}
|
|
<li class="card-wrap">
|
|
<a href={`/recipes/${r.id}`} class="card">
|
|
{#if r.image_path}
|
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
|
{:else}
|
|
<div class="placeholder"><CookingPot size={36} /></div>
|
|
{/if}
|
|
<div class="card-body">
|
|
<div class="title">{r.title}</div>
|
|
{#if r.source_domain}
|
|
<div class="domain">{r.source_domain}</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 3: Wrap the „Zuletzt hinzugefügt" section the same way**
|
|
|
|
Find:
|
|
|
|
```svelte
|
|
{#if recent.length > 0}
|
|
<section class="listing">
|
|
<h2>Zuletzt hinzugefügt</h2>
|
|
<ul class="cards">
|
|
{#each recent as r (r.id)}
|
|
...
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```svelte
|
|
{#if recent.length > 0}
|
|
<section class="listing">
|
|
<button
|
|
type="button"
|
|
class="section-head"
|
|
onclick={() => toggleCollapsed('recent')}
|
|
aria-expanded={!collapsed.recent}
|
|
>
|
|
<ChevronDown
|
|
size={18}
|
|
strokeWidth={2.2}
|
|
class={collapsed.recent ? 'chev rotated' : 'chev'}
|
|
/>
|
|
<h2>Zuletzt hinzugefügt</h2>
|
|
<span class="count">{recent.length}</span>
|
|
</button>
|
|
{#if !collapsed.recent}
|
|
<div transition:slide={{ duration: 180 }}>
|
|
<ul class="cards">
|
|
{#each recent as r (r.id)}
|
|
<li class="card-wrap">
|
|
<a href={`/recipes/${r.id}`} class="card">
|
|
{#if r.image_path}
|
|
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
|
|
{:else}
|
|
<div class="placeholder"><CookingPot size={36} /></div>
|
|
{/if}
|
|
<div class="card-body">
|
|
<div class="title">{r.title}</div>
|
|
{#if r.source_domain}
|
|
<div class="domain">{r.source_domain}</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
<button
|
|
class="dismiss"
|
|
aria-label="Aus Zuletzt-hinzugefügt entfernen"
|
|
onclick={(e) => dismissFromRecent(r.id, e)}
|
|
>
|
|
<X size={16} strokeWidth={2.5} />
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 4: Add CSS for the section header + chevron**
|
|
|
|
In the `<style>` block of `+page.svelte`, add (near the existing `.listing` rules):
|
|
|
|
```css
|
|
.section-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
padding: 0.4rem 0.25rem;
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 8px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
color: inherit;
|
|
min-height: 44px;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.section-head:hover {
|
|
background: #f4f8f5;
|
|
}
|
|
.section-head h2 {
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
color: #444;
|
|
font-weight: 600;
|
|
}
|
|
.section-head .count {
|
|
margin-left: auto;
|
|
color: #888;
|
|
font-size: 0.85rem;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
:global(.chev) {
|
|
color: #2b6a3d;
|
|
flex-shrink: 0;
|
|
transition: transform 180ms;
|
|
}
|
|
:global(.chev.rotated) {
|
|
transform: rotate(-90deg);
|
|
}
|
|
```
|
|
|
|
(`:global(...)` because lucide-svelte renders an `<svg>` inside its own
|
|
component scope; the class lives on that svg.)
|
|
|
|
- [ ] **Step 5: Manual smoke test**
|
|
|
|
Run: `npm run dev`
|
|
Open: `http://localhost:5173/`
|
|
Steps:
|
|
1. Both sections visible and open → click each header → both collapse with slide
|
|
2. Click again → both expand with slide
|
|
3. Reload the page → collapsed/expanded state matches what you left
|
|
4. Clear localStorage in DevTools → reload → both default to open
|
|
5. Verify "Alle Rezepte" is NOT collapsible (Hauptliste bleibt sichtbar)
|
|
|
|
- [ ] **Step 6: Type + test sanity**
|
|
|
|
Run: `npm run check`
|
|
Expected: 0 errors.
|
|
|
|
Run: `npm test`
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/routes/+page.svelte
|
|
git commit -m "feat(home): Collapsible Sections fuer Favoriten + Zuletzt hinzugefuegt
|
|
|
|
Header als <button> mit Chevron + Count-Pill, slide-Transition (180ms).
|
|
State in localStorage unter kochwas.collapsed.sections — JSON-Map
|
|
{favorites, recent}, default beide offen, corrupt-JSON faellt auf
|
|
Default zurueck.
|
|
|
|
Alle Rezepte bleibt absichtlich nicht-collapsibel — Hauptliste, immer
|
|
sichtbar.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Push + Verify Deploy
|
|
|
|
- [ ] **Step 1: Push all commits**
|
|
|
|
```bash
|
|
git push
|
|
```
|
|
|
|
- [ ] **Step 2: Wait for CI build**
|
|
|
|
Check Gitea Actions: https://gitea.siegeln.net/claude/kochwas/actions
|
|
|
|
Wait until the latest run for the most recent commit shows `success`.
|
|
On the Pi this is typically 3-5 min for arm64.
|
|
|
|
- [ ] **Step 3: Verify on kochwas-dev**
|
|
|
|
Open: https://kochwas-dev.siegeln.net/
|
|
|
|
Manual checks:
|
|
1. Pick a profile, view 3 recipes, return to home
|
|
2. Click "Zuletzt angesehen" sort chip — those 3 should be at the top in reverse-view-order
|
|
3. Switch profiles → list refetches; if other profile has different views, they appear
|
|
4. Click "Deine Favoriten" header — section collapses; click again — expands
|
|
5. Click "Zuletzt hinzugefügt" header — same
|
|
6. Reload — collapsed states restored
|
|
|
|
If something's off, fix in a small follow-up commit.
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
**Spec coverage check:**
|
|
|
|
| Spec Requirement | Implemented in |
|
|
|---|---|
|
|
| Migration `recipe_views` | Task 1 |
|
|
| `recordView` repo function | Task 2 |
|
|
| Sort `'viewed'` in DB layer | Task 3 |
|
|
| `POST /api/recipes/[id]/view` | Task 4 |
|
|
| Client beacon in detail page | Task 5 |
|
|
| API accepts `profile_id` | Task 6 |
|
|
| Home passes `profile_id` everywhere | Task 7 |
|
|
| `ALL_SORTS` adds `'viewed'` + reactive refetch | Task 8 |
|
|
| Collapsible Favoriten + Recent + persistence | Task 9 |
|
|
| Test strategy (migration, sort logic, endpoint) | Tasks 1, 3, 4, 6 |
|
|
| Snapshot rehydrate passes profile_id | Task 7 (`buildAllUrl` is used in `rehydrateAll`) |
|
|
|
|
All spec requirements have a task. No placeholders. Type names consistent
|
|
across tasks (`AllSort`, `CollapseKey`). Profile access goes through
|
|
`profileStore.active?.id` consistently.
|
|
|
|
**Out of scope:**
|
|
- "Alle Rezepte" stays uncollapsible (explicit in spec)
|
|
- No view tracking when no profile is active (explicit in spec)
|
|
- No analytics dashboard for views (not asked for)
|