diff --git a/src/lib/components/IngredientRow.svelte b/src/lib/components/IngredientRow.svelte
index cb775fe..075ced0 100644
--- a/src/lib/components/IngredientRow.svelte
+++ b/src/lib/components/IngredientRow.svelte
@@ -1,5 +1,5 @@
+{#if ing.section_heading === null}
+
{#each scaled as ing, i (i)}
+ {#if ing.section_heading && ing.section_heading.trim()}
+ - {ing.section_heading}
+ {/if}
-
{#if ing.quantity !== null || ing.unit}
@@ -281,6 +284,19 @@
padding: 0;
margin: 0;
}
+ .ing-list .section-heading {
+ list-style: none;
+ font-weight: 600;
+ color: #2b6a3d;
+ font-size: 1rem;
+ margin-top: 0.9rem;
+ margin-bottom: 0.2rem;
+ padding: 0.15rem 0;
+ border-bottom: 1px solid #e4eae7;
+ }
+ .ing-list .section-heading:first-child {
+ margin-top: 0;
+ }
.ing-list li {
display: flex;
gap: 0.75rem;
diff --git a/src/lib/components/recipe-editor-types.ts b/src/lib/components/recipe-editor-types.ts
index ec8e899..fbda2ab 100644
--- a/src/lib/components/recipe-editor-types.ts
+++ b/src/lib/components/recipe-editor-types.ts
@@ -3,6 +3,7 @@ export type DraftIng = {
unit: string;
name: string;
note: string;
+ section_heading: string | null;
};
export type DraftStep = { text: string };
diff --git a/src/lib/server/db/migrations/012_ingredient_section.sql b/src/lib/server/db/migrations/012_ingredient_section.sql
new file mode 100644
index 0000000..c4ce7ff
--- /dev/null
+++ b/src/lib/server/db/migrations/012_ingredient_section.sql
@@ -0,0 +1,7 @@
+-- Nullable-Spalte fuer optionale Sektionsueberschriften bei Zutaten. User
+-- soll im Editor gruppieren koennen ("Fuer den Teig", "Fuer die Fuellung").
+-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL, nicht leer),
+-- startet an dieser Zeile eine neue Sektion mit diesem Titel; alle folgenden
+-- Zutaten gehoeren dazu, bis die naechste Zeile wieder eine Ueberschrift hat.
+-- Ordnung bleibt die bestehende position-Spalte.
+ALTER TABLE ingredient ADD COLUMN section_heading TEXT;
diff --git a/src/lib/server/parsers/ingredient.ts b/src/lib/server/parsers/ingredient.ts
index 51230fc..8d28b83 100644
--- a/src/lib/server/parsers/ingredient.ts
+++ b/src/lib/server/parsers/ingredient.ts
@@ -105,16 +105,16 @@ export function parseIngredient(raw: string, position = 0): Ingredient {
if (tail.length > 0) {
const quantity = clampQuantity(UNICODE_FRACTION_MAP[firstChar]);
const { unit, name } = splitUnitAndName(tail);
- return { position, quantity, unit, name, note, raw_text: rawText };
+ return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
}
}
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
const qtyMatch = qtyPattern.exec(working);
if (!qtyMatch) {
- return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
+ return { position, quantity: null, unit: null, name: working, note, raw_text: rawText, section_heading: null };
}
const quantity = clampQuantity(parseQuantity(qtyMatch[1]));
const { unit, name } = splitUnitAndName(qtyMatch[2]);
- return { position, quantity, unit, name, note, raw_text: rawText };
+ return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null };
}
diff --git a/src/lib/server/recipes/repository.ts b/src/lib/server/recipes/repository.ts
index 24d54ee..6a97c17 100644
--- a/src/lib/server/recipes/repository.ts
+++ b/src/lib/server/recipes/repository.ts
@@ -64,11 +64,11 @@ export function insertRecipe(db: Database.Database, recipe: Recipe): number {
const id = Number(info.lastInsertRowid);
const insIng = db.prepare(
- `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
- VALUES (?, ?, ?, ?, ?, ?, ?)`
+ `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);
+ insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
const insStep = db.prepare(
@@ -104,7 +104,7 @@ export function getRecipeById(db: Database.Database, id: number): Recipe | null
const ingredients = db
.prepare(
- `SELECT position, quantity, unit, name, note, raw_text
+ `SELECT position, quantity, unit, name, note, raw_text, section_heading
FROM ingredient WHERE recipe_id = ? ORDER BY position`
)
.all(id) as Ingredient[];
@@ -215,11 +215,11 @@ export function replaceIngredients(
const tx = db.transaction(() => {
db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId);
const ins = db.prepare(
- `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
- VALUES (?, ?, ?, ?, ?, ?, ?)`
+ `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);
+ ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
refreshFts(db, recipeId);
});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c2d63d2..4be0532 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -5,6 +5,7 @@ export type Ingredient = {
name: string;
note: string | null;
raw_text: string;
+ section_heading: string | null;
};
export type Step = {
diff --git a/src/routes/api/recipes/[id]/+server.ts b/src/routes/api/recipes/[id]/+server.ts
index 5448d9d..37f73b0 100644
--- a/src/routes/api/recipes/[id]/+server.ts
+++ b/src/routes/api/recipes/[id]/+server.ts
@@ -24,7 +24,8 @@ const IngredientSchema = z.object({
unit: z.string().max(30).nullable(),
name: z.string().min(1).max(200),
note: z.string().max(300).nullable(),
- raw_text: z.string().max(500)
+ raw_text: z.string().max(500),
+ section_heading: z.string().max(200).nullable()
});
const StepSchema = z.object({
diff --git a/tests/e2e/remote/ingredient-sections.spec.ts b/tests/e2e/remote/ingredient-sections.spec.ts
new file mode 100644
index 0000000..7bad458
--- /dev/null
+++ b/tests/e2e/remote/ingredient-sections.spec.ts
@@ -0,0 +1,219 @@
+import { test, expect } from '@playwright/test';
+import { setActiveProfile, HENDRIK_ID } from './fixtures/profile';
+
+// Helper: idempotent recipe delete.
+async function deleteRecipe(
+ request: Parameters[1]>[0]['request'],
+ id: number
+): Promise {
+ await request.delete(`/api/recipes/${id}`);
+}
+
+// Shared ingredient payload builder — fills all required Zod fields.
+function makeIngredient(
+ position: number,
+ name: string,
+ section_heading: string | null,
+ overrides: Partial<{
+ quantity: number | null;
+ unit: string | null;
+ note: string | null;
+ raw_text: string;
+ }> = {}
+) {
+ return {
+ position,
+ quantity: overrides.quantity ?? null,
+ unit: overrides.unit ?? null,
+ name,
+ note: overrides.note ?? null,
+ raw_text: overrides.raw_text ?? name,
+ section_heading
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Per-test cleanup scaffolding — single variable, reset in beforeEach.
+// ─────────────────────────────────────────────────────────────────────────────
+
+let createdId: number | null = null;
+
+test.beforeEach(() => {
+ createdId = null;
+});
+
+test.afterEach(async ({ request }) => {
+ if (createdId !== null) {
+ await deleteRecipe(request, createdId);
+ createdId = null;
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test 1 — pure API roundtrip (no browser needed)
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('API: section_heading persistiert ueber PATCH + GET', async ({ request }) => {
+ // 1. Create blank recipe.
+ const createRes = await request.post('/api/recipes/blank');
+ expect(createRes.status()).toBe(200);
+ const { id } = (await createRes.json()) as { id: number };
+ createdId = id;
+
+ // 2. PATCH with 3 ingredients carrying section_heading values.
+ const patchRes = await request.patch(`/api/recipes/${id}`, {
+ data: {
+ ingredients: [
+ makeIngredient(1, 'Mehl', 'Fuer den Teig', { quantity: 200, unit: 'g', raw_text: '200 g Mehl' }),
+ makeIngredient(2, 'Zucker', null, { quantity: 100, unit: 'g', raw_text: '100 g Zucker' }),
+ makeIngredient(3, 'Beeren', 'Fuer die Fuellung', { quantity: 150, unit: 'g', raw_text: '150 g Beeren' })
+ ]
+ }
+ });
+ expect(patchRes.status()).toBe(200);
+
+ // 3. GET and assert persisted values.
+ const getRes = await request.get(`/api/recipes/${id}`);
+ expect(getRes.status()).toBe(200);
+ const body = (await getRes.json()) as {
+ recipe: { ingredients: Array<{ name: string; section_heading: string | null }> };
+ };
+ const ings = body.recipe.ingredients;
+
+ const mehl = ings.find((i) => i.name === 'Mehl');
+ const zucker = ings.find((i) => i.name === 'Zucker');
+ const beeren = ings.find((i) => i.name === 'Beeren');
+
+ expect(mehl?.section_heading).toBe('Fuer den Teig');
+ expect(zucker?.section_heading).toBeNull();
+ expect(beeren?.section_heading).toBe('Fuer die Fuellung');
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test 2 — UI edit flow: add section, save, assert view renders heading
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('Editor: Abschnitt via Inline-Button anlegen, View rendert Ueberschrift', async ({
+ page,
+ request
+}) => {
+ // 1. Create blank recipe via API.
+ const createRes = await request.post('/api/recipes/blank');
+ expect(createRes.status()).toBe(200);
+ const { id } = (await createRes.json()) as { id: number };
+ createdId = id;
+
+ // 2. Open recipe in edit mode.
+ await setActiveProfile(page, HENDRIK_ID);
+ await page.goto(`/recipes/${id}?edit=1`);
+
+ // 3. Add two ingredient rows.
+ const addIngBtn = page.getByRole('button', { name: /Zutat hinzufügen/i });
+ await addIngBtn.click();
+ await addIngBtn.click();
+
+ // Fill the two ingredient rows by aria-label "Zutat" inputs.
+ const nameInputs = page.locator('.ing-list .ing-row input[aria-label="Zutat"]');
+ await nameInputs.nth(0).fill('Mehl');
+ await nameInputs.nth(1).fill('Zucker');
+
+ // 4. Click "Abschnitt hinzufügen" above the first row.
+ // The button is inside .section-insert which is opacity:0 until hover/focus.
+ // Hover the ing-list to trigger visibility, then click.
+ await page.hover('.ing-list');
+ await page.locator('.ing-list .add-section').first().click();
+
+ // 5. Type heading text into the section-heading input that appeared.
+ const headingInput = page.locator('.ing-list input[aria-label="Sektionsüberschrift"]').first();
+ await headingInput.fill('Fuer den Teig');
+
+ // 6. Save — exact match to avoid colliding with "Kommentar speichern".
+ await page.getByRole('button', { name: 'Speichern', exact: true }).click();
+
+ // After save, editMode becomes false — page switches to view mode.
+ // Wait for the section-heading element to confirm view mode is active.
+ await expect(page.locator('.ing-list .section-heading').first()).toBeVisible({ timeout: 8000 });
+
+ // 7. Assert heading text is rendered.
+ await expect(page.locator('.ing-list .section-heading').first()).toHaveText('Fuer den Teig');
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test 3 — UI: remove an existing section heading, save, confirm it's gone
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('Editor: Sektion entfernen speichert ohne Ueberschrift', async ({ page, request }) => {
+ // 1. Create blank recipe and pre-populate via API.
+ const createRes = await request.post('/api/recipes/blank');
+ expect(createRes.status()).toBe(200);
+ const { id } = (await createRes.json()) as { id: number };
+ createdId = id;
+
+ await request.patch(`/api/recipes/${id}`, {
+ data: {
+ ingredients: [makeIngredient(1, 'Butter', 'Teig', { raw_text: 'Butter' })]
+ }
+ });
+
+ // 2. Open editor.
+ await setActiveProfile(page, HENDRIK_ID);
+ await page.goto(`/recipes/${id}?edit=1`);
+
+ // The section-heading-row should be visible since heading = 'Teig'.
+ const removeBtn = page
+ .locator('.ing-list')
+ .getByRole('button', { name: 'Sektion entfernen' });
+ await expect(removeBtn).toBeVisible({ timeout: 6000 });
+
+ // 3. Click the section-remove X button.
+ await removeBtn.click();
+
+ // 4. Save — exact match to avoid colliding with "Kommentar speichern".
+ await page.getByRole('button', { name: 'Speichern', exact: true }).click();
+
+ // Wait for view mode (editMode = false makes RecipeEditor unmount).
+ // The .section-heading-row is part of the editor; in view mode we check
+ // the view's .ing-list for absence of .section-heading items.
+ await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Test 4 — empty heading trims to null on save
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('Editor: leeres Heading wird beim Speichern zu null', async ({ page, request }) => {
+ // 1. Create blank recipe.
+ const createRes = await request.post('/api/recipes/blank');
+ expect(createRes.status()).toBe(200);
+ const { id } = (await createRes.json()) as { id: number };
+ createdId = id;
+
+ // 2. Open editor, add one ingredient, open section input and leave it empty.
+ await setActiveProfile(page, HENDRIK_ID);
+ await page.goto(`/recipes/${id}?edit=1`);
+
+ await page.getByRole('button', { name: /Zutat hinzufügen/i }).click();
+ await page.locator('.ing-list .ing-row input[aria-label="Zutat"]').first().fill('Eier');
+
+ // Trigger add-section visibility and click.
+ await page.hover('.ing-list');
+ await page.locator('.ing-list .add-section').first().click();
+
+ // Leave the heading input empty (do not type anything).
+ // The save() function trims '' → null.
+
+ // 3. Save — exact match to avoid colliding with "Kommentar speichern".
+ await page.getByRole('button', { name: 'Speichern', exact: true }).click();
+
+ // Wait until view mode is active (editor gone).
+ await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 });
+
+ // 4. Confirm via API that section_heading is null.
+ const getRes = await request.get(`/api/recipes/${id}`);
+ expect(getRes.status()).toBe(200);
+ const body = (await getRes.json()) as {
+ recipe: { ingredients: Array<{ name: string; section_heading: string | null }> };
+ };
+ const eier = body.recipe.ingredients.find((i) => i.name === 'Eier');
+ expect(eier?.section_heading).toBeNull();
+});
diff --git a/tests/integration/recipe-repository.test.ts b/tests/integration/recipe-repository.test.ts
index 6cd0ebd..2821469 100644
--- a/tests/integration/recipe-repository.test.ts
+++ b/tests/integration/recipe-repository.test.ts
@@ -70,7 +70,8 @@ describe('recipe repository', () => {
unit: 'g',
name: 'Pancetta',
note: null,
- raw_text: '200 g Pancetta'
+ raw_text: '200 g Pancetta',
+ section_heading: null
}
],
tags: ['Italienisch']
@@ -118,13 +119,13 @@ describe('recipe repository', () => {
baseRecipe({
title: 'Pasta',
ingredients: [
- { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta' }
+ { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null }
]
})
);
replaceIngredients(db, id, [
- { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln' },
- { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier' }
+ { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln', section_heading: null },
+ { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier', section_heading: null }
]);
const loaded = getRecipeById(db, id);
expect(loaded?.ingredients.length).toBe(2);
@@ -154,4 +155,31 @@ describe('recipe repository', () => {
const loaded = getRecipeById(db, id);
expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']);
});
+
+ it('persistiert section_heading und gibt es beim Laden zurueck', () => {
+ 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');
+ });
});
diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts
index 8b53639..81b0299 100644
--- a/tests/integration/search-local.test.ts
+++ b/tests/integration/search-local.test.ts
@@ -48,7 +48,7 @@ describe('searchLocal', () => {
recipe({
title: 'Pasta',
ingredients: [
- { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '' }
+ { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '', section_heading: null }
]
})
);
diff --git a/tests/unit/scaler.test.ts b/tests/unit/scaler.test.ts
index 5edcef2..7aca3b5 100644
--- a/tests/unit/scaler.test.ts
+++ b/tests/unit/scaler.test.ts
@@ -8,7 +8,8 @@ const mk = (q: number | null, unit: string | null, name: string): Ingredient =>
unit,
name,
note: null,
- raw_text: ''
+ raw_text: '',
+ section_heading: null
});
describe('roundQuantity', () => {
@@ -40,4 +41,15 @@ describe('scaleIngredients', () => {
const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3);
expect(scaled[0].quantity).toBe(33);
});
+
+ 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);
+ });
});