From f2656bd9e3646205c1fb821addd68ddccb27df14 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:02:27 +0200 Subject: [PATCH] feat(shopping): listShoppingList konsolidiert g/kg + ml/l TS-seitige Family-Gruppierung via unitFamily() + consolidate() ersetzt die reine SQL-Aggregation. unit_key im ShoppingListRow traegt jetzt den Family-Key ('weight', 'volume' oder raw-unit). toggleCheck-Aufrufe und unit_key-Assertions in den Tests entsprechend angepasst. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/shopping/repository.ts | 73 +++++++++-- tests/integration/shopping-repository.test.ts | 123 +++++++++++++++++- 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/src/lib/server/shopping/repository.ts b/src/lib/server/shopping/repository.ts index c70350d..5c970c9 100644 --- a/src/lib/server/shopping/repository.ts +++ b/src/lib/server/shopping/repository.ts @@ -1,4 +1,5 @@ import type Database from 'better-sqlite3'; +import { consolidate, unitFamily } from '../unit-consolidation'; // Fallback when a recipe has no servings_default set — matches the default // used by RecipeEditor's "new recipe" template. @@ -80,7 +81,18 @@ export function listShoppingList( ) .all() as ShoppingCartRecipe[]; - const rows = db + // SQL aggregates per (name, raw-unit). Family-grouping + consolidation is + // done in TypeScript so SQL stays readable and the logic is unit-testable. + type RawRow = { + name_key: string; + unit_key: string; + display_name: string; + display_unit: string | null; + total_quantity: number | null; + from_recipes: string; + }; + + const raw = db .prepare( `SELECT LOWER(TRIM(i.name)) AS name_key, @@ -88,19 +100,60 @@ export function listShoppingList( MIN(i.name) AS display_name, MIN(i.unit) AS display_unit, SUM(i.quantity * cr.servings * 1.0 / NULLIF(COALESCE(r.servings_default, cr.servings), 0)) 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 + GROUP_CONCAT(DISTINCT r.title) AS from_recipes 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` + GROUP BY name_key, unit_key` ) - .all() as ShoppingListRow[]; + .all() as RawRow[]; + + // Load all checked keys up front + const checkedSet = new Set( + ( + db + .prepare('SELECT name_key, unit_key FROM shopping_cart_check') + .all() as { name_key: string; unit_key: string }[] + ).map((c) => `${c.name_key}|${c.unit_key}`) + ); + + // Group by (name_key, unitFamily(unit_key)) + const grouped = new Map(); + for (const r of raw) { + const familyKey = unitFamily(r.unit_key); + const key = `${r.name_key}|${familyKey}`; + const arr = grouped.get(key) ?? []; + arr.push(r); + grouped.set(key, arr); + } + + const rows: ShoppingListRow[] = []; + for (const [key, members] of grouped) { + const [nameKey, familyKey] = key.split('|'); + const consolidated = consolidate( + members.map((m) => ({ quantity: m.total_quantity, unit: m.display_unit })) + ); + const displayName = members[0].display_name; + const allRecipes = new Set(); + for (const m of members) { + for (const t of m.from_recipes.split(',')) allRecipes.add(t); + } + rows.push({ + name_key: nameKey, + unit_key: familyKey, + display_name: displayName, + display_unit: consolidated.unit, + total_quantity: consolidated.quantity, + from_recipes: [...allRecipes].join(','), + checked: checkedSet.has(`${nameKey}|${familyKey}`) ? 1 : 0 + }); + } + + // Sort: unchecked first, then alphabetically by display_name + rows.sort((a, b) => { + if (a.checked !== b.checked) return a.checked - b.checked; + return a.display_name.localeCompare(b.display_name, 'de', { sensitivity: 'base' }); + }); const uncheckedCount = rows.reduce((n, r) => n + (r.checked ? 0 : 1), 0); return { recipes, rows, uncheckedCount }; diff --git a/tests/integration/shopping-repository.test.ts b/tests/integration/shopping-repository.test.ts index 91d41b3..d2f2d93 100644 --- a/tests/integration/shopping-repository.test.ts +++ b/tests/integration/shopping-repository.test.ts @@ -123,7 +123,7 @@ describe('listShoppingList aggregation', () => { 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].unit_key).toBe('weight'); expect(rows[0].total_quantity).toBe(400); expect(rows[0].from_recipes).toContain('Carbonara'); expect(rows[0].from_recipes).toContain('Lasagne'); @@ -201,15 +201,15 @@ describe('toggleCheck', () => { it('marks a row as checked', () => { const { db } = setupOneRowCart(); - toggleCheck(db, 'mehl', 'g', true); + toggleCheck(db, 'mehl', 'weight', 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); + toggleCheck(db, 'mehl', 'weight', true); + toggleCheck(db, 'mehl', 'weight', false); expect(listShoppingList(db).rows[0].checked).toBe(0); }); @@ -225,7 +225,7 @@ describe('toggleCheck', () => { })); addRecipeToCart(db, a, null); addRecipeToCart(db, b, null); - toggleCheck(db, 'mehl', 'g', true); + toggleCheck(db, 'mehl', 'weight', true); // Rezept A weg, Mehl kommt noch aus B — check bleibt, mit neuer Menge removeRecipeFromCart(db, a); const rows = listShoppingList(db).rows; @@ -304,3 +304,116 @@ describe('clearCart', () => { expect(anyCheck).toBeUndefined(); }); }); + +describe('listShoppingList — Konsolidierung ueber Einheiten', () => { + it('fasst 500 g + 1 kg Kartoffeln zu 1,5 kg zusammen', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'Kartoffelsuppe', + servings_default: 4, + ingredients: [ + { position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null } + ] + }) + ); + const b = insertRecipe( + db, + recipe({ + title: 'Kartoffelpuffer', + servings_default: 4, + ingredients: [ + { position: 1, name: 'Kartoffeln', quantity: 1, unit: 'kg', note: null, raw_text: '', section_heading: null } + ] + }) + ); + addRecipeToCart(db, a, null); + addRecipeToCart(db, b, null); + + const snap = listShoppingList(db); + const kartoffeln = snap.rows.filter((r) => r.display_name.toLowerCase() === 'kartoffeln'); + expect(kartoffeln).toHaveLength(1); + expect(kartoffeln[0].total_quantity).toBe(1.5); + expect(kartoffeln[0].display_unit).toBe('kg'); + }); + + it('kombiniert ml + l korrekt (400 ml + 0,5 l → 900 ml)', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'R1', + servings_default: 4, + ingredients: [{ position: 1, name: 'Milch', quantity: 400, unit: 'ml', note: null, raw_text: '', section_heading: null }] + }) + ); + const b = insertRecipe( + db, + recipe({ + title: 'R2', + servings_default: 4, + ingredients: [{ position: 1, name: 'Milch', quantity: 0.5, unit: 'l', note: null, raw_text: '', section_heading: null }] + }) + ); + addRecipeToCart(db, a, null); + addRecipeToCart(db, b, null); + + const milch = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'milch'); + expect(milch).toHaveLength(1); + expect(milch[0].total_quantity).toBe(900); + expect(milch[0].display_unit).toBe('ml'); + }); + + it('laesst inkompatible Families getrennt (5 Stueck Eier + 500 g Eier = 2 Zeilen)', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'R1', + servings_default: 4, + ingredients: [{ position: 1, name: 'Eier', quantity: 5, unit: 'Stück', note: null, raw_text: '', section_heading: null }] + }) + ); + const b = insertRecipe( + db, + recipe({ + title: 'R2', + servings_default: 4, + ingredients: [{ position: 1, name: 'Eier', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }] + }) + ); + addRecipeToCart(db, a, null); + addRecipeToCart(db, b, null); + + const eier = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'eier'); + expect(eier).toHaveLength(2); + }); + + it('summiert gleiche Unit-Family ohne Konversion (2 Bund + 1 Bund → 3 Bund)', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'R1', + servings_default: 4, + ingredients: [{ position: 1, name: 'Petersilie', quantity: 2, unit: 'Bund', note: null, raw_text: '', section_heading: null }] + }) + ); + const b = insertRecipe( + db, + recipe({ + title: 'R2', + servings_default: 4, + ingredients: [{ position: 1, name: 'Petersilie', quantity: 1, unit: 'Bund', note: null, raw_text: '', section_heading: null }] + }) + ); + addRecipeToCart(db, a, null); + addRecipeToCart(db, b, null); + + const petersilie = listShoppingList(db).rows.filter((r) => r.display_name.toLowerCase() === 'petersilie'); + expect(petersilie).toHaveLength(1); + expect(petersilie[0].total_quantity).toBe(3); + expect(petersilie[0].display_unit?.toLowerCase()).toBe('bund'); + }); +});