feat(shopping): listShoppingList mit Aggregation + Skalierung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s

This commit is contained in:
hsiegeln
2026-04-21 23:02:05 +02:00
parent 85bf197084
commit c31a9c6110
2 changed files with 98 additions and 4 deletions

View File

@@ -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(

View File

@@ -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);
});
});