diff --git a/src/lib/server/shopping/repository.ts b/src/lib/server/shopping/repository.ts index 2a40528..2621e31 100644 --- a/src/lib/server/shopping/repository.ts +++ b/src/lib/server/shopping/repository.ts @@ -125,8 +125,66 @@ export function toggleCheck( } } -export function clearCheckedItems(_db: Database.Database): void { - throw new Error('not implemented'); +export function clearCheckedItems(db: Database.Database): void { + const tx = db.transaction(() => { + // Alle aggregierten Zeilen mit checked-Status holen, pro recipe_id gruppieren + // und Rezepte finden, deren Zeilen ALLE abgehakt sind. + const allRows = db + .prepare( + `SELECT + cr.recipe_id, + LOWER(TRIM(i.name)) AS name_key, + LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key, + 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 ingredient i ON i.recipe_id = cr.recipe_id` + ) + .all() as { recipe_id: number; name_key: string; unit_key: string; checked: 0 | 1 }[]; + + const perRecipe = new Map(); + for (const r of allRows) { + const e = perRecipe.get(r.recipe_id) ?? { total: 0, checked: 0 }; + e.total += 1; + e.checked += r.checked; + perRecipe.set(r.recipe_id, e); + } + const toRemove: number[] = []; + for (const [id, e] of perRecipe) { + if (e.total > 0 && e.total === e.checked) toRemove.push(id); + } + for (const id of toRemove) { + db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(id); + } + + // Orphan-Checks raeumen: alle Check-Keys, die jetzt in KEINEM Cart-Rezept + // mehr vorkommen. + const activeKeys = db + .prepare( + `SELECT DISTINCT + LOWER(TRIM(i.name)) AS name_key, + LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key + FROM shopping_cart_recipe cr + JOIN ingredient i ON i.recipe_id = cr.recipe_id` + ) + .all() as { name_key: string; unit_key: string }[]; + const activeSet = new Set(activeKeys.map((k) => `${k.name_key} ${k.unit_key}`)); + const allChecks = db + .prepare('SELECT name_key, unit_key FROM shopping_cart_check') + .all() as { name_key: string; unit_key: string }[]; + const del = db.prepare( + 'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?' + ); + for (const c of allChecks) { + if (!activeSet.has(`${c.name_key} ${c.unit_key}`)) { + del.run(c.name_key, c.unit_key); + } + } + }); + tx(); } export function clearCart(_db: Database.Database): void { diff --git a/tests/integration/shopping-repository.test.ts b/tests/integration/shopping-repository.test.ts index 8453e5f..dd0469e 100644 --- a/tests/integration/shopping-repository.test.ts +++ b/tests/integration/shopping-repository.test.ts @@ -6,7 +6,8 @@ import { removeRecipeFromCart, listShoppingList, setCartServings, - toggleCheck + toggleCheck, + clearCheckedItems } from '../../src/lib/server/shopping/repository'; import type { Recipe } from '../../src/lib/types'; @@ -231,3 +232,56 @@ describe('toggleCheck', () => { expect(rows[0].total_quantity).toBe(200); }); }); + +describe('clearCheckedItems', () => { + it('removes recipes where ALL rows are checked', () => { + const db = openInMemoryForTest(); + const a = insertRecipe(db, recipe({ + title: 'A', + ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] + })); + const b = insertRecipe(db, recipe({ + title: 'B', + ingredients: [ + { position: 1, quantity: 1, unit: 'Stk', name: 'Birne', note: null, raw_text: '', section_heading: null }, + { position: 2, quantity: 1, unit: 'Stk', name: 'Salz', note: null, raw_text: '', section_heading: null } + ] + })); + addRecipeToCart(db, a, null); + addRecipeToCart(db, b, null); + toggleCheck(db, 'apfel', 'stk', true); + toggleCheck(db, 'birne', 'stk', true); + // Salz aus B noch nicht abgehakt → B bleibt, A fliegt + clearCheckedItems(db); + const snap = listShoppingList(db); + expect(snap.recipes.map((r) => r.recipe_id)).toEqual([b]); + // Birne-Check bleibt, weil B noch im Cart und Birne noch aktiv + const birneRow = snap.rows.find((r) => r.name_key === 'birne'); + expect(birneRow?.checked).toBe(1); + }); + + it('purges orphan checks that no longer map to any cart recipe', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ + ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] + })); + addRecipeToCart(db, id, null); + toggleCheck(db, 'apfel', 'stk', true); + clearCheckedItems(db); + // Apfel-Check haengt jetzt an nichts mehr → muss aus der Tabelle raus sein + const row = db + .prepare('SELECT * FROM shopping_cart_check WHERE name_key = ?') + .get('apfel'); + expect(row).toBeUndefined(); + }); + + it('is a no-op when nothing is checked', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, recipe({ + ingredients: [{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '', section_heading: null }] + })); + addRecipeToCart(db, id, null); + clearCheckedItems(db); + expect(listShoppingList(db).recipes).toHaveLength(1); + }); +});