Files
kochwas/docs/superpowers/plans/2026-04-21-shopping-list.md
hsiegeln fd5d759336
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
docs(plan): Implementierungs-Plan fuer Einkaufsliste
24 Tasks, TDD, bite-sized. Reihenfolge: Migration -> Repository -> Quantity-Formatter
-> API -> Service-Worker -> Client-Store -> Header-Badge -> Wunschlisten-Relayout
-> Shopping-List-Seite -> E2E.

E2E-Tests laufen erst nach Deploy gegen kochwas-dev.siegeln.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:37:04 +02:00

66 KiB
Raw Permalink Blame History

Einkaufsliste 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: Eine haushaltsweit geteilte Einkaufsliste, die Rezepte aus der Wunschliste aufnimmt, deren Zutaten aggregiert anzeigt, und beim Einkaufen abgehakt werden kann.

Architecture: Neue Tabellen shopping_cart_recipe + shopping_cart_check. Aggregation wird bei jedem Read aus shopping_cart_recipe JOIN ingredient × servings-Faktor derived — nichts materialisiert. Abhaken pro (name_key, unit_key). Client-Store analog wishlistStore hält uncheckedCount + recipeIds fürs Header-Badge.

Tech Stack: SvelteKit, better-sqlite3, Vite migrations via import.meta.glob, Vitest, Playwright (E2E nach Deploy), Zod für Body-Validation, Lucide-Icons (ShoppingCart).

Spec: docs/superpowers/specs/2026-04-21-shopping-list-design.md


Konventionen

Nach jedem Task:

  • npm test muss grün sein
  • npm run check muss 0 Errors / 0 Warnings zeigen
  • Commit folgt Projekt-Konvention (siehe CLAUDE.md): englische Subject-Zeile < 72 Zeichen, deutscher Body, kein --no-verify
  • Push nach jedem Commit (CI baut arm64-Image)

Commit-Prefix: feat(shopping) für neue Funktionalität, test(shopping) für reine Test-Commits, refactor(wishlist) für das Karten-Relayout, chore(db) für die Migration.


File Structure

Create:

  • src/lib/server/db/migrations/013_shopping_list.sql
  • src/lib/server/shopping/repository.ts
  • src/lib/quantity-format.ts
  • src/lib/client/shopping-cart.svelte.ts
  • src/routes/api/shopping-list/+server.ts
  • src/routes/api/shopping-list/recipe/+server.ts
  • src/routes/api/shopping-list/recipe/[recipe_id]/+server.ts
  • src/routes/api/shopping-list/check/+server.ts
  • src/routes/api/shopping-list/checked/+server.ts
  • src/routes/shopping-list/+page.svelte
  • src/lib/components/ShoppingCartChip.svelte
  • src/lib/components/ShoppingListRow.svelte
  • tests/integration/shopping-repository.test.ts
  • tests/unit/shopping-cart-store.test.ts
  • tests/unit/quantity-format.test.ts
  • tests/e2e/remote/shopping.spec.ts

Modify:

  • src/routes/+layout.svelte — ShoppingCart-Icon + Badge
  • src/routes/wishlist/+page.svelte — horizontale Action-Leiste, Cart-Button, Domain raus
  • src/lib/sw/cache-strategy.ts/api/shopping-list/* als network-only

Phase 1 — Datenbank

Task 1: Migration 013

Files:

  • Create: src/lib/server/db/migrations/013_shopping_list.sql

  • Step 1: Migration-Datei schreiben

-- Einkaufsliste: haushaltsweit geteilt. shopping_cart_recipe haelt die
-- Rezepte im Wagen (inkl. gewuenschter Portionsgroesse), shopping_cart_check
-- die abgehakten aggregierten Zutaten-Zeilen. Aggregation wird bei jedem
-- Read aus shopping_cart_recipe JOIN ingredient derived — nichts
-- materialisiert, damit Rezept-Edits live durchschlagen.
CREATE TABLE shopping_cart_recipe (
  recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
  servings INTEGER NOT NULL CHECK (servings > 0),
  added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
  added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE shopping_cart_check (
  name_key TEXT NOT NULL,
  unit_key TEXT NOT NULL,
  checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (name_key, unit_key)
);
  • Step 2: Tests laufen lassen — Migration wird vom Bundler aufgenommen

Run: npm test Expected: Alle bestehenden Tests grün. openInMemoryForTest wendet die neue Migration auto. an.

  • Step 3: svelte-check

Run: npm run check Expected: 0 ERRORS 0 WARNINGS

  • Step 4: Commit
git add src/lib/server/db/migrations/013_shopping_list.sql
git commit -m "chore(db): Migration 013 fuer Einkaufsliste-Tabellen"
git push

Phase 2 — Repository (TDD)

Task 2: Repository-Skeleton + Types

Files:

  • Create: src/lib/server/shopping/repository.ts

  • Step 1: Skeleton mit Types und noch nicht implementierten Funktionen

import type Database from 'better-sqlite3';

export type ShoppingCartRecipe = {
  recipe_id: number;
  title: string;
  image_path: string | null;
  servings: number;
  servings_default: number;
};

export type ShoppingListRow = {
  name_key: string;
  unit_key: string;
  display_name: string;
  display_unit: string | null;
  total_quantity: number | null;
  from_recipes: string;
  checked: 0 | 1;
};

export type ShoppingListSnapshot = {
  recipes: ShoppingCartRecipe[];
  rows: ShoppingListRow[];
  uncheckedCount: number;
};

export function addRecipeToCart(
  _db: Database.Database,
  _recipeId: number,
  _profileId: number | null,
  _servings?: number
): void {
  throw new Error('not implemented');
}

export function removeRecipeFromCart(
  _db: Database.Database,
  _recipeId: number
): void {
  throw new Error('not implemented');
}

export function setCartServings(
  _db: Database.Database,
  _recipeId: number,
  _servings: number
): void {
  throw new Error('not implemented');
}

export function listShoppingList(
  _db: Database.Database
): ShoppingListSnapshot {
  throw new Error('not implemented');
}

export function toggleCheck(
  _db: Database.Database,
  _nameKey: string,
  _unitKey: string,
  _checked: boolean
): void {
  throw new Error('not implemented');
}

export function clearCheckedItems(_db: Database.Database): void {
  throw new Error('not implemented');
}

export function clearCart(_db: Database.Database): void {
  throw new Error('not implemented');
}
  • Step 2: svelte-check

Run: npm run check Expected: 0 ERRORS 0 WARNINGS

  • Step 3: Commit
git add src/lib/server/shopping/repository.ts
git commit -m "feat(shopping): Repository-Skeleton mit Types"
git push

Task 3: addRecipeToCart (idempotent)

Files:

  • Create: tests/integration/shopping-repository.test.ts

  • Modify: src/lib/server/shopping/repository.ts

  • Step 1: Test schreiben

import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
import { insertRecipe } from '../../src/lib/server/recipes/repository';
import {
  addRecipeToCart,
  listShoppingList
} from '../../src/lib/server/shopping/repository';
import type { Recipe } from '../../src/lib/types';

function recipe(overrides: Partial<Recipe> = {}): Recipe {
  return {
    id: null,
    title: 'Test',
    description: null,
    source_url: null,
    source_domain: null,
    image_path: null,
    servings_default: 4,
    servings_unit: null,
    prep_time_min: null,
    cook_time_min: null,
    total_time_min: null,
    cuisine: null,
    category: null,
    ingredients: [],
    steps: [],
    tags: [],
    ...overrides
  };
}

describe('addRecipeToCart', () => {
  it('inserts recipe with default servings from recipe.servings_default', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({ title: 'Pasta', servings_default: 4 }));
    addRecipeToCart(db, id, null);
    const snap = listShoppingList(db);
    expect(snap.recipes).toHaveLength(1);
    expect(snap.recipes[0].servings).toBe(4);
  });

  it('respects explicit servings override', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({ servings_default: 4 }));
    addRecipeToCart(db, id, null, 2);
    expect(listShoppingList(db).recipes[0].servings).toBe(2);
  });

  it('is idempotent: second insert updates servings, not fails', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({ servings_default: 4 }));
    addRecipeToCart(db, id, null, 2);
    addRecipeToCart(db, id, null, 6);
    const snap = listShoppingList(db);
    expect(snap.recipes).toHaveLength(1);
    expect(snap.recipes[0].servings).toBe(6);
  });

  it('falls back to servings=4 when recipe has no default', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({ servings_default: null }));
    addRecipeToCart(db, id, null);
    expect(listShoppingList(db).recipes[0].servings).toBe(4);
  });
});
  • Step 2: Test läuft — muss FAILEN (Funktion wirft 'not implemented')

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: alle 4 Tests FAIL (throw 'not implemented' oder listShoppingList not implemented)

  • Step 3: Minimal implementieren

addRecipeToCart + listShoppingList-Skelett (nur Recipes, Rows kommt später):

export function addRecipeToCart(
  db: Database.Database,
  recipeId: number,
  profileId: number | null,
  servings?: number
): void {
  const row = db
    .prepare('SELECT servings_default FROM recipe WHERE id = ?')
    .get(recipeId) as { servings_default: number | null } | undefined;
  const resolved = servings ?? row?.servings_default ?? 4;
  db.prepare(
    `INSERT INTO shopping_cart_recipe (recipe_id, servings, added_by_profile_id)
     VALUES (?, ?, ?)
     ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`
  ).run(recipeId, resolved, profileId);
}

export function listShoppingList(
  db: Database.Database
): ShoppingListSnapshot {
  const recipes = db
    .prepare(
      `SELECT cr.recipe_id, r.title, r.image_path, cr.servings,
              COALESCE(r.servings_default, cr.servings) AS servings_default
         FROM shopping_cart_recipe cr
         JOIN recipe r ON r.id = cr.recipe_id
        ORDER BY cr.added_at ASC`
    )
    .all() as ShoppingCartRecipe[];
  return { recipes, rows: [], uncheckedCount: 0 };
}
  • Step 4: Test grün?

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: 4/4 PASS

  • Step 5: Commit
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): addRecipeToCart (idempotent via ON CONFLICT)"
git push

Task 4: removeRecipeFromCart

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Test schreiben (neuer describe-Block ans Ende der Datei)

import {
  addRecipeToCart,
  removeRecipeFromCart,
  listShoppingList
} from '../../src/lib/server/shopping/repository';

describe('removeRecipeFromCart', () => {
  it('deletes only the given recipe', () => {
    const db = openInMemoryForTest();
    const a = insertRecipe(db, recipe({ title: 'A' }));
    const b = insertRecipe(db, recipe({ title: 'B' }));
    addRecipeToCart(db, a, null);
    addRecipeToCart(db, b, null);
    removeRecipeFromCart(db, a);
    const snap = listShoppingList(db);
    expect(snap.recipes).toHaveLength(1);
    expect(snap.recipes[0].recipe_id).toBe(b);
  });

  it('is idempotent when recipe is not in cart', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe());
    expect(() => removeRecipeFromCart(db, id)).not.toThrow();
  });
});
  • Step 2: Test muss failen

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: die neuen 2 Tests FAIL ('not implemented')

  • Step 3: Implementieren
export function removeRecipeFromCart(
  db: Database.Database,
  recipeId: number
): void {
  db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(recipeId);
}
  • Step 4: Tests grün?

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: alle Tests PASS

  • Step 5: Commit
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): removeRecipeFromCart"
git push

Task 5: setCartServings

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Test schreiben

import { setCartServings } from '../../src/lib/server/shopping/repository';

describe('setCartServings', () => {
  it('updates servings for a cart recipe', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe());
    addRecipeToCart(db, id, null, 4);
    setCartServings(db, id, 8);
    expect(listShoppingList(db).recipes[0].servings).toBe(8);
  });

  it('rejects non-positive servings', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe());
    addRecipeToCart(db, id, null, 4);
    expect(() => setCartServings(db, id, 0)).toThrow();
    expect(() => setCartServings(db, id, -3)).toThrow();
  });
});
  • Step 2: Test muss failen

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: neue Tests FAIL

  • Step 3: Implementieren
export function setCartServings(
  db: Database.Database,
  recipeId: number,
  servings: number
): void {
  if (!Number.isInteger(servings) || servings <= 0) {
    throw new Error(`Invalid servings: ${servings}`);
  }
  db.prepare(
    'UPDATE shopping_cart_recipe SET servings = ? WHERE recipe_id = ?'
  ).run(servings, recipeId);
}
  • Step 4: Tests grün?

Run: npx vitest run tests/integration/shopping-repository.test.ts

  • Step 5: Commit
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): setCartServings mit Positiv-Validation"
git push

Task 6: listShoppingList mit Aggregation

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Tests schreiben für Aggregation

describe('listShoppingList aggregation', () => {
  it('aggregates same name+unit across recipes', () => {
    const db = openInMemoryForTest();
    const a = insertRecipe(db, recipe({
      title: 'Carbonara', servings_default: 4,
      ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    const b = insertRecipe(db, recipe({
      title: 'Lasagne', servings_default: 4,
      ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, a, null, 4);
    addRecipeToCart(db, b, null, 4);
    const rows = listShoppingList(db).rows;
    expect(rows).toHaveLength(1);
    expect(rows[0].name_key).toBe('mehl');
    expect(rows[0].unit_key).toBe('g');
    expect(rows[0].total_quantity).toBe(400);
    expect(rows[0].from_recipes).toContain('Carbonara');
    expect(rows[0].from_recipes).toContain('Lasagne');
  });

  it('keeps different units as separate rows', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      servings_default: 4,
      ingredients: [
        { position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null },
        { position: 2, quantity: 1, unit: 'Pck', name: 'Mehl', note: null, raw_text: '', section_heading: null }
      ]
    }));
    addRecipeToCart(db, id, null, 4);
    const rows = listShoppingList(db).rows;
    expect(rows).toHaveLength(2);
  });

  it('scales quantities by servings/servings_default', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      servings_default: 4,
      ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null, 2);
    expect(listShoppingList(db).rows[0].total_quantity).toBe(100);
  });

  it('null quantity stays null after aggregation', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [{ position: 1, quantity: null, unit: null, name: 'Salz', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null);
    const rows = listShoppingList(db).rows;
    expect(rows[0].total_quantity).toBeNull();
    expect(rows[0].unit_key).toBe('');
  });

  it('counts unchecked rows in uncheckedCount', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [
        { position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null },
        { position: 2, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null }
      ]
    }));
    addRecipeToCart(db, id, null);
    expect(listShoppingList(db).uncheckedCount).toBe(2);
  });
});
  • Step 2: Tests laufen — müssen failen

Run: npx vitest run tests/integration/shopping-repository.test.ts Expected: die 5 neuen Tests FAIL

  • Step 3: listShoppingList ausbauen
export function listShoppingList(
  db: Database.Database
): ShoppingListSnapshot {
  const recipes = db
    .prepare(
      `SELECT cr.recipe_id, r.title, r.image_path, cr.servings,
              COALESCE(r.servings_default, cr.servings) AS servings_default
         FROM shopping_cart_recipe cr
         JOIN recipe r ON r.id = cr.recipe_id
        ORDER BY cr.added_at ASC`
    )
    .all() as ShoppingCartRecipe[];

  const rows = db
    .prepare(
      `SELECT
         LOWER(TRIM(i.name)) AS name_key,
         LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
         MIN(i.name) AS display_name,
         MIN(i.unit) AS display_unit,
         SUM(i.quantity * cr.servings * 1.0 / COALESCE(r.servings_default, cr.servings)) AS total_quantity,
         GROUP_CONCAT(DISTINCT r.title) AS from_recipes,
         EXISTS(
           SELECT 1 FROM shopping_cart_check c
            WHERE c.name_key = LOWER(TRIM(i.name))
              AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))
         ) AS checked
       FROM shopping_cart_recipe cr
       JOIN recipe r ON r.id = cr.recipe_id
       JOIN ingredient i ON i.recipe_id = r.id
       GROUP BY name_key, unit_key
       ORDER BY checked ASC, display_name COLLATE NOCASE`
    )
    .all() as ShoppingListRow[];

  const uncheckedCount = rows.reduce((n, r) => n + (r.checked ? 0 : 1), 0);
  return { recipes, rows, uncheckedCount };
}
  • Step 4: Tests grün?

Run: npx vitest run tests/integration/shopping-repository.test.ts

  • Step 5: Commit
git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): listShoppingList mit Aggregation + Skalierung"
git push

Task 7: toggleCheck

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Tests schreiben

import { toggleCheck } from '../../src/lib/server/shopping/repository';

describe('toggleCheck', () => {
  function setupOneRowCart() {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null);
    return { db, id };
  }

  it('marks a row as checked', () => {
    const { db } = setupOneRowCart();
    toggleCheck(db, 'mehl', 'g', true);
    const rows = listShoppingList(db).rows;
    expect(rows[0].checked).toBe(1);
  });

  it('unchecks a row when passed false', () => {
    const { db } = setupOneRowCart();
    toggleCheck(db, 'mehl', 'g', true);
    toggleCheck(db, 'mehl', 'g', false);
    expect(listShoppingList(db).rows[0].checked).toBe(0);
  });

  it('check survives removal of one recipe when another still contributes', () => {
    const db = openInMemoryForTest();
    const a = insertRecipe(db, recipe({
      title: 'A',
      ingredients: [{ position: 1, quantity: 100, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    const b = insertRecipe(db, recipe({
      title: 'B',
      ingredients: [{ position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, a, null);
    addRecipeToCart(db, b, null);
    toggleCheck(db, 'mehl', 'g', true);
    // Rezept A weg, Mehl kommt noch aus B — check bleibt, mit neuer Menge
    removeRecipeFromCart(db, a);
    const rows = listShoppingList(db).rows;
    expect(rows[0].checked).toBe(1);
    expect(rows[0].total_quantity).toBe(200);
  });
});
  • Step 2: Tests laufen — müssen failen

Run: npx vitest run tests/integration/shopping-repository.test.ts

  • Step 3: Implementieren
export function toggleCheck(
  db: Database.Database,
  nameKey: string,
  unitKey: string,
  checked: boolean
): void {
  if (checked) {
    db.prepare(
      `INSERT INTO shopping_cart_check (name_key, unit_key)
       VALUES (?, ?)
       ON CONFLICT(name_key, unit_key) DO NOTHING`
    ).run(nameKey, unitKey);
  } else {
    db.prepare(
      'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
    ).run(nameKey, unitKey);
  }
}
  • Step 4: Tests grün?

  • Step 5: Commit

git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): toggleCheck (idempotent)"
git push

Task 8: clearCheckedItems

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Tests schreiben

import { clearCheckedItems } from '../../src/lib/server/shopping/repository';

describe('clearCheckedItems', () => {
  it('removes recipes where ALL rows are checked', () => {
    const db = openInMemoryForTest();
    const a = insertRecipe(db, recipe({
      title: 'A',
      ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
    }));
    const b = insertRecipe(db, recipe({
      title: 'B',
      ingredients: [
        { position: 1, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null },
        { position: 2, quantity: 1, unit: 'Stk', name: 'Salz', note: null, raw_text: '', section_heading: null }
      ]
    }));
    addRecipeToCart(db, a, null);
    addRecipeToCart(db, b, null);
    toggleCheck(db, 'apfel', 'stk', true);
    toggleCheck(db, 'birne', 'stk', true);
    // Salz aus B noch nicht abgehakt → B bleibt, A fliegt
    clearCheckedItems(db);
    const snap = listShoppingList(db);
    expect(snap.recipes.map((r) => r.recipe_id)).toEqual([b]);
    // Birne-Check bleibt, weil B noch im Cart und Birne noch aktiv
    const birneRow = snap.rows.find((r) => r.name_key === 'birne');
    expect(birneRow?.checked).toBe(1);
  });

  it('purges orphan checks that no longer map to any cart recipe', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null);
    toggleCheck(db, 'apfel', 'stk', true);
    clearCheckedItems(db);
    // Apfel-Check haengt jetzt an nichts mehr → muss aus der Tabelle raus sein
    const row = db
      .prepare('SELECT * FROM shopping_cart_check WHERE name_key = ?')
      .get('apfel');
    expect(row).toBeUndefined();
  });

  it('is a no-op when nothing is checked', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null);
    clearCheckedItems(db);
    expect(listShoppingList(db).recipes).toHaveLength(1);
  });
});
  • Step 2: Tests müssen failen

  • Step 3: Implementieren

export function clearCheckedItems(db: Database.Database): void {
  const tx = db.transaction(() => {
    // Alle aggregierten Zeilen mit checked-Status holen, pro recipe_id gruppieren
    // und Rezepte finden, deren Zeilen ALLE abgehakt sind.
    const allRows = db
      .prepare(
        `SELECT
           cr.recipe_id,
           LOWER(TRIM(i.name)) AS name_key,
           LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
           EXISTS(
             SELECT 1 FROM shopping_cart_check c
              WHERE c.name_key = LOWER(TRIM(i.name))
                AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))
           ) AS checked
         FROM shopping_cart_recipe cr
         JOIN ingredient i ON i.recipe_id = cr.recipe_id`
      )
      .all() as { recipe_id: number; name_key: string; unit_key: string; checked: 0 | 1 }[];

    const perRecipe = new Map<number, { total: number; checked: number }>();
    for (const r of allRows) {
      const e = perRecipe.get(r.recipe_id) ?? { total: 0, checked: 0 };
      e.total += 1;
      e.checked += r.checked;
      perRecipe.set(r.recipe_id, e);
    }
    const toRemove: number[] = [];
    for (const [id, e] of perRecipe) {
      if (e.total > 0 && e.total === e.checked) toRemove.push(id);
    }
    for (const id of toRemove) {
      db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(id);
    }

    // Orphan-Checks raeumen: alle Check-Keys, die jetzt in KEINEM Cart-Rezept
    // mehr vorkommen.
    const activeKeys = db
      .prepare(
        `SELECT DISTINCT
           LOWER(TRIM(i.name)) AS name_key,
           LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key
         FROM shopping_cart_recipe cr
         JOIN ingredient i ON i.recipe_id = cr.recipe_id`
      )
      .all() as { name_key: string; unit_key: string }[];
    const activeSet = new Set(activeKeys.map((k) => `${k.name_key} ${k.unit_key}`));
    const allChecks = db
      .prepare('SELECT name_key, unit_key FROM shopping_cart_check')
      .all() as { name_key: string; unit_key: string }[];
    const del = db.prepare(
      'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
    );
    for (const c of allChecks) {
      if (!activeSet.has(`${c.name_key} ${c.unit_key}`)) {
        del.run(c.name_key, c.unit_key);
      }
    }
  });
  tx();
}
  • Step 4: Tests grün?

  • Step 5: Commit

git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): clearCheckedItems + Orphan-Cleanup"
git push

Task 9: clearCart

Files:

  • Modify: src/lib/server/shopping/repository.ts

  • Modify: tests/integration/shopping-repository.test.ts

  • Step 1: Test schreiben

import { clearCart } from '../../src/lib/server/shopping/repository';

describe('clearCart', () => {
  it('deletes all cart recipes and all checks', () => {
    const db = openInMemoryForTest();
    const id = insertRecipe(db, recipe({
      ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }]
    }));
    addRecipeToCart(db, id, null);
    toggleCheck(db, 'apfel', 'stk', true);
    clearCart(db);
    const snap = listShoppingList(db);
    expect(snap.recipes).toEqual([]);
    expect(snap.rows).toEqual([]);
    expect(snap.uncheckedCount).toBe(0);
    const anyCheck = db.prepare('SELECT 1 FROM shopping_cart_check').get();
    expect(anyCheck).toBeUndefined();
  });
});
  • Step 2: Test muss failen

  • Step 3: Implementieren

export function clearCart(db: Database.Database): void {
  const tx = db.transaction(() => {
    db.prepare('DELETE FROM shopping_cart_recipe').run();
    db.prepare('DELETE FROM shopping_cart_check').run();
  });
  tx();
}
  • Step 4: Tests grün?

  • Step 5: Commit

git add src/lib/server/shopping/repository.ts tests/integration/shopping-repository.test.ts
git commit -m "feat(shopping): clearCart"
git push

Phase 3 — Quantity-Formatter

Task 10: formatQuantity + Tests

Files:

  • Create: src/lib/quantity-format.ts

  • Create: tests/unit/quantity-format.test.ts

  • Step 1: Tests schreiben

import { describe, it, expect } from 'vitest';
import { formatQuantity } from '../../src/lib/quantity-format';

describe('formatQuantity', () => {
  it('renders null as empty string', () => {
    expect(formatQuantity(null)).toBe('');
  });

  it('renders whole numbers as integer', () => {
    expect(formatQuantity(400)).toBe('400');
  });

  it('renders near-integer as integer (epsilon 0.01)', () => {
    expect(formatQuantity(400.001)).toBe('400');
    expect(formatQuantity(399.999)).toBe('400');
  });

  it('renders fractional with up to 2 decimals, trailing zeros trimmed', () => {
    expect(formatQuantity(0.5)).toBe('0.5');
    expect(formatQuantity(0.333333)).toBe('0.33');
    expect(formatQuantity(1.1)).toBe('1.1');
    expect(formatQuantity(1.10)).toBe('1.1');
  });

  it('handles zero', () => {
    expect(formatQuantity(0)).toBe('0');
  });
});
  • Step 2: Test muss failen (Modul existiert nicht)

Run: npx vitest run tests/unit/quantity-format.test.ts

  • Step 3: Implementieren
export function formatQuantity(q: number | null): string {
  if (q === null || q === undefined) return '';
  const rounded = Math.round(q);
  if (Math.abs(q - rounded) < 0.01) return String(rounded);
  // auf max. 2 Nachkommastellen, trailing Nullen raus
  return q
    .toFixed(2)
    .replace(/\.?0+$/, '');
}
  • Step 4: Tests grün?

Run: npx vitest run tests/unit/quantity-format.test.ts

  • Step 5: Commit
git add src/lib/quantity-format.ts tests/unit/quantity-format.test.ts
git commit -m "feat(shopping): formatQuantity-Utility"
git push

Phase 4 — API-Routen

Task 11: GET /api/shopping-list + DELETE (Liste leeren)

Files:

  • Create: src/routes/api/shopping-list/+server.ts

  • Step 1: Endpoint implementieren

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { clearCart, listShoppingList } from '$lib/server/shopping/repository';

export const GET: RequestHandler = async () => {
  return json(listShoppingList(getDb()));
};

export const DELETE: RequestHandler = async () => {
  clearCart(getDb());
  return json({ ok: true });
};
  • Step 2: svelte-check

Run: npm run check Expected: 0/0

  • Step 3: Tests laufen (bestehende Tests bleiben grün, neuer Endpoint unbenutzt)

Run: npm test

  • Step 4: Commit
git add src/routes/api/shopping-list/+server.ts
git commit -m "feat(shopping): GET /api/shopping-list + DELETE (Liste leeren)"
git push

Task 12: POST /api/shopping-list/recipe

Files:

  • Create: src/routes/api/shopping-list/recipe/+server.ts

  • Step 1: Implementieren

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { validateBody } from '$lib/server/api-helpers';
import { addRecipeToCart } from '$lib/server/shopping/repository';

const AddSchema = z.object({
  recipe_id: z.number().int().positive(),
  servings: z.number().int().min(1).max(50).optional(),
  profile_id: z.number().int().positive().optional()
});

export const POST: RequestHandler = async ({ request }) => {
  const data = validateBody(await request.json().catch(() => null), AddSchema);
  addRecipeToCart(getDb(), data.recipe_id, data.profile_id ?? null, data.servings);
  return json({ ok: true }, { status: 201 });
};
  • Step 2: svelte-check + tests

Run: npm run check && npm test

  • Step 3: Commit
git add src/routes/api/shopping-list/recipe/+server.ts
git commit -m "feat(shopping): POST /api/shopping-list/recipe"
git push

Task 13: PATCH/DELETE /api/shopping-list/recipe/[recipe_id]

Files:

  • Create: src/routes/api/shopping-list/recipe/[recipe_id]/+server.ts

  • Step 1: Implementieren

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers';
import { removeRecipeFromCart, setCartServings } from '$lib/server/shopping/repository';

const PatchSchema = z.object({
  servings: z.number().int().min(1).max(50)
});

export const PATCH: RequestHandler = async ({ params, request }) => {
  const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
  const data = validateBody(await request.json().catch(() => null), PatchSchema);
  setCartServings(getDb(), id, data.servings);
  return json({ ok: true });
};

export const DELETE: RequestHandler = async ({ params }) => {
  const id = parsePositiveIntParam(params.recipe_id, 'recipe_id');
  removeRecipeFromCart(getDb(), id);
  return json({ ok: true });
};
  • Step 2: svelte-check + tests

  • Step 3: Commit

git add src/routes/api/shopping-list/recipe/\[recipe_id\]/+server.ts
git commit -m "feat(shopping): PATCH/DELETE /api/shopping-list/recipe/[id]"
git push

Task 14: POST/DELETE /api/shopping-list/check

Files:

  • Create: src/routes/api/shopping-list/check/+server.ts

  • Step 1: Implementieren

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import { validateBody } from '$lib/server/api-helpers';
import { toggleCheck } from '$lib/server/shopping/repository';

const CheckSchema = z.object({
  name_key: z.string().min(1).max(200),
  unit_key: z.string().max(50)  // kann leer sein
});

export const POST: RequestHandler = async ({ request }) => {
  const data = validateBody(await request.json().catch(() => null), CheckSchema);
  toggleCheck(getDb(), data.name_key, data.unit_key, true);
  return json({ ok: true });
};

export const DELETE: RequestHandler = async ({ request }) => {
  const data = validateBody(await request.json().catch(() => null), CheckSchema);
  toggleCheck(getDb(), data.name_key, data.unit_key, false);
  return json({ ok: true });
};
  • Step 2: svelte-check + tests

  • Step 3: Commit

git add src/routes/api/shopping-list/check/+server.ts
git commit -m "feat(shopping): POST/DELETE /api/shopping-list/check"
git push

Task 15: DELETE /api/shopping-list/checked

Files:

  • Create: src/routes/api/shopping-list/checked/+server.ts

  • Step 1: Implementieren

import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { clearCheckedItems } from '$lib/server/shopping/repository';

export const DELETE: RequestHandler = async () => {
  clearCheckedItems(getDb());
  return json({ ok: true });
};
  • Step 2: svelte-check + tests

  • Step 3: Commit

git add src/routes/api/shopping-list/checked/+server.ts
git commit -m "feat(shopping): DELETE /api/shopping-list/checked (Erledigte entfernen)"
git push

Phase 5 — Service-Worker

Task 16: network-only für /api/shopping-list/*

Files:

  • Modify: src/lib/sw/cache-strategy.ts

  • Modify: tests/unit/cache-strategy.test.ts

  • Step 1: Test hinzufügen (ans Ende der bestehenden Datei)

it('network-only for /api/shopping-list/*', () => {
  expect(resolveStrategy({ url: '/api/shopping-list', method: 'GET' })).toBe('network-only');
  expect(resolveStrategy({ url: '/api/shopping-list/recipe/5', method: 'GET' })).toBe('network-only');
  expect(resolveStrategy({ url: '/api/shopping-list/check', method: 'GET' })).toBe('network-only');
});
  • Step 2: Test muss failen

Run: npx vitest run tests/unit/cache-strategy.test.ts

  • Step 3: cache-strategy.ts erweitern

In src/lib/sw/cache-strategy.ts, erweitere den „Explicitly online-only GETs"-Block:

  if (
    path === '/api/recipes/import' ||
    path === '/api/recipes/preview' ||
    path === '/api/recipes/extract-from-photo' ||
    path.startsWith('/api/recipes/search/web') ||
    path.startsWith('/api/shopping-list')
  ) {
    return 'network-only';
  }
  • Step 4: Tests grün?

Run: npm test

  • Step 5: Commit
git add src/lib/sw/cache-strategy.ts tests/unit/cache-strategy.test.ts
git commit -m "feat(shopping): Service-Worker network-only fuer /api/shopping-list/*"
git push

Phase 6 — Client-Store

Task 17: ShoppingCartStore + Tests

Files:

  • Create: src/lib/client/shopping-cart.svelte.ts

  • Create: tests/unit/shopping-cart-store.test.ts

  • Step 1: Tests schreiben

// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { ShoppingCartStore } from '../../src/lib/client/shopping-cart.svelte';

type FetchMock = ReturnType<typeof vi.fn>;

function snapshotBody(opts: {
  recipeIds?: number[];
  uncheckedCount?: number;
}) {
  return {
    recipes: (opts.recipeIds ?? []).map((id) => ({
      recipe_id: id, title: `R${id}`, image_path: null, servings: 4, servings_default: 4
    })),
    rows: [],
    uncheckedCount: opts.uncheckedCount ?? 0
  };
}

function makeFetch(responses: unknown[]): FetchMock {
  const queue = [...responses];
  return vi.fn(async () => ({
    ok: true,
    status: 200,
    json: async () => queue.shift()
  } as Response));
}

describe('ShoppingCartStore', () => {
  it('refresh populates recipeIds and uncheckedCount', async () => {
    const fetchImpl = makeFetch([snapshotBody({ recipeIds: [1, 2], uncheckedCount: 3 })]);
    const store = new ShoppingCartStore(fetchImpl);
    await store.refresh();
    expect(store.uncheckedCount).toBe(3);
    expect(store.isInCart(1)).toBe(true);
    expect(store.isInCart(2)).toBe(true);
    expect(store.isInCart(3)).toBe(false);
    expect(store.loaded).toBe(true);
  });

  it('addRecipe posts then refreshes', async () => {
    const fetchImpl = makeFetch([
      {},  // POST response
      snapshotBody({ recipeIds: [42], uncheckedCount: 5 })
    ]);
    const store = new ShoppingCartStore(fetchImpl);
    await store.addRecipe(42);
    expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe');
    expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'POST' });
    expect(store.isInCart(42)).toBe(true);
    expect(store.uncheckedCount).toBe(5);
  });

  it('removeRecipe deletes then refreshes', async () => {
    const fetchImpl = makeFetch([
      {},  // DELETE response
      snapshotBody({ recipeIds: [], uncheckedCount: 0 })
    ]);
    const store = new ShoppingCartStore(fetchImpl);
    await store.removeRecipe(42);
    expect(fetchImpl.mock.calls[0][0]).toBe('/api/shopping-list/recipe/42');
    expect(fetchImpl.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
    expect(store.uncheckedCount).toBe(0);
  });

  it('refresh keeps last known state on network error', async () => {
    const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'));
    const store = new ShoppingCartStore(fetchImpl);
    store.uncheckedCount = 7;
    await store.refresh();
    expect(store.uncheckedCount).toBe(7);
  });
});
  • Step 2: Tests müssen failen

  • Step 3: Store implementieren

type Snapshot = {
  recipes: { recipe_id: number }[];
  uncheckedCount: number;
};

export class ShoppingCartStore {
  uncheckedCount = $state(0);
  recipeIds = $state<Set<number>>(new Set());
  loaded = $state(false);

  private readonly fetchImpl: typeof fetch;

  constructor(fetchImpl?: typeof fetch) {
    this.fetchImpl = fetchImpl ?? ((...a) => fetch(...a));
  }

  async refresh(): Promise<void> {
    try {
      const res = await this.fetchImpl('/api/shopping-list');
      if (!res.ok) return;
      const body = (await res.json()) as Snapshot;
      this.recipeIds = new Set(body.recipes.map((r) => r.recipe_id));
      this.uncheckedCount = body.uncheckedCount;
      this.loaded = true;
    } catch {
      // keep last known state on network error
    }
  }

  async addRecipe(recipeId: number): Promise<void> {
    await this.fetchImpl('/api/shopping-list/recipe', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ recipe_id: recipeId })
    });
    await this.refresh();
  }

  async removeRecipe(recipeId: number): Promise<void> {
    await this.fetchImpl(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
    await this.refresh();
  }

  isInCart(recipeId: number): boolean {
    return this.recipeIds.has(recipeId);
  }
}

export const shoppingCartStore = new ShoppingCartStore();
  • Step 4: Tests grün?

Run: npx vitest run tests/unit/shopping-cart-store.test.ts

  • Step 5: Commit
git add src/lib/client/shopping-cart.svelte.ts tests/unit/shopping-cart-store.test.ts
git commit -m "feat(shopping): ShoppingCartStore (Client)"
git push

Phase 7 — Header-Badge

Task 18: ShoppingCart-Icon im Header

Files:

  • Modify: src/routes/+layout.svelte

  • Step 1: Icon-Import ergänzen

In src/routes/+layout.svelte, in der Lucide-Import-Liste (Zeile 5-13), ShoppingCart hinzufügen:

  import {
    Settings,
    CookingPot,
    Utensils,
    Menu,
    BookOpen,
    ArrowLeft,
    Camera,
    ShoppingCart
  } from 'lucide-svelte';
  • Step 2: Store importieren + onMount + afterNavigate erweitern
  import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';

In afterNavigate nach void wishlistStore.refresh(); ergänzen:

    void shoppingCartStore.refresh();

In onMount nach void wishlistStore.refresh();:

    void shoppingCartStore.refresh();
  • Step 3: Markup — neuer Link rechts vom CookingPot

Im <header>-Block, direkt nach dem </a>-Ende des wishlist-link (nach Zeile 270), vor <div class="menu-wrap">, einfügen:

      {#if shoppingCartStore.uncheckedCount > 0}
        <a
          href="/shopping-list"
          class="nav-link shopping-link"
          aria-label={`Einkaufsliste (${shoppingCartStore.uncheckedCount})`}
        >
          <ShoppingCart size={20} strokeWidth={2} />
          <span class="badge">{shoppingCartStore.uncheckedCount}</span>
        </a>
      {/if}

.shopping-link kann die gleichen Styles wie .wishlist-link nutzen — falls nötig im <style>-Block ergänzen (Farbe: gleiches Grün #2b6a3d).

  • Step 4: Manueller Test + svelte-check

Run: npm run dev, öffne http://localhost:5173, lege via POST /api/shopping-list/recipe ein Rezept in den Cart (oder warte bis der UI-Teil fertig ist und nutze die Wunschliste). Verify: Icon erscheint + Badge-Zahl stimmt.

Run: npm run check

  • Step 5: Commit
git add src/routes/+layout.svelte
git commit -m "feat(shopping): Header-Badge mit Einkaufswagen-Icon"
git push

Phase 8 — Wunschlisten-Karte Relayout

Task 19: Horizontale Action-Leiste + Cart-Button, Domain raus

Files:

  • Modify: src/routes/wishlist/+page.svelte

  • Step 1: Import ergänzen

In der Lucide-Import-Zeile (Zeile 3):

  import { Utensils, Trash2, CookingPot, ShoppingCart } from 'lucide-svelte';

Dazu:

  import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';
  • Step 2: toggleCart-Funktion schreiben

Nach removeForAll:

  async function toggleCart(entry: WishlistEntry) {
    if (!requireOnline('Die Einkaufsliste')) return;
    if (shoppingCartStore.isInCart(entry.recipe_id)) {
      await shoppingCartStore.removeRecipe(entry.recipe_id);
    } else {
      await shoppingCartStore.addRecipe(entry.recipe_id);
    }
  }

Im onMount ergänzen:

    void shoppingCartStore.refresh();
  • Step 3: Markup umbauen — Domain raus, Action-Leiste NEBEN dem Link (nicht darin)

Wichtig: Buttons dürfen nicht innerhalb eines <a> sitzen (interaktive Descendants sind im HTML-Content-Model nicht erlaubt). Darum trennen wir <a class="body"> und <div class="actions-top"> als Geschwister und positionieren die Action-Leiste per CSS position: absolute oben rechts in der Card.

Im <li class="card">-Template (Zeilen 114-157 der aktuellen Datei), das aktuelle Layout ersetzen durch:

      <li class="card">
        <a class="body" href={`/recipes/${e.recipe_id}`}>
          {#if resolveImage(e.image_path)}
            <img src={resolveImage(e.image_path)} alt="" loading="lazy" />
          {:else}
            <div class="placeholder"><CookingPot size={32} /></div>
          {/if}
          <div class="text">
            <div class="title">{e.title}</div>
            <div class="meta">
              {#if e.wanted_by_names}
                <span class="wanted-by">{e.wanted_by_names}</span>
              {/if}
              {#if e.avg_stars !== null}
                <span>· ★ {e.avg_stars.toFixed(1)}</span>
              {/if}
            </div>
          </div>
        </a>
        <div class="actions-top">
          <button
            class="like"
            class:active={e.on_my_wishlist}
            aria-label={e.on_my_wishlist ? 'Ich will das nicht mehr' : 'Ich will das auch'}
            onclick={() => toggleMine(e)}
          >
            <Utensils size={18} strokeWidth={2} />
            {#if e.wanted_by_count > 0}
              <span class="count">{e.wanted_by_count}</span>
            {/if}
          </button>
          <button
            class="cart"
            class:active={shoppingCartStore.isInCart(e.recipe_id)}
            aria-label={shoppingCartStore.isInCart(e.recipe_id)
              ? 'Aus Einkaufswagen entfernen'
              : 'In den Einkaufswagen'}
            onclick={() => toggleCart(e)}
          >
            <ShoppingCart size={18} strokeWidth={2} />
          </button>
          <button
            class="del"
            aria-label="Für alle entfernen"
            onclick={() => removeForAll(e)}
          >
            <Trash2 size={18} strokeWidth={2} />
          </button>
        </div>
      </li>

Wichtige Anpassungen:

  • <a class="body"> umschließt NUR Bild + Text, keine Buttons.

  • <div class="actions-top"> ist Geschwister von <a class="body"> und wird per CSS oben rechts in der .card positioniert (Card ist position: relative).

  • <span class="src"> mit Domain ist raus.

  • Step 4: Styles anpassen

Im <style>-Block: .actions entfernen, stattdessen hinzufügen:

  .card {
    position: relative;  /* neu: damit .actions-top absolut positioniert werden kann */
  }
  .actions-top {
    position: absolute;
    top: 0.5rem;
    right: 0.5rem;
    display: flex;
    gap: 0.4rem;
    z-index: 1;
  }
  .like,
  .cart,
  .del {
    min-width: 44px;
    min-height: 44px;
    border-radius: 10px;
    border: 1px solid #e4eae7;
    background: white;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.25rem;
    font-size: 1rem;
    color: #444;
  }
  .like.active {
    color: #2b6a3d;
    background: #eaf4ed;
    border-color: #b7d6c2;
  }
  .cart.active {
    color: #2b6a3d;
    background: #eaf4ed;
    border-color: #b7d6c2;
  }
  .del:hover {
    color: #c53030;
    border-color: #f1b4b4;
    background: #fdf3f3;
  }
  .count {
    font-size: 0.85rem;
    font-weight: 600;
  }
  .text {
    flex: 1;
    /* padding-right etwa = Breite von 3 Buttons (44px) * 3 + 2 * gap (0.4rem) + right (0.5rem) ≈ 170px */
    padding: 0.7rem 170px 0.7rem 0.75rem;
    min-width: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

Die bisherige .actions-Regel und die bisherigen .like/.del-Styles darunter können raus, sind durch Obiges ersetzt. Die bestehende .card-Regel muss um position: relative ergänzt werden (falls die andere Regel schon existiert, einfach den Property dort einsetzen).

  • Step 5: Manueller Test + svelte-check

Run: npm run dev, http://localhost:5173/wishlist. Verify auf Handy-Viewport (Chrome DevTools mobile 375px): Titel hat Platz, 3 Buttons oben rechts nebeneinander, keine Domain mehr.

Run: npm run check Expected: 0/0

  • Step 6: Commit
git add src/routes/wishlist/+page.svelte
git commit -m "refactor(wishlist): horizontale Actions + Einkaufswagen-Button"
git push

Phase 9 — Einkaufslisten-Seite

Task 20: Empty-State + Seiten-Grundgerüst

Files:

  • Create: src/routes/shopping-list/+page.svelte

  • Step 1: Grundgerüst + Empty State

<script lang="ts">
  import { onMount } from 'svelte';
  import { ShoppingCart } from 'lucide-svelte';
  import type { ShoppingListSnapshot } from '$lib/server/shopping/repository';

  let snapshot = $state<ShoppingListSnapshot>({ recipes: [], rows: [], uncheckedCount: 0 });
  let loading = $state(true);

  async function load() {
    loading = true;
    try {
      const res = await fetch('/api/shopping-list');
      snapshot = await res.json();
    } finally {
      loading = false;
    }
  }

  onMount(load);
</script>

<header class="head">
  <h1>Einkaufsliste</h1>
  {#if snapshot.recipes.length > 0}
    <p class="sub">
      {snapshot.uncheckedCount} noch zu besorgen · {snapshot.recipes.length} Rezept{snapshot.recipes.length === 1 ? '' : 'e'} im Wagen
    </p>
  {/if}
</header>

{#if loading}
  <p class="muted">Lädt …</p>
{:else if snapshot.recipes.length === 0}
  <section class="empty">
    <div class="big"><ShoppingCart size={48} strokeWidth={1.5} /></div>
    <p>Einkaufswagen ist leer.</p>
    <p class="hint">Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen.</p>
  </section>
{/if}

<style>
  .head { padding: 1.25rem 0 0.5rem; }
  .head h1 { margin: 0; font-size: 1.6rem; color: #2b6a3d; }
  .sub { margin: 0.2rem 0 0; color: #666; }
  .muted { color: #888; text-align: center; padding: 2rem 0; }
  .empty { text-align: center; padding: 3rem 1rem; }
  .big { color: #8fb097; display: inline-flex; margin: 0 0 0.5rem; }
  .hint { color: #888; font-size: 0.9rem; }
</style>

Hinweis: In diesem Zwischenstand rendert die Seite im „populated"-Fall nur den Header mit Counts — der {:else}-Zweig mit Chips/Rows/Footer kommt in Tasks 21-23. Lauffähig, kein Placeholder.

  • Step 2: svelte-check + manueller Test

Run: npm run check && npm run dev Besuche http://localhost:5173/shopping-list — Empty State sollte korrekt erscheinen.

  • Step 3: Commit
git add src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Einkaufslisten-Seite mit Empty-State"
git push

Task 21: Zutaten-Rows mit Checkbox

Files:

  • Create: src/lib/components/ShoppingListRow.svelte

  • Modify: src/routes/shopping-list/+page.svelte

  • Step 1: Row-Komponente

<script lang="ts">
  import type { ShoppingListRow } from '$lib/server/shopping/repository';
  import { formatQuantity } from '$lib/quantity-format';

  let { row, onToggle }: {
    row: ShoppingListRow;
    onToggle: (row: ShoppingListRow, next: boolean) => void;
  } = $props();

  const qtyStr = $derived(formatQuantity(row.total_quantity));
  const hasUnit = $derived(!!row.display_unit && row.display_unit.trim().length > 0);
</script>

<label class="row" class:checked={row.checked}>
  <input
    type="checkbox"
    checked={row.checked === 1}
    onchange={(e) => onToggle(row, (e.currentTarget as HTMLInputElement).checked)}
  />
  <span class="text">
    <span class="name">
      {#if qtyStr}
        <span class="qty">{qtyStr}{hasUnit ? ` ${row.display_unit}` : ''}</span>
      {/if}
      {row.display_name}
    </span>
    <span class="src">aus {row.from_recipes}</span>
  </span>
</label>

<style>
  .row {
    display: flex;
    gap: 0.75rem;
    align-items: flex-start;
    padding: 0.75rem;
    border: 1px solid #e4eae7;
    border-radius: 10px;
    background: white;
    cursor: pointer;
    min-height: 60px;
  }
  .row input {
    width: 24px;
    height: 24px;
    margin-top: 0.1rem;
    flex-shrink: 0;
    accent-color: #2b6a3d;
  }
  .text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
  .name { font-size: 1rem; }
  .qty { font-weight: 600; margin-right: 0.3rem; }
  .src { color: #888; font-size: 0.82rem; }
  .row.checked { background: #f6f8f7; }
  .row.checked .name,
  .row.checked .qty { text-decoration: line-through; color: #888; }
</style>
  • Step 2: Page-Einbindung

In src/routes/shopping-list/+page.svelte:

Import ergänzen:

  import ShoppingListRow from '$lib/components/ShoppingListRow.svelte';
  import type { ShoppingListRow as Row } from '$lib/server/shopping/repository';
  import { shoppingCartStore } from '$lib/client/shopping-cart.svelte';

Funktion hinzufügen:

  async function onToggleRow(row: Row, next: boolean) {
    const method = next ? 'POST' : 'DELETE';
    await fetch('/api/shopping-list/check', {
      method,
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ name_key: row.name_key, unit_key: row.unit_key })
    });
    await load();
    void shoppingCartStore.refresh();
  }

Die {#if}-Kette aus Task 20 um einen {:else}-Zweig mit der Liste erweitern (zwischen {/if} und <style> einfügen, bzw. den bisherigen {/if} durch folgendes ersetzen):

{:else}
  <ul class="list">
    {#each snapshot.rows as row (row.name_key + '|' + row.unit_key)}
      <li>
        <ShoppingListRow {row} onToggle={onToggleRow} />
      </li>
    {/each}
  </ul>
{/if}

Styles ergänzen:

  .list {
    list-style: none;
    padding: 0;
    margin: 0.75rem 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }
  • Step 3: svelte-check + manueller Test

Run: npm run check

Test im Browser: Lege ein Rezept via Wunschliste in den Cart → navigiere zu /shopping-list → Zutaten erscheinen → Abhaken → Reload → Status persistiert → Badge-Count im Header sinkt.

  • Step 4: Commit
git add src/lib/components/ShoppingListRow.svelte src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Zutaten-Rows mit Abhaken"
git push

Task 22: Rezept-Chips mit Portions-Stepper

Files:

  • Create: src/lib/components/ShoppingCartChip.svelte

  • Modify: src/routes/shopping-list/+page.svelte

  • Step 1: Chip-Komponente

<script lang="ts">
  import { X, Minus, Plus } from 'lucide-svelte';
  import type { ShoppingCartRecipe } from '$lib/server/shopping/repository';

  let { recipe, onServingsChange, onRemove }: {
    recipe: ShoppingCartRecipe;
    onServingsChange: (id: number, servings: number) => void;
    onRemove: (id: number) => void;
  } = $props();

  function dec() {
    if (recipe.servings > 1) onServingsChange(recipe.recipe_id, recipe.servings - 1);
  }
  function inc() {
    if (recipe.servings < 50) onServingsChange(recipe.recipe_id, recipe.servings + 1);
  }
</script>

<div class="chip">
  <a class="title" href={`/recipes/${recipe.recipe_id}`}>{recipe.title}</a>
  <div class="controls">
    <button aria-label="Portion weniger" onclick={dec} disabled={recipe.servings <= 1}>
      <Minus size={16} />
    </button>
    <span class="val" aria-label="Portionen">{recipe.servings}p</span>
    <button aria-label="Portion mehr" onclick={inc} disabled={recipe.servings >= 50}>
      <Plus size={16} />
    </button>
    <button aria-label="Rezept aus Einkaufsliste entfernen" class="rm" onclick={() => onRemove(recipe.recipe_id)}>
      <X size={16} />
    </button>
  </div>
</div>

<style>
  .chip {
    flex: 0 0 auto;
    padding: 0.5rem 0.75rem;
    background: white;
    border: 1px solid #cfd9d1;
    border-radius: 14px;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    min-width: 140px;
  }
  .title {
    color: #2b6a3d;
    font-weight: 600;
    font-size: 0.92rem;
    text-decoration: none;
    line-height: 1.2;
    max-width: 160px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .controls { display: flex; gap: 0.25rem; align-items: center; }
  .controls button {
    min-width: 32px;
    min-height: 32px;
    border-radius: 8px;
    border: 1px solid #e4eae7;
    background: white;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: #444;
  }
  .controls button:disabled { opacity: 0.4; cursor: not-allowed; }
  .controls button.rm { margin-left: auto; }
  .controls button.rm:hover { color: #c53030; border-color: #f1b4b4; background: #fdf3f3; }
  .val { min-width: 32px; text-align: center; font-weight: 600; color: #444; }
</style>
  • Step 2: Page-Einbindung

In src/routes/shopping-list/+page.svelte:

Import + Handler:

  import ShoppingCartChip from '$lib/components/ShoppingCartChip.svelte';

  async function onServingsChange(recipeId: number, servings: number) {
    await fetch(`/api/shopping-list/recipe/${recipeId}`, {
      method: 'PATCH',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ servings })
    });
    await load();
    void shoppingCartStore.refresh();
  }

  async function onRemoveRecipe(recipeId: number) {
    await fetch(`/api/shopping-list/recipe/${recipeId}`, { method: 'DELETE' });
    await load();
    void shoppingCartStore.refresh();
  }

Markup: direkt VOR der <ul class="list"> die Chip-Leiste einfügen:

  <div class="chips">
    {#each snapshot.recipes as r (r.recipe_id)}
      <ShoppingCartChip recipe={r} {onServingsChange} onRemove={onRemoveRecipe} />
    {/each}
  </div>

Styles:

  .chips {
    display: flex;
    gap: 0.5rem;
    overflow-x: auto;
    padding: 0.5rem 0;
    margin: 0.5rem 0;
    -webkit-overflow-scrolling: touch;
  }
  • Step 3: svelte-check + manueller Test

Test: Chips erscheinen, Plus/Minus ändert Portionen → Zutatenmengen reagieren live, X entfernt das Rezept.

  • Step 4: Commit
git add src/lib/components/ShoppingCartChip.svelte src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Rezept-Chips mit Portions-Stepper"
git push

Files:

  • Modify: src/routes/shopping-list/+page.svelte

  • Step 1: Imports + Handler

Import ergänzen:

  import { confirmAction } from '$lib/client/confirm.svelte';

Handler:

  const hasChecked = $derived(snapshot.rows.some((r) => r.checked === 1));

  async function clearChecked() {
    await fetch('/api/shopping-list/checked', { method: 'DELETE' });
    await load();
    void shoppingCartStore.refresh();
  }

  async function clearAll() {
    const ok = await confirmAction({
      title: 'Einkaufsliste leeren?',
      message: 'Alle Rezepte und abgehakten Zutaten werden entfernt. Das lässt sich nicht rückgängig machen.',
      confirmLabel: 'Leeren',
      destructive: true
    });
    if (!ok) return;
    await fetch('/api/shopping-list', { method: 'DELETE' });
    await load();
    void shoppingCartStore.refresh();
  }
  • Step 2: Markup — Sticky Footer, nur rendern wenn recipes > 0

Nach der <ul class="list"> (noch innerhalb des {:else}-Blocks):

  <div class="footer">
    {#if hasChecked}
      <button class="btn secondary" onclick={clearChecked}>Erledigte entfernen</button>
    {/if}
    <button class="btn destructive" onclick={clearAll}>Liste leeren</button>
  </div>

Styles:

  .footer {
    position: sticky;
    bottom: 0;
    background: #f4f8f5;
    padding: 0.75rem 0;
    display: flex;
    gap: 0.5rem;
    justify-content: flex-end;
    margin-top: 1rem;
    border-top: 1px solid #e4eae7;
  }
  .btn {
    padding: 0.6rem 1rem;
    border-radius: 10px;
    border: 1px solid #cfd9d1;
    background: white;
    cursor: pointer;
    font-family: inherit;
    font-size: 0.9rem;
    min-height: 44px;
  }
  .btn.secondary { color: #2b6a3d; border-color: #b7d6c2; }
  .btn.destructive { color: #c53030; border-color: #f1b4b4; }
  .btn.destructive:hover { background: #fdf3f3; }
  • Step 3: svelte-check + manueller Test

Tests:

  • „Erledigte entfernen" erscheint nur wenn mindestens eine Zeile abgehakt ist

  • Klick räumt vollständig abgehakte Rezepte raus

  • „Liste leeren" zeigt Confirm-Dialog; nach OK leer + Redirect zum Empty-State

  • Badge im Header verschwindet nach „Liste leeren"

  • Step 4: Commit

git add src/routes/shopping-list/+page.svelte
git commit -m "feat(shopping): Footer-Actions (Erledigte entfernen, Liste leeren)"
git push

Phase 10 — E2E-Tests (lauffähig nach Deploy)

Task 24: E2E-Suite schreiben

Files:

  • Create: tests/e2e/remote/shopping.spec.ts

Wichtig: Diese Tests laufen gegen kochwas-dev.siegeln.net. Vor Ausführung muss der Feature-Branch via CI auf Dev-Env deployed sein. Tests schreiben wir trotzdem jetzt, damit sie nach Deploy gleich lauffähig sind.

Fixture-Stil (bestätigt durch Sichtung von tests/e2e/remote/wishlist.spec.ts + fixtures/api-cleanup.ts):

  • Kein neues Anlegen von Test-Rezepten. Die Tests nutzen bereits auf dem Dev-System vorhandene Rezepte.

  • Profil-Setup via setActiveProfile(page, HENDRIK_ID) aus fixtures/profile.

  • Cleanup via afterEach-Hook, der den API-Endpoint DELETE /api/shopping-list aufruft.

  • test.skip(condition, ...) wird genutzt, wenn Voraussetzungen fehlen (z. B. Dev-System hat keine Wunschliste-Einträge).

  • Step 1: Cleanup-Helper ergänzen

In tests/e2e/remote/fixtures/api-cleanup.ts am Ende hinzufügen:

/**
 * Leert den haushaltsweiten Einkaufswagen. Idempotent.
 */
export async function clearShoppingCart(api: APIRequestContext): Promise<void> {
  await api.delete('/api/shopping-list');
}
  • Step 2: E2E-Test-Datei schreiben
import { test, expect } from '@playwright/test';
import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
import { clearShoppingCart } from './fixtures/api-cleanup';

test.describe('Einkaufsliste E2E', () => {
  test.afterEach(async ({ request }) => {
    await clearShoppingCart(request);
  });

  test('Cart-Button auf der Wunschliste erzeugt Header-Badge', async ({ page, request }) => {
    await setActiveProfile(page, HENDRIK_ID);
    // Voraussetzung: Dev-System hat mindestens einen Wunschlisten-Eintrag
    const wlRes = await request.get('/api/wishlist?sort=popular');
    const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
    test.skip(wlBody.entries.length === 0, 'Wunschliste leer auf Dev — Test uebersprungen');

    await page.goto('/wishlist');
    await page.getByLabel('In den Einkaufswagen').first().click();
    await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toBeVisible();
  });

  test('Shopping-List-Seite zeigt Rezept-Chip + Zutaten', async ({ page, request }) => {
    await setActiveProfile(page, HENDRIK_ID);
    const wlRes = await request.get('/api/wishlist?sort=popular');
    const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
    test.skip(wlBody.entries.length === 0, 'Wunschliste leer');
    const recipeId = wlBody.entries[0].recipe_id;

    await request.post('/api/shopping-list/recipe', { data: { recipe_id: recipeId } });
    await page.goto('/shopping-list');

    await expect(page.getByRole('heading', { level: 1, name: 'Einkaufsliste' })).toBeVisible();
    // Chip fuers Rezept sichtbar
    await expect(page.getByLabel('Portion weniger').first()).toBeVisible();
    // Mindestens eine Zutatenzeile
    await expect(page.locator('.row').first()).toBeVisible();
  });

  test('Portions-Stepper veraendert Mengen live', async ({ page, request }) => {
    await setActiveProfile(page, HENDRIK_ID);
    const wlRes = await request.get('/api/wishlist?sort=popular');
    const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
    test.skip(wlBody.entries.length === 0, 'Wunschliste leer');

    await request.post('/api/shopping-list/recipe', {
      data: { recipe_id: wlBody.entries[0].recipe_id, servings: 4 }
    });
    await page.goto('/shopping-list');
    // Menge der ersten Zeile "vorher" lesen
    const qtyBefore = await page.locator('.qty').first().textContent();
    // Portion +1
    await page.getByLabel('Portion mehr').first().click();
    // Nach Fetch+Rerender muss die Menge sich aendern (ungleich dem Vorher-Wert)
    await expect
      .poll(async () => (await page.locator('.qty').first().textContent())?.trim())
      .not.toBe(qtyBefore?.trim());
  });

  test('Abhaken: Zeile durchgestrichen, Badge-Count sinkt, persistiert nach Reload', async ({ page, request }) => {
    await setActiveProfile(page, HENDRIK_ID);
    const wlRes = await request.get('/api/wishlist?sort=popular');
    const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
    test.skip(wlBody.entries.length === 0, 'Wunschliste leer');

    await request.post('/api/shopping-list/recipe', {
      data: { recipe_id: wlBody.entries[0].recipe_id }
    });
    await page.goto('/shopping-list');

    const countBadge = page.getByLabel(/Einkaufsliste \(\d+\)/);
    const badgeTextBefore = await countBadge.textContent();
    const numBefore = Number((badgeTextBefore ?? '').replace(/\D+/g, '')) || 0;

    const firstRow = page.locator('label.row').first();
    await firstRow.locator('input[type=checkbox]').check();
    await expect(firstRow).toHaveClass(/checked/);

    // Badge muss sinken (nach Store-Refresh)
    await expect
      .poll(async () => {
        const t = (await countBadge.textContent()) ?? '';
        return Number(t.replace(/\D+/g, '')) || 0;
      })
      .toBeLessThan(numBefore);

    // Reload persistiert
    await page.reload();
    await expect(page.locator('label.row.checked').first()).toBeVisible();
  });

  test('Liste leeren: Confirm + Empty-State + Badge weg', async ({ page, request }) => {
    await setActiveProfile(page, HENDRIK_ID);
    const wlRes = await request.get('/api/wishlist?sort=popular');
    const wlBody = (await wlRes.json()) as { entries: { recipe_id: number }[] };
    test.skip(wlBody.entries.length === 0, 'Wunschliste leer');

    await request.post('/api/shopping-list/recipe', {
      data: { recipe_id: wlBody.entries[0].recipe_id }
    });
    await page.goto('/shopping-list');

    await page.getByRole('button', { name: 'Liste leeren' }).click();
    // Confirm-Dialog (ConfirmAction nutzt einen App-eigenen Dialog, kein native)
    await page.getByRole('button', { name: 'Leeren' }).click();

    await expect(page.getByText('Einkaufswagen ist leer.')).toBeVisible();
    await expect(page.getByLabel(/Einkaufsliste \(\d+\)/)).toHaveCount(0);
  });
});

Nicht direkt E2E-getestet (wegen Abhängigkeit von unbekanntem Dev-Rezept-Stand):

  • „Erledigte entfernen" mit Vollstaendig-vs-Teilweise-Logik → über Integration-Tests abgedeckt

  • Aggregation über zwei Rezepte mit gleichem Zutaten-Key → über Integration-Tests abgedeckt (zuverlässige Fixtures)

  • Step 3: svelte-check + TypeScript-Validierung

Run: npm run check Expected: 0/0

  • Step 4: Commit
git add tests/e2e/remote/shopping.spec.ts tests/e2e/remote/fixtures/api-cleanup.ts
git commit -m "test(shopping): E2E-Spec + clearShoppingCart-Fixture"
git push

Nach-Deploy-Checkliste

Nach dem Merge in main und erfolgreichem CI-Build + Deploy auf kochwas-dev.siegeln.net:

  • E2E-Suite ausführen:
    npx playwright test tests/e2e/remote/shopping.spec.ts
    
  • Manuell auf dem Handy testen (PWA-Install, Offline-Verhalten — Liste sollte bei Offline leer / Fehlermeldung zeigen, nicht crashen)
  • Prod-Deploy nach grünem Dev-Run

Out of Scope (nicht in diesem Plan)

  • Manuelle Einträge (Klopapier etc.)
  • Offline-Queue für Mutating-Calls
  • Supermarkt-Abteilungs-Sortierung
  • Fuzzy-Matching von Zutaten-Synonymen
  • Auto-Kopplung an cooking_log / Wunschliste-Remove beim Abhaken