All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m14s
197 lines
6.2 KiB
TypeScript
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();
|
|
}
|