# 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 `
  • ` with: ```svelte {#if ing.section_heading === null}
  • {:else}
  • {/if}
  • ``` **Hinweis:** Wir rendern pro Row zwei `
  • `: optional einen Sektions-Block (Insert-Button ODER Heading-Input), plus die bestehende Zutaten-Row. Das passt in die `