Files
kochwas/docs/superpowers/plans/2026-04-22-views-and-collapsibles.md
hsiegeln 866a222265 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>
2026-04-22 14:08:54 +02:00

38 KiB

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_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.

Spec: docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md


File Structure

Create:

  • src/lib/server/db/migrations/014_recipe_view.sql — schema
  • src/lib/server/recipes/views.tsrecordView(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_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_view table

Files:

  • Create: src/lib/server/db/migrations/014_recipe_view.sql

  • Test: tests/integration/recipe-views.test.ts

  • Step 1: Write the failing test

Create tests/integration/recipe-views.test.ts:

import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';

describe('014_recipe_view migration', () => {
  it('creates recipe_view table with expected columns', () => {
    const db = openInMemoryForTest();
    const cols = db.prepare("PRAGMA table_info(recipe_view)").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_view)")
      .all() as Array<{ name: string }>;
    expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true);
  });
});
  • Step 2: Run test to verify it fails

Run: npm test -- tests/integration/recipe-views.test.ts Expected: FAIL — table recipe_view does not exist.

  • Step 3: Create the migration file

Create src/lib/server/db/migrations/014_recipe_view.sql:

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_view_recent
  ON recipe_view (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
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.
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:

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:

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_view (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_view
        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
git add src/lib/server/recipes/views.ts tests/integration/recipe-views.test.ts
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
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:

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:

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_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
  // 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_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,
                   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
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_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).

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:

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:

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

  // 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_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.

  • Step 5: Commit
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:

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:

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
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):

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:

const res = await fetch(
  `/api/recipes/all?sort=${allSort}&limit=${ALL_PAGE}&offset=${allRecipes.length}`
);

with:

const res = await fetch(buildAllUrl(allSort, ALL_PAGE, allRecipes.length));

In setAllSort(), replace:

const res = await fetch(
  `/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
);

with:

const res = await fetch(buildAllUrl(next, ALL_PAGE, 0));

In rehydrateAll(), replace:

const res = await fetch(`/api/recipes/all?sort=${sort}&limit=${count}&offset=0`);

with:

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

type AllSort = 'name' | 'rating' | 'cooked' | 'created';

Replace with:

type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed';

Find:

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:

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:

if (saved && ['name', 'rating', 'cooked', 'created'].includes(saved)) {

Replace with:

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:

// '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
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:

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):

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):

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):

{#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:

{#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:

{#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:

{#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):

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