Files
recipe-mockup/js/services/planIngredients.js
ulfrxdev 868862d031
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m20s
Add ingredients' products
2026-04-07 22:51:30 +02:00

343 lines
13 KiB
JavaScript

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<typeof resolveLine>[]} */
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<string, unknown>} 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;
}