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?v=2'; import { getPantryTotal } from './pantryShopping.js?v=2'; export function dayHasAnyMeal(plans, d) { const p = getDayPlan(plans, d); const skipped = p._skipped || {}; return MEAL_SLOTS.some((s) => { if (skipped[s.id]) return false; const arr = p[s.id]; return Array.isArray(arr) && arr.length > 0; }); } 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.productSelections && Object.keys(entry.productSelections).length > 0); } function nutritionForAmountRaw(ingredientId, amount, unit, productId = null) { const def = INGREDIENTS[ingredientId]; 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: nutrition.kcal * f, protein: nutrition.protein * f, fat: nutrition.fat * f, carbs: nutrition.carbs * f, }; } export function computeEntryNutrition(entry) { if (!entry?.recipeId) return { kcal: 0, protein: 0, fat: 0, carbs: 0 }; const r = RECIPES[entry.recipeId]; if (!r) return { kcal: 0, protein: 0, fat: 0, carbs: 0 }; const s = Math.max(1, Number(entry.servings) || 1); if (!hasCustomizations(entry)) { return { kcal: Math.round(r.nutritionPerServing.kcal * s), protein: Math.round(r.nutritionPerServing.protein * s * 10) / 10, fat: Math.round(r.nutritionPerServing.fat * s * 10) / 10, carbs: Math.round(r.nutritionPerServing.carbs * s * 10) / 10, }; } 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 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 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; } } return { kcal: Math.round(kcal), protein: Math.round(protein * 10) / 10, fat: Math.round(fat * 10) / 10, carbs: Math.round(carbs * 10) / 10, }; } export function sumDayNutrition(dayPlan) { let kcal = 0, protein = 0, fat = 0, carbs = 0, mealCount = 0; const skipped = dayPlan._skipped || {}; MEAL_SLOTS.forEach((slot) => { if (skipped[slot.id]) return; const entries = dayPlan[slot.id]; if (!Array.isArray(entries)) return; entries.forEach((entry) => { if (!entry?.recipeId || !RECIPES[entry.recipeId]) return; mealCount += 1; const n = computeEntryNutrition(entry); kcal += n.kcal; protein += n.protein; fat += n.fat; carbs += n.carbs; }); }); return { kcal: Math.round(kcal), protein: Math.round(protein * 10) / 10, fat: Math.round(fat * 10) / 10, carbs: Math.round(carbs * 10) / 10, mealCount, }; } function resolveLine(ing, scaledAmount) { const def = INGREDIENTS[ing.ingredientId]; return { ingredientId: ing.ingredientId, name: def?.name ?? ing.ingredientId, category: def?.category ?? 'inne', amount: scaledAmount, unit: ing.unit, }; } /** Płaska lista składników z jednego dnia (wszystkie pory). */ export function flattenDayIngredientLines(dayPlan) { /** @type {ReturnType[]} */ const out = []; const skipped = dayPlan._skipped || {}; MEAL_SLOTS.forEach((slot) => { if (skipped[slot.id]) return; const entries = dayPlan[slot.id]; if (!Array.isArray(entries)) return; entries.forEach((entry) => { if (!entry || !entry.recipeId) return; const r = RECIPES[entry.recipeId]; if (!r || !Array.isArray(r.ingredients)) return; const serv = Math.max(1, Number(entry.servings) || 1); const excluded = new Set(entry.excludedIngredients || []); const overrides = entry.amountOverrides || {}; const subs = entry.substitutions || {}; r.ingredients.forEach((ing) => { if (excluded.has(ing.ingredientId)) return; const effectiveId = subs[ing.ingredientId] || ing.ingredientId; const base = overrides[ing.ingredientId] ?? ing.amount; const scaled = Math.round(base * serv * 10) / 10; out.push(resolveLine({ ingredientId: effectiveId, unit: ing.unit }, scaled)); }); for (const a of (entry.addedIngredients || [])) { const scaled = Math.round(a.amount * serv * 10) / 10; out.push(resolveLine(a, scaled)); } }); }); return out; } /** Sumuje po (ingredientId + unit) — ta sama jednostka jak w przepisie. */ export function mergeIngredientLines(lines) { const m = new Map(); for (const L of lines) { const key = `${L.ingredientId}\t${L.unit}`; const cur = m.get(key); if (!cur) { m.set(key, { ...L }); } else { cur.amount = Math.round((cur.amount + L.amount) * 10) / 10; } } return [...m.values()].sort((a, b) => { const c = a.category.localeCompare(b.category); return c !== 0 ? c : a.name.localeCompare(b.name); }); } /** * Zapotrzebowanie składników od weekStart (włącznie) przez 7 dni. * @param {Record} plans * @param {Date} weekStart */ export function aggregateWeekIngredientNeed(plans, weekStart) { const all = []; for (let i = 0; i < 7; i++) { const day = addDays(weekStart, i); const dayPlan = getDayPlan(plans, day); all.push(...flattenDayIngredientLines(dayPlan)); } return mergeIngredientLines(all); } /** * Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami. */ export function aggregateDayIngredientsBySlot(dayPlan) { /** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: string }[] }[] }[]} */ const blocks = []; const skipped = dayPlan._skipped || {}; MEAL_SLOTS.forEach((slot) => { if (skipped[slot.id]) return; const entries = dayPlan[slot.id]; if (!Array.isArray(entries) || entries.length === 0) return; const recipes = []; entries.forEach((entry) => { if (!entry || !entry.recipeId) return; const r = RECIPES[entry.recipeId]; if (!r) return; const s = Math.max(1, Number(entry.servings) || 1); const excluded = new Set(entry.excludedIngredients || []); const overrides = entry.amountOverrides || {}; const subs = entry.substitutions || {}; const items = []; r.ingredients.forEach((ing) => { if (excluded.has(ing.ingredientId)) return; const effectiveId = subs[ing.ingredientId] || ing.ingredientId; const base = overrides[ing.ingredientId] ?? ing.amount; const def = INGREDIENTS[effectiveId]; items.push({ ingredientId: effectiveId, name: def?.name ?? effectiveId, category: def?.category ?? 'inne', amount: Math.round(base * s * 10) / 10, unit: ing.unit, }); }); for (const a of (entry.addedIngredients || [])) { const def = INGREDIENTS[a.ingredientId]; items.push({ ingredientId: a.ingredientId, name: def?.name ?? a.ingredientId, category: def?.category ?? 'inne', amount: Math.round(a.amount * s * 10) / 10, unit: a.unit, }); } recipes.push({ recipeTitle: r.title, items }); }); if (recipes.length > 0) { blocks.push({ mealLabel: slot.label, recipes }); } }); return blocks; } export function countDayShortfalls(dayPlan, pantry) { const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan)); let count = 0; for (const line of lines) { if (getPantryTotal(line.ingredientId, pantry) < line.amount) count++; } return count; } /** * Kumulatywna prognoza zużycia spiżarni: od startDate przez lookAheadDays dni. * Zwraca tablicę dni (tylko te z posiłkami), każdy z listą składników i informacją * ile jest w spiżarni (po odjęciu zużycia z poprzednich dni) i ile brakuje. */ export function computeFullForecast(plans, pantry, startDate, lookAheadDays = 8) { // 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++) { const day = addDays(startDate, i); const dayPlan = getDayPlan(plans, day); const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan)); if (lines.length === 0) continue; const items = lines.map((line) => { const def = INGREDIENTS[line.ingredientId]; const have = Math.round((Number(running[line.ingredientId]) || 0) * 10) / 10; const miss = Math.max(0, Math.round((line.amount - have) * 10) / 10); return { ...line, pantryQty: have, shortfall: miss, enough: miss <= 0, pantryUnit: def ? def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit : line.unit, }; }); for (const line of lines) { const have = Number(running[line.ingredientId]) || 0; running[line.ingredientId] = Math.max(0, Math.round((have - line.amount) * 10) / 10); } days.push({ date: day, dayIndex: i, items, hasShortfall: items.some((it) => !it.enough) }); } 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; }