diff --git a/src/lib/server/shopping/repository.ts b/src/lib/server/shopping/repository.ts index b2c3810..37ec41b 100644 --- a/src/lib/server/shopping/repository.ts +++ b/src/lib/server/shopping/repository.ts @@ -183,21 +183,33 @@ 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 + // Rohe (name, unit)-Zeilen holen, checked-Status per Family-Key-Lookup + // in JS entscheiden (SQL-CASE-Duplikation vermeiden). + const allRowsRaw = 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 + 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 { recipe_id: number; name_key: string; unit_key: string; checked: 0 | 1 }[]; + .all() as { recipe_id: number; name_key: string; unit_key: string }[]; + + const checkSet = 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}`) + ); + + const allRows = allRowsRaw.map((r) => ({ + recipe_id: r.recipe_id, + name_key: r.name_key, + unit_key: r.unit_key, + checked: checkSet.has(`${r.name_key}|${unitFamily(r.unit_key)}`) ? (1 as const) : (0 as const) + })); const perRecipe = new Map(); for (const r of allRows) { @@ -214,9 +226,9 @@ export function clearCheckedItems(db: Database.Database): void { 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 + // Orphan-Checks raeumen: Active-Keys nach (name_key, unitFamily(raw-unit)) + // bauen, damit Checks mit family-key korrekt gematcht werden. + const activeRaw = db .prepare( `SELECT DISTINCT LOWER(TRIM(i.name)) AS name_key, @@ -225,7 +237,9 @@ export function clearCheckedItems(db: Database.Database): void { 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 activeSet = new Set( + activeRaw.map((k) => `${k.name_key}|${unitFamily(k.unit_key)}`) + ); const allChecks = db .prepare('SELECT name_key, unit_key FROM shopping_cart_check') .all() as { name_key: string; unit_key: string }[]; @@ -233,7 +247,7 @@ export function clearCheckedItems(db: Database.Database): void { '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}`)) { + if (!activeSet.has(`${c.name_key}|${c.unit_key}`)) { del.run(c.name_key, c.unit_key); } } diff --git a/tests/integration/shopping-repository.test.ts b/tests/integration/shopping-repository.test.ts index d2f2d93..3862a08 100644 --- a/tests/integration/shopping-repository.test.ts +++ b/tests/integration/shopping-repository.test.ts @@ -305,6 +305,68 @@ describe('clearCart', () => { }); }); +describe('toggleCheck — stabil ueber Unit-Family', () => { + it('haekchen bleibt erhalten wenn Gesamtmenge von kg auf g faellt', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'R1', + 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: 'R2', + 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); + + // Abhaken der konsolidierten 1,5-kg-Zeile via family-key + const before = listShoppingList(db).rows[0]; + toggleCheck(db, before.name_key, before.unit_key, true); + expect(listShoppingList(db).rows[0].checked).toBe(1); + + // Ein Rezept rausnehmen → nur noch 500 g, display wechselt auf g + removeRecipeFromCart(db, b); + const after = listShoppingList(db).rows[0]; + expect(after.display_unit).toBe('g'); + expect(after.total_quantity).toBe(500); + // Haekchen bleibt: unit_key ist weiterhin 'weight' + expect(after.checked).toBe(1); + }); + + it('clearCheckedItems respektiert family-key beim Orphan-Cleanup', () => { + const db = openInMemoryForTest(); + const a = insertRecipe( + db, + recipe({ + title: 'R1', + servings_default: 4, + ingredients: [ + { position: 1, name: 'Kartoffeln', quantity: 500, unit: 'g', note: null, raw_text: '', section_heading: null }, + { position: 2, name: 'Salz', quantity: 1, unit: 'Prise', note: null, raw_text: '', section_heading: null } + ] + }) + ); + addRecipeToCart(db, a, null); + const rows = listShoppingList(db).rows; + // Alle abhaken + for (const r of rows) toggleCheck(db, r.name_key, r.unit_key, true); + clearCheckedItems(db); + // Das Rezept sollte raus sein + expect(listShoppingList(db).recipes).toHaveLength(0); + // Check-Tabelle sollte leer sein (keine Orphans) + const remaining = (db.prepare('SELECT COUNT(*) AS c FROM shopping_cart_check').get() as { c: number }).c; + expect(remaining).toBe(0); + }); +}); + describe('listShoppingList — Konsolidierung ueber Einheiten', () => { it('fasst 500 g + 1 kg Kartoffeln zu 1,5 kg zusammen', () => { const db = openInMemoryForTest();