2026-04-21 22:47:21 +02:00
|
|
|
import type Database from 'better-sqlite3';
|
|
|
|
|
|
2026-04-21 22:54:54 +02:00
|
|
|
// Fallback when a recipe has no servings_default set — matches the default
|
|
|
|
|
// used by RecipeEditor's "new recipe" template.
|
|
|
|
|
const DEFAULT_SERVINGS = 4;
|
|
|
|
|
|
2026-04-21 22:47:21 +02:00
|
|
|
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(
|
2026-04-21 22:50:58 +02:00
|
|
|
db: Database.Database,
|
|
|
|
|
recipeId: number,
|
|
|
|
|
profileId: number | null,
|
|
|
|
|
servings?: number
|
2026-04-21 22:47:21 +02:00
|
|
|
): void {
|
2026-04-21 22:50:58 +02:00
|
|
|
const row = db
|
|
|
|
|
.prepare('SELECT servings_default FROM recipe WHERE id = ?')
|
|
|
|
|
.get(recipeId) as { servings_default: number | null } | undefined;
|
2026-04-21 22:54:54 +02:00
|
|
|
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).
|
2026-04-21 22:50:58 +02:00
|
|
|
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);
|
2026-04-21 22:47:21 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 22:56:26 +02:00
|
|
|
export function removeRecipeFromCart(
|
|
|
|
|
db: Database.Database,
|
|
|
|
|
recipeId: number
|
|
|
|
|
): void {
|
|
|
|
|
db.prepare('DELETE FROM shopping_cart_recipe WHERE recipe_id = ?').run(recipeId);
|
2026-04-21 22:47:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setCartServings(
|
2026-04-21 22:59:12 +02:00
|
|
|
db: Database.Database,
|
|
|
|
|
recipeId: number,
|
|
|
|
|
servings: number
|
2026-04-21 22:47:21 +02:00
|
|
|
): void {
|
2026-04-21 22:59:12 +02:00
|
|
|
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);
|
2026-04-21 22:47:21 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 23:02:05 +02:00
|
|
|
export function listShoppingList(
|
|
|
|
|
db: Database.Database
|
|
|
|
|
): ShoppingListSnapshot {
|
2026-04-21 22:50:58 +02:00
|
|
|
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[];
|
2026-04-21 23:02:05 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-04-21 23:06:19 +02:00
|
|
|
SUM(i.quantity * cr.servings * 1.0 / NULLIF(COALESCE(r.servings_default, cr.servings), 0)) AS total_quantity,
|
2026-04-21 23:02:05 +02:00
|
|
|
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 };
|
2026-04-21 22:47:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function toggleCheck(
|
2026-04-21 23:08:20 +02:00
|
|
|
db: Database.Database,
|
|
|
|
|
nameKey: string,
|
|
|
|
|
unitKey: string,
|
|
|
|
|
checked: boolean
|
2026-04-21 22:47:21 +02:00
|
|
|
): void {
|
2026-04-21 23:08:20 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-04-21 22:47:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearCheckedItems(_db: Database.Database): void {
|
|
|
|
|
throw new Error('not implemented');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearCart(_db: Database.Database): void {
|
|
|
|
|
throw new Error('not implemented');
|
|
|
|
|
}
|