diff --git a/src/lib/server/shopping/repository.ts b/src/lib/server/shopping/repository.ts index 58948ba..81f91dc 100644 --- a/src/lib/server/shopping/repository.ts +++ b/src/lib/server/shopping/repository.ts @@ -67,7 +67,9 @@ export function setCartServings( ).run(servings, recipeId); } -export function listShoppingList(db: Database.Database): ShoppingListSnapshot { +export function listShoppingList( + db: Database.Database +): ShoppingListSnapshot { const recipes = db .prepare( `SELECT cr.recipe_id, r.title, r.image_path, cr.servings, @@ -77,9 +79,31 @@ export function listShoppingList(db: Database.Database): ShoppingListSnapshot { ORDER BY cr.added_at ASC` ) .all() as ShoppingCartRecipe[]; - // TODO(Task 6): rows + uncheckedCount are populated by the aggregation query. - // Until then, callers must not rely on these fields. - return { recipes, rows: [], uncheckedCount: 0 }; + + 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 }; } export function toggleCheck( diff --git a/tests/integration/shopping-repository.test.ts b/tests/integration/shopping-repository.test.ts index aa50ee4..0c33f1a 100644 --- a/tests/integration/shopping-repository.test.ts +++ b/tests/integration/shopping-repository.test.ts @@ -103,3 +103,73 @@ describe('setCartServings', () => { expect(() => setCartServings(db, id, -3)).toThrow(); }); }); + +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); + }); +});