diff --git a/docs/superpowers/plans/2026-04-19-ingredient-sections.md b/docs/superpowers/plans/2026-04-19-ingredient-sections.md new file mode 100644 index 0000000..f966e16 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-ingredient-sections.md @@ -0,0 +1,634 @@ +# Ingredient 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:** Zutaten können im Editor in benannte Sektionen (z. B. „Für den Teig", „Für die Füllung") gruppiert werden; in der View werden die Sektionen als Überschriften über den zugehörigen Zutatenblöcken gerendert. + +**Architecture:** Eine neue nullable Spalte `section_heading` auf `ingredient`. Ist sie gesetzt, startet an dieser Zeile eine neue Sektion — alle folgenden Zutaten gehören dazu bis zur nächsten Zeile mit gesetzter `section_heading`. Ordnung bleibt `position`. Keine neue Tabelle, keine zweite Ordnungsachse, Scaler/FTS/Importer bleiben unverändert im Verhalten (nur Type-Passthrough). Inline-Button „Abschnitt hinzufügen" erscheint im Editor vor jeder Zutatenzeile und am Listenende. + +**Tech Stack:** better-sqlite3 Migration, TypeScript-strict, Svelte 5 runes, vitest. + +**Scope-Entscheidungen (vom User bestätigt):** +- Sektionen **nur für Zutaten**, nicht für Zubereitungsschritte. +- „Abschnitt hinzufügen"-Button inline vor jeder Zeile (plus einer am Listenende). +- Keine Import-Extraction — JSON-LD hat keine Sektionen, Emmikochteinfach rendert sie nur im HTML. Später via HTML-Parse möglich, aber out-of-scope. + +--- + +### Task 1: Migration + Type-Erweiterung + parseIngredient-Sites + +**Files:** +- Create: `src/lib/server/db/migrations/012_ingredient_section.sql` +- Modify: `src/lib/types.ts` (Ingredient type) +- Modify: `src/lib/server/parsers/ingredient.ts` (3 return sites) +- Test: `tests/unit/ingredient.test.ts` (bereits existierend, muss grün bleiben) + +**Warum zusammen:** Nach der Type-Änderung schlägt `svelte-check` überall fehl, wo ein `Ingredient`-Literal gebaut wird. `parseIngredient` hat 3 solcher Stellen und ist vom selben Commit abhängig, sonst wird der Build rot. + +- [ ] **Step 1: Migration schreiben** + +Create `src/lib/server/db/migrations/012_ingredient_section.sql`: +```sql +-- Nullable — alte Zeilen behalten NULL, neue dürfen eine Überschrift haben. +-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL und nicht leer), +-- startet an dieser Zeile eine neue Sektion mit diesem Titel. +ALTER TABLE ingredient ADD COLUMN section_heading TEXT; +``` + +- [ ] **Step 2: Ingredient-Type erweitern** + +Modify `src/lib/types.ts`: +```ts +export type Ingredient = { + position: number; + quantity: number | null; + unit: string | null; + name: string; + note: string | null; + raw_text: string; + section_heading: string | null; +}; +``` + +- [ ] **Step 3: parseIngredient-Return-Sites aktualisieren** + +Modify `src/lib/server/parsers/ingredient.ts`: +Alle drei `return { position, ... raw_text: rawText };`-Literale (Zeilen 108, 115, 119) bekommen `section_heading: null` am Ende. Beispiel für Zeile 108: +```ts +return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null }; +``` +Analog für Zeilen 115 und 119. + +- [ ] **Step 4: Bestehende Unit-Tests grün** + +Run: `npm run test -- ingredient.test.ts` +Expected: PASS (Tests prüfen nur vorhandene Felder, neues Feld stört nicht). + +- [ ] **Step 5: Svelte-Check muss noch rot sein** + +Run: `npm run check` +Expected: FAIL mit Fehlern in `repository.ts` (Select-Statement ohne `section_heading`). Das ist erwartet — wird in Task 2 behoben. Nicht hier fixen. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/types.ts src/lib/server/db/migrations/012_ingredient_section.sql src/lib/server/parsers/ingredient.ts +git commit -m "feat(schema): ingredient.section_heading (Migration 012 + Type)" +``` + +--- + +### Task 2: Repository-Layer Persistenz + +**Files:** +- Modify: `src/lib/server/recipes/repository.ts` (insertRecipe, replaceIngredients, getRecipeById) +- Test: `tests/integration/recipe-repository.test.ts` + +**Warum jetzt:** Nach Task 1 ist der Type-Vertrag aufgemacht. Die DB muss das Feld lesen und schreiben, sonst gehen Sektionen beim Save/Load verloren. + +- [ ] **Step 1: Failing test für Roundtrip** + +Add to `tests/integration/recipe-repository.test.ts` inside `describe('recipe repository', ...)`: +```ts +it('persistiert section_heading und gibt es beim Laden zurück', () => { + const db = openInMemoryForTest(); + const recipe = baseRecipe({ + title: 'Torte', + ingredients: [ + { position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' }, + { position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null }, + { position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' } + ] + }); + const id = insertRecipe(db, recipe); + const loaded = getRecipeById(db, id); + expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig'); + expect(loaded!.ingredients[1].section_heading).toBeNull(); + expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung'); +}); + +it('replaceIngredients persistiert section_heading', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, baseRecipe({ title: 'X' })); + replaceIngredients(db, id, [ + { position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' } + ]); + const loaded = getRecipeById(db, id); + expect(loaded!.ingredients[0].section_heading).toBe('Kopf'); +}); +``` + +- [ ] **Step 2: Test laufen — muss fehlschlagen** + +Run: `npm run test -- recipe-repository.test.ts` +Expected: FAIL — `section_heading` kommt als `undefined` zurück, weil SQL-SELECT es nicht holt. + +- [ ] **Step 3: INSERT-Statements erweitern** + +Modify `src/lib/server/recipes/repository.ts`: + +In `insertRecipe` (line ~66): Spalte + Parameter anhängen. +```ts +const insIng = db.prepare( + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +); +for (const ing of recipe.ingredients) { + insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading); +} +``` + +In `replaceIngredients` (line ~217): gleiche Änderung. +```ts +const ins = db.prepare( + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +); +for (const ing of ingredients) { + ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading); +} +``` + +- [ ] **Step 4: SELECT-Statement erweitern** + +In `getRecipeById` (line ~105): +```ts +const ingredients = db + .prepare( + `SELECT position, quantity, unit, name, note, raw_text, section_heading + FROM ingredient WHERE recipe_id = ? ORDER BY position` + ) + .all(id) as Ingredient[]; +``` + +- [ ] **Step 5: Tests grün** + +Run: `npm run test -- recipe-repository.test.ts` +Expected: PASS. + +- [ ] **Step 6: Volle Suite + svelte-check** + +Run: `npm test && npm run check` +Expected: Beides PASS. `svelte-check` ist jetzt auf Repo-Ebene typ-clean; View/Editor noch nicht berührt, deren Nutzung von `Ingredient` bleibt (Feld darf fehlen, weil der Type optional wirkt? — Nein, es ist `string | null`, also **pflicht**. Falls `check` rot wird, liegt es an Importer/Scaler-Aufrufern, die `Ingredient`-Literale bauen. Das ist dann Task 3.) + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/server/recipes/repository.ts tests/integration/recipe-repository.test.ts +git commit -m "feat(db): section_heading roundtrip in recipe-repository" +``` + +--- + +### Task 3: Importer-Passthrough + Scaler-Test + +**Files:** +- Modify: `src/lib/recipes/scaler.ts` (nur falls Test rot — siehe unten) +- Test: `tests/unit/scaler.test.ts` +- Test: evtl. `tests/integration/importer.test.ts` + +**Warum:** parseIngredient setzt `section_heading: null` (Task 1). Das reicht für den Importer — keine JSON-LD-Extraction. Aber der Scaler ruft `.map((i) => ({ ...i, quantity: ... }))` auf; das Spread erhält `section_heading` automatisch. Wir fügen nur einen Regressions-Test hinzu, dass das stimmt. + +- [ ] **Step 1: Scaler-Regressions-Test** + +Add to `tests/unit/scaler.test.ts`: +```ts +it('preserves section_heading through scaling', () => { + const input: Ingredient[] = [ + { position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Teig' }, + { position: 2, quantity: null, unit: null, name: 'Ei', note: null, raw_text: 'Ei', section_heading: null } + ]; + const scaled = scaleIngredients(input, 2); + expect(scaled[0].section_heading).toBe('Teig'); + expect(scaled[1].section_heading).toBeNull(); + expect(scaled[0].quantity).toBe(400); +}); +``` + +- [ ] **Step 2: Test laufen** + +Run: `npm run test -- scaler.test.ts` +Expected: PASS (weil `...i` das Feld durchreicht). + +Falls FAIL: In `src/lib/recipes/scaler.ts` das `.map` prüfen — es sollte `...i` spreaden und nur `quantity` überschreiben. Bei Abweichung angleichen. + +- [ ] **Step 3: Importer-Roundtrip-Test (Bolognese-Fixture)** + +Prüfen, dass Importer für Emmi-Fixture `section_heading: null` auf allen Zutaten liefert. Der existierende `importer.test.ts` sollte automatisch grün bleiben (parseIngredient setzt das Feld auf null), aber wir schauen kurz nach: + +Run: `npm run test -- importer.test.ts` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add tests/unit/scaler.test.ts +git commit -m "test(scaler): section_heading ueberlebt Skalierung" +``` + +--- + +### Task 4: IngredientRow — Heading-Anzeige + Inline Insert-Button + +**Files:** +- Modify: `src/lib/components/recipe-editor-types.ts` +- Modify: `src/lib/components/IngredientRow.svelte` +- Test: neue Svelte-Component-Tests via vitest-browser — **ausgenommen**: wir haben keine Svelte-Component-Unit-Tests im Repo. Stattdessen decken E2E + manuelle Verifikation ab. Das ist konsistent mit der bestehenden Praxis. + +**Verhalten:** +- `DraftIng` bekommt `section_heading: string | null` (immer gesetzt, aber nullable). +- Hat eine Zeile `section_heading` als String (auch leer), wird oberhalb der Row ein `` für den Titel gerendert plus ein kleiner „Sektion entfernen"-Button. +- Hat eine Zeile `section_heading === null`, wird ein dezenter `` **über** der Row gerendert. +- IngredientRow bekommt Callbacks `onaddSection`, `onremoveSection` — Parent verwaltet das Array. + +- [ ] **Step 1: DraftIng-Typ erweitern** + +Modify `src/lib/components/recipe-editor-types.ts`: +```ts +export type DraftIng = { + qty: string; + unit: string; + name: string; + note: string; + section_heading: string | null; +}; +export type DraftStep = { text: string }; +``` + +- [ ] **Step 2: IngredientRow erweitern — Props** + +Modify `src/lib/components/IngredientRow.svelte` Script-Block: +```svelte + +``` + +- [ ] **Step 3: IngredientRow-Template — Section-Block + Add-Button** + +Replace the existing `