Files
kochwas/src/lib/server/shopping/repository.ts
hsiegeln 2c61d82935
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
feat(shopping): clearCart
2026-04-21 23:13:58 +02:00

197 lines
6.2 KiB
TypeScript

import type Database from 'better-sqlite3';
// Fallback when a recipe has no servings_default set — matches the default
// used by RecipeEditor's "new recipe" template.
const DEFAULT_SERVINGS = 4;
export type ShoppingCartRecipe = {
recipe_id: number;
title: string;
image_path: string | null;
servings: number;
servings_default: number;
};
export type ShoppingListRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string;
checked: 0 | 1;
};
export type ShoppingListSnapshot = {
recipes: ShoppingCartRecipe[];
rows: ShoppingListRow[];
uncheckedCount: number;
};
export function addRecipeToCart(
db: Database.Database,
recipeId: number,
profileId: number | null,
servings?: number
): void {
const row = db
.prepare('SELECT servings_default FROM recipe WHERE id = ?')
.get(recipeId) as { servings_default: number | null } | undefined;
const resolved = servings ?? row?.servings_default ?? DEFAULT_SERVINGS;
// ON CONFLICT updates only servings — added_by_profile_id stays with the
// first profile that added the recipe (household cart, audit trail).
db.prepare(
`INSERT INTO shopping_cart_recipe (recipe_id, servings, added_by_profile_id)
VALUES (?, ?, ?)
ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`
).run(recipeId, resolved, profileId);
}
export function removeRecipeFromCart(
db: Database.Database,
recipeId: number
): void {
db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(recipeId);
}
export function setCartServings(
db: Database.Database,
recipeId: number,
servings: number
): void {
if (!Number.isInteger(servings) || servings <= 0) {
throw new Error(`Invalid servings: ${servings}`);
}
db.prepare(
'UPDATE shopping_cart_recipe SET servings = ? WHERE recipe_id = ?'
).run(servings, recipeId);
}
export function listShoppingList(
db: Database.Database
): ShoppingListSnapshot {
const recipes = db
.prepare(
`SELECT cr.recipe_id, r.title, r.image_path, cr.servings,
COALESCE(r.servings_default, cr.servings) AS servings_default
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
ORDER BY cr.added_at ASC`
)
.all() as ShoppingCartRecipe[];
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 / 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
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(
db: Database.Database,
nameKey: string,
unitKey: string,
checked: boolean
): void {
if (checked) {
db.prepare(
`INSERT INTO shopping_cart_check (name_key, unit_key)
VALUES (?, ?)
ON CONFLICT(name_key, unit_key) DO NOTHING`
).run(nameKey, unitKey);
} else {
db.prepare(
'DELETE FROM shopping_cart_check WHERE name_key = ? AND unit_key = ?'
).run(nameKey, unitKey);
}
}
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<number, { total: number; checked: number }>();
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 {
const tx = db.transaction(() => {
db.prepare('DELETE FROM shopping_cart_recipe').run();
db.prepare('DELETE FROM shopping_cart_check').run();
});
tx();
}