Add ingredients' products
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m20s

This commit is contained in:
2026-04-07 22:51:30 +02:00
parent ac32e05c31
commit 868862d031
11 changed files with 576 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js?v=2';
import { PANTRY_STORAGE_KEY, SHOPPING_STORAGE_KEY } from '../storageKeys.js';
import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=6';
import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY } from '../storageKeys.js';
export const KITCHEN_LIST_ID = 'kitchen';
export const MISC_LIST_ID = 'misc';
@@ -11,7 +11,7 @@ function newId(prefix) {
return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/** @typedef {{ id: string, ingredientId: string, name: string, amount: number, unit: string, category: string, checked: boolean, sourceNote?: string }} KitchenShoppingItem */
/** @typedef {{ id: string, ingredientId: string, productId?: string, name: string, amount: number, unit: string, category: string, checked: boolean, sourceNote?: string }} KitchenShoppingItem */
/** @typedef {{ id: string, text: string, note?: string, checked: boolean }} FreeformShoppingItem */
/** @typedef {{ id: string, name: string, type: 'kitchen'|'freeform', items: (KitchenShoppingItem|FreeformShoppingItem)[] }} ShoppingListDef */
@@ -39,7 +39,7 @@ function normalizeKitchenItem(x) {
if (!o.ingredientId || !Number.isFinite(Number(o.amount))) return null;
const id = String(o.id && String(o.id).length ? o.id : newId('s'));
const ingId = String(o.ingredientId);
return {
const item = {
id,
ingredientId: ingId,
name: String(o.name || INGREDIENTS[ingId]?.name || ingId),
@@ -49,6 +49,10 @@ function normalizeKitchenItem(x) {
checked: Boolean(o.checked),
sourceNote: o.sourceNote ? String(o.sourceNote) : undefined,
};
if (o.productId && typeof o.productId === 'string' && PRODUCTS[o.productId]) {
item.productId = o.productId;
}
return item;
}
/** @param {unknown} x */
@@ -222,23 +226,28 @@ export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) {
const def = INGREDIENTS[L.ingredientId];
const name = L.name || def?.name || L.ingredientId;
const category = L.category || def?.category || 'inne';
const productId = L.productId && PRODUCTS[L.productId] ? L.productId : undefined;
const productName = productId ? PRODUCTS[productId].name : null;
const displayName = productName || name;
const idx = open.findIndex(
(x) => x.ingredientId === L.ingredientId && x.unit === L.unit,
(x) => x.ingredientId === L.ingredientId && x.unit === L.unit && (x.productId || '') === (productId || ''),
);
if (idx >= 0) {
open[idx].amount = Math.round((open[idx].amount + L.amount) * 100) / 100;
if (L.sourceNote && !open[idx].sourceNote) open[idx].sourceNote = L.sourceNote;
} else {
open.push({
const item = {
id: newId('s'),
ingredientId: L.ingredientId,
name,
name: displayName,
amount: Math.round(L.amount * 100) / 100,
unit: L.unit,
category,
checked: false,
sourceNote: L.sourceNote,
});
};
if (productId) item.productId = productId;
open.push(item);
}
}
@@ -338,7 +347,7 @@ export function computeShortfalls(needLines, pantry = loadPantry()) {
short.push({ ...L, pantryQty: 0, shortfall: L.amount, unitMismatch: true });
continue;
}
const have = Number(pantry[L.ingredientId]) || 0;
const have = getPantryTotal(L.ingredientId, pantry);
const miss = Math.max(0, Math.round((L.amount - have) * 100) / 100);
if (miss > 0) {
short.push({
@@ -370,35 +379,154 @@ export function categoryLabel(cat) {
return CATEGORY_LABELS[cat] || CATEGORY_LABELS.inne;
}
/** @returns {Record<string, number>} ingredientId -> ilość w pantryUnit */
export function loadPantry() {
/* ══════════════════════════════════════════════════════════════════════
* Pantry v2 — hybrydowy format.
*
* Wartość dla składnika może być:
* number — składnik generyczny (bez zdefiniowanych produktów)
* { _total, items: [{productId, qty}], generic } — składnik z produktami
*
* _total = generic + sum(items[].qty) (cache, zawsze przeliczany przy zapisie)
* ══════════════════════════════════════════════════════════════════════ */
/** @typedef {{ productId: string, qty: number }} PantryProductItem */
/** @typedef {{ _total: number, items: PantryProductItem[], generic: number }} PantryProductEntry */
/** @typedef {Record<string, number | PantryProductEntry>} PantryV2 */
function recalcTotal(entry) {
const itemSum = entry.items.reduce((s, i) => s + i.qty, 0);
entry._total = Math.round((entry.generic + itemSum) * 1000) / 1000;
return entry;
}
function normalizePantryEntry(ingredientId, val) {
if (val && typeof val === 'object' && !Array.isArray(val)) {
const items = Array.isArray(val.items)
? val.items
.filter(i => i && typeof i.productId === 'string' && PRODUCTS[i.productId] && Number.isFinite(Number(i.qty)) && Number(i.qty) > 0)
.map(i => ({ productId: i.productId, qty: Math.round(Number(i.qty) * 1000) / 1000 }))
: [];
const generic = Math.max(0, Number(val.generic) || 0);
return recalcTotal({ items, generic, _total: 0 });
}
const n = Number(val);
if (!Number.isFinite(n) || n < 0) return null;
if (ingredientHasProducts(ingredientId)) {
return recalcTotal({ items: [], generic: n, _total: 0 });
}
return n;
}
function migrateV1toV2() {
try {
const raw = localStorage.getItem(PANTRY_STORAGE_KEY);
if (!raw) return {};
const p = JSON.parse(raw);
if (typeof p !== 'object' || p === null) return {};
const out = {};
Object.keys(p).forEach((k) => {
const n = Number(p[k]);
if (!Number.isFinite(n) || n < 0) return;
out[k] = n;
});
for (const [k, v] of Object.entries(p)) {
const norm = normalizePantryEntry(k, v);
if (norm !== null) out[k] = norm;
}
return out;
} catch {
return {};
}
}
/** @returns {PantryV2} */
export function loadPantry() {
try {
const rawV2 = localStorage.getItem(PANTRY_STORAGE_KEY_V2);
if (rawV2) {
const p = JSON.parse(rawV2);
if (typeof p !== 'object' || p === null) return {};
const out = {};
for (const [k, v] of Object.entries(p)) {
const norm = normalizePantryEntry(k, v);
if (norm !== null) out[k] = norm;
}
return out;
}
const migrated = migrateV1toV2();
if (Object.keys(migrated).length > 0) {
savePantry(migrated);
}
return migrated;
} catch {
return {};
}
}
/** @param {PantryV2} pantry */
export function savePantry(pantry) {
try {
localStorage.setItem(PANTRY_STORAGE_KEY, JSON.stringify(pantry));
localStorage.setItem(PANTRY_STORAGE_KEY_V2, JSON.stringify(pantry));
} catch { /* ignore */ }
}
/** Łączna ilość składnika (generyczna + produkty). */
export function getPantryTotal(ingredientId, pantry) {
const val = pantry[ingredientId];
if (val == null) return 0;
if (typeof val === 'number') return val;
return val._total || 0;
}
/** Lista produktów w spiżarni dla danego składnika. */
export function getPantryProducts(ingredientId, pantry) {
const val = pantry[ingredientId];
if (!val || typeof val === 'number') return [];
return val.items || [];
}
/** Ilość generyczna (nieprzypisana do produktu). */
export function getPantryGeneric(ingredientId, pantry) {
const val = pantry[ingredientId];
if (typeof val === 'number') return val;
if (!val) return 0;
return val.generic || 0;
}
/** Ustaw ilość generyczną składnika (bez produktu). */
export function setPantryQty(ingredientId, qty) {
const pantry = loadPantry();
if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId];
else pantry[ingredientId] = Math.round(qty * 1000) / 1000;
const val = pantry[ingredientId];
if (val && typeof val === 'object') {
val.generic = Math.max(0, Math.round(qty * 1000) / 1000);
recalcTotal(val);
if (val._total <= 0) delete pantry[ingredientId];
} else {
if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId];
else if (ingredientHasProducts(ingredientId)) {
pantry[ingredientId] = recalcTotal({ items: [], generic: Math.round(qty * 1000) / 1000, _total: 0 });
} else {
pantry[ingredientId] = Math.round(qty * 1000) / 1000;
}
}
savePantry(pantry);
return pantry;
}
/** Ustaw ilość konkretnego produktu w spiżarni. */
export function setPantryProductQty(ingredientId, productId, qty) {
const pantry = loadPantry();
let val = pantry[ingredientId];
if (!val || typeof val === 'number') {
const generic = typeof val === 'number' ? val : 0;
val = { items: [], generic, _total: 0 };
pantry[ingredientId] = val;
}
const idx = val.items.findIndex(i => i.productId === productId);
const q = Math.max(0, Math.round(qty * 1000) / 1000);
if (q > 0) {
if (idx >= 0) val.items[idx].qty = q;
else val.items.push({ productId, qty: q });
} else {
if (idx >= 0) val.items.splice(idx, 1);
}
recalcTotal(val);
if (val._total <= 0) delete pantry[ingredientId];
savePantry(pantry);
return pantry;
}
@@ -417,8 +545,29 @@ export function applyCheckedKitchenListToPantry() {
const def = INGREDIENTS[it.ingredientId];
if (!def) continue;
if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue;
const cur = Number(pantry[it.ingredientId]) || 0;
pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000;
const val = pantry[it.ingredientId];
if (val && typeof val === 'object') {
if (it.productId && PRODUCTS[it.productId]) {
const idx = val.items.findIndex(i => i.productId === it.productId);
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + it.amount) * 1000) / 1000;
else val.items.push({ productId: it.productId, qty: Math.round(it.amount * 1000) / 1000 });
} else {
val.generic = Math.round(((val.generic || 0) + it.amount) * 1000) / 1000;
}
recalcTotal(val);
} else if (ingredientHasProducts(it.ingredientId)) {
const cur = typeof val === 'number' ? val : 0;
const entry = { items: [], generic: cur, _total: 0 };
if (it.productId && PRODUCTS[it.productId]) {
entry.items.push({ productId: it.productId, qty: Math.round(it.amount * 1000) / 1000 });
} else {
entry.generic = Math.round((entry.generic + it.amount) * 1000) / 1000;
}
pantry[it.ingredientId] = recalcTotal(entry);
} else {
const cur = typeof val === 'number' ? val : 0;
pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000;
}
}
kitchen.items = items.filter((i) => !i.checked);

View File

@@ -1,7 +1,8 @@
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=2';
import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=6';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { addDays } from './dateUtils.js';
import { getDayPlan } from './planStore.js';
import { getDayPlan } from './planStore.js?v=2';
import { getPantryTotal } from './pantryShopping.js?v=2';
export function dayHasAnyMeal(plans, d) {
const p = getDayPlan(plans, d);
@@ -17,20 +18,24 @@ function hasCustomizations(entry) {
return (entry.excludedIngredients?.length > 0) ||
(entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) ||
(entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).length > 0);
(entry.substitutions && Object.keys(entry.substitutions).length > 0) ||
(entry.productSelections && Object.keys(entry.productSelections).length > 0);
}
function nutritionForAmountRaw(ingredientId, amount, unit) {
function nutritionForAmountRaw(ingredientId, amount, unit, productId = null) {
const def = INGREDIENTS[ingredientId];
if (!def?.nutritionPer100g) return null;
if (!def) return null;
const product = productId ? PRODUCTS[productId] : null;
const nutrition = product?.nutritionPer100g || def.nutritionPer100g;
if (!nutrition) return null;
let g = amount;
if ((unit === 'szt.' || unit === 'szt') && def.weightPerPiece) g = amount * def.weightPerPiece;
const f = g / 100;
return {
kcal: def.nutritionPer100g.kcal * f,
protein: def.nutritionPer100g.protein * f,
fat: def.nutritionPer100g.fat * f,
carbs: def.nutritionPer100g.carbs * f,
kcal: nutrition.kcal * f,
protein: nutrition.protein * f,
fat: nutrition.fat * f,
carbs: nutrition.carbs * f,
};
}
@@ -52,17 +57,20 @@ export function computeEntryNutrition(entry) {
const excluded = new Set(entry.excludedIngredients || []);
const overrides = entry.amountOverrides || {};
const subs = entry.substitutions || {};
const ps = entry.productSelections || {};
let kcal = 0, protein = 0, fat = 0, carbs = 0;
for (const ing of r.ingredients) {
if (excluded.has(ing.ingredientId)) continue;
const eid = subs[ing.ingredientId] || ing.ingredientId;
const base = overrides[ing.ingredientId] ?? ing.amount;
const n = nutritionForAmountRaw(eid, base * s, ing.unit);
const productId = ps[eid] || null;
const n = nutritionForAmountRaw(eid, base * s, ing.unit, productId);
if (n) { kcal += n.kcal; protein += n.protein; fat += n.fat; carbs += n.carbs; }
}
for (const a of (entry.addedIngredients || [])) {
const n = nutritionForAmountRaw(a.ingredientId, a.amount * s, a.unit);
const productId = ps[a.ingredientId] || null;
const n = nutritionForAmountRaw(a.ingredientId, a.amount * s, a.unit, productId);
if (n) { kcal += n.kcal; protein += n.protein; fat += n.fat; carbs += n.carbs; }
}
@@ -231,7 +239,7 @@ export function countDayShortfalls(dayPlan, pantry) {
const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan));
let count = 0;
for (const line of lines) {
if ((Number(pantry[line.ingredientId]) || 0) < line.amount) count++;
if (getPantryTotal(line.ingredientId, pantry) < line.amount) count++;
}
return count;
}
@@ -242,7 +250,11 @@ export function countDayShortfalls(dayPlan, pantry) {
* ile jest w spiżarni (po odjęciu zużycia z poprzednich dni) i ile brakuje.
*/
export function computeFullForecast(plans, pantry, startDate, lookAheadDays = 8) {
const running = { ...pantry };
// Flatten pantry to simple totals for forecast (running deduction)
const running = {};
for (const [k, v] of Object.entries(pantry)) {
running[k] = typeof v === 'number' ? v : (v && v._total) || 0;
}
const days = [];
for (let i = 0; i < lookAheadDays; i++) {
@@ -276,3 +288,55 @@ export function computeFullForecast(plans, pantry, startDate, lookAheadDays = 8)
return days;
}
const LAST_PRODUCTS_KEY = 'recipe-last-product-selections';
function loadLastProductSelections() {
try {
const raw = localStorage.getItem(LAST_PRODUCTS_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
}
/** Save user's product choice so it becomes the default next time. */
export function saveLastProductSelection(ingredientId, productId) {
const prev = loadLastProductSelections();
prev[ingredientId] = productId;
try { localStorage.setItem(LAST_PRODUCTS_KEY, JSON.stringify(prev)); } catch {}
}
/**
* Auto-select products for a recipe.
* Priority: last user choice > pantry stock (highest qty) > first from catalog.
* Only selects for ingredients that have products defined.
*/
export function autoSelectProducts(recipe, pantry) {
const selections = {};
const lastUsed = loadLastProductSelections();
for (const ing of recipe.ingredients) {
const products = getProductsForIngredient(ing.ingredientId);
if (products.length === 0) continue;
// 1. Last user choice (if product still exists)
const lastPid = lastUsed[ing.ingredientId];
if (lastPid && products.some(p => p.id === lastPid)) {
selections[ing.ingredientId] = lastPid;
continue;
}
// 2. Pantry stock — pick product with most qty
const val = pantry[ing.ingredientId];
if (val && typeof val === 'object') {
const items = (val.items || []).filter(i => i.qty > 0);
if (items.length > 0) {
items.sort((a, b) => b.qty - a.qty);
selections[ing.ingredientId] = items[0].productId;
continue;
}
}
// 3. First from catalog
selections[ing.ingredientId] = products[0].id;
}
return selections;
}

View File

@@ -1,4 +1,4 @@
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=2';
import { INGREDIENTS, RECIPES, PRODUCTS } from '../data/catalog.js?v=6';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import { PLANS_STORAGE_KEY } from '../storageKeys.js';
import { startOfDay } from './dateUtils.js';
@@ -40,6 +40,13 @@ function normalizeEntryExtras(x) {
.map((a) => ({ ingredientId: a.ingredientId, amount: a.amount, unit: a.unit }));
if (valid.length > 0) out.addedIngredients = valid;
}
if (x.productSelections && typeof x.productSelections === 'object' && !Array.isArray(x.productSelections)) {
const ps = {};
for (const [k, v] of Object.entries(x.productSelections)) {
if (typeof v === 'string' && PRODUCTS[v]) ps[k] = v;
}
if (Object.keys(ps).length > 0) out.productSelections = ps;
}
return out;
}