diff --git a/js/data/catalog.js b/js/data/catalog.js index ad32fdf..c318336 100644 --- a/js/data/catalog.js +++ b/js/data/catalog.js @@ -20,6 +20,7 @@ export const CATEGORY_LABELS = { * @typedef {{ kcal: number, protein: number, fat: number, carbs: number }} NutritionPer100 * @typedef {{ amount: number, label?: string }} PurchasePack * @typedef {{ id: string, name: string, category: keyof typeof CATEGORY_LABELS, pantryUnit: 'g'|'ml'|'szt', purchasePack?: PurchasePack, nutritionPer100g?: NutritionPer100 }} IngredientDef + * @typedef {{ id: string, ingredientId: string, name: string, brand?: string, packSize: number, packLabel?: string, nutritionPer100g: NutritionPer100 }} ProductDef */ /** @type {Record} */ @@ -59,6 +60,14 @@ export const INGREDIENTS = { purchasePack: { amount: 250, label: 'opakowanie 250 g' }, nutritionPer100g: { kcal: 174, protein: 11, fat: 13, carbs: 3 }, }, + burrata: { + id: 'burrata', + name: 'Burrata', + category: 'nabial', + pantryUnit: 'g', + purchasePack: { amount: 150, label: 'opakowanie 150 g' }, + nutritionPer100g: { kcal: 280, protein: 16, fat: 22, carbs: 2 }, + }, serek_wiejski: { id: 'serek_wiejski', name: 'Serek wiejski', @@ -339,7 +348,7 @@ export const RECIPES = { { ingredientId: 'czosnek', amount: 6, unit: 'g' }, { ingredientId: 'tymianek', amount: 1, unit: 'g' }, { ingredientId: 'oliwa', amount: 5, unit: 'ml' }, - { ingredientId: 'ricotta', amount: 75, unit: 'g' }, + { ingredientId: 'ricotta', amount: 75, unit: 'g', alternatives: ['burrata'] }, { ingredientId: 'bazylia_swieza', amount: 3, unit: 'g' }, { ingredientId: 'nasiona_slonecznika', amount: 15, unit: 'g' }, ], @@ -448,6 +457,119 @@ export const RECIPES = { }, }; +/* ══════════════════════════════════════════════════════════════════════ + * Konkretne produkty — warianty składników generycznych. + * Każdy produkt należy do jednego IngredientDef (przez ingredientId). + * ══════════════════════════════════════════════════════════════════════ */ + +/** @type {Record} */ +export const PRODUCTS = { + /* ── Nabiał ───────────────────────────────────────── */ + almette_naturalny: { + id: 'almette_naturalny', + ingredientId: 'serek_smietankowy', + name: 'Almette naturalny', + brand: 'Almette', + packSize: 150, + packLabel: '150 g', + nutritionPer100g: { kcal: 234, protein: 5.5, fat: 22, carbs: 3.5 }, + }, + philadelphia_original: { + id: 'philadelphia_original', + ingredientId: 'serek_smietankowy', + name: 'Philadelphia Original', + brand: 'Philadelphia', + packSize: 125, + packLabel: '125 g', + nutritionPer100g: { kcal: 235, protein: 5.4, fat: 21.5, carbs: 4.1 }, + }, + mozzarella_galbani: { + id: 'mozzarella_galbani', + ingredientId: 'mozzarella', + name: 'Galbani Mozzarella', + brand: 'Galbani', + packSize: 125, + packLabel: '125 g', + nutritionPer100g: { kcal: 253, protein: 18.4, fat: 19.1, carbs: 1.6 }, + }, + ricotta_galbani: { + id: 'ricotta_galbani', + ingredientId: 'ricotta', + name: 'Galbani Ricotta', + brand: 'Galbani', + packSize: 250, + packLabel: '250 g', + nutritionPer100g: { kcal: 138, protein: 10, fat: 10, carbs: 3 }, + }, + serek_wiejski_piatnica: { + id: 'serek_wiejski_piatnica', + ingredientId: 'serek_wiejski', + name: 'Piątnica Serek wiejski', + brand: 'Piątnica', + packSize: 200, + packLabel: '200 g', + nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 2 }, + }, + serek_wiejski_piatnica_wb: { + id: 'serek_wiejski_piatnica_wb', + ingredientId: 'serek_wiejski', + name: 'Piątnica Serek wiejski wysokobiałkowy', + brand: 'Piątnica', + packSize: 200, + packLabel: '200 g', + nutritionPer100g: { kcal: 93, protein: 14, fat: 3, carbs: 2.4 }, + }, + /* ── Mięso i ryby ─────────────────────────────────── */ + burrata_milbona: { + id: 'burrata_milbona', + ingredientId: 'burrata', + name: 'Milbona Burrata', + brand: 'Milbona', + packSize: 125, + packLabel: '125 g', + nutritionPer100g: { kcal: 254, protein: 10, fat: 23, carbs: 1.8 }, + }, + burrata_gustobello: { + id: 'burrata_gustobello', + ingredientId: 'burrata', + name: 'GustoBello Burrata', + brand: 'GustoBello', + packSize: 100, + packLabel: '100 g', + nutritionPer100g: { kcal: 259, protein: 10, fat: 23, carbs: 2 }, + }, + /* ── Mięso i ryby ─────────────────────────────────── */ + losos_wedzony_suempol: { + id: 'losos_wedzony_suempol', + ingredientId: 'losos_wedzony', + name: 'Suempol Łosoś atlantycki wędzony', + brand: 'Suempol', + packSize: 100, + packLabel: '100 g', + nutritionPer100g: { kcal: 160, protein: 21.5, fat: 8, carbs: 0.5 }, + }, + /* ── Inne ─────────────────────────────────────────── */ + hummus_klasyczny_well_well: { + id: 'hummus_klasyczny_well_well', + ingredientId: 'hummus', + name: 'Well Well Hummus klasyczny', + brand: 'Well Well', + packSize: 200, + packLabel: '200 g', + nutritionPer100g: { kcal: 198, protein: 6.6, fat: 12, carbs: 16 }, + }, +}; + +/** @param {string} ingredientId @returns {ProductDef[]} */ +export function getProductsForIngredient(ingredientId) { + return Object.values(PRODUCTS).filter(p => p.ingredientId === ingredientId); +} + +/** @param {string} ingredientId @returns {boolean} */ +export function ingredientHasProducts(ingredientId) { + return Object.values(PRODUCTS).some(p => p.ingredientId === ingredientId); +} + /** * Krok +/- w spiżarni: całe opakowanie albo domyślny krok (10 g/ml lub 1 szt.). * @param {string} ingredientId diff --git a/js/services/pantryShopping.js b/js/services/pantryShopping.js index 2bcef1f..0ff99e0 100644 --- a/js/services/pantryShopping.js +++ b/js/services/pantryShopping.js @@ -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} 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} 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); diff --git a/js/services/planIngredients.js b/js/services/planIngredients.js index 0450fd4..0794039 100644 --- a/js/services/planIngredients.js +++ b/js/services/planIngredients.js @@ -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; +} diff --git a/js/services/planStore.js b/js/services/planStore.js index 6b731f2..e84314e 100644 --- a/js/services/planStore.js +++ b/js/services/planStore.js @@ -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; } diff --git a/js/storageKeys.js b/js/storageKeys.js index ad42b3d..509ca85 100644 --- a/js/storageKeys.js +++ b/js/storageKeys.js @@ -1,3 +1,4 @@ export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1'; export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1'; +export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2'; export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1'; diff --git a/js/ui/mealPlanEditor.js b/js/ui/mealPlanEditor.js index 6a7b500..74961ba 100644 --- a/js/ui/mealPlanEditor.js +++ b/js/ui/mealPlanEditor.js @@ -1,4 +1,4 @@ -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, @@ -13,8 +13,9 @@ import { loadPlans, newPlanEntryId, savePlans, -} from '../services/planStore.js'; -import { dayHasAnyMeal } from '../services/planIngredients.js'; +} from '../services/planStore.js?v=2'; +import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=3'; +import { loadPantry } from '../services/pantryShopping.js?v=2'; import { showAppToast } from './toast.js'; import { bindCalendarDayClicks, @@ -113,21 +114,26 @@ export function setupMealPlanEditor() { altOpen: new Set(), addOpen: false, addQuery: '', + productSelections: {}, }; /* ── helpers ───────────────────────────────────── */ function nutFor(ingredientId, amount, unit) { const def = INGREDIENTS[ingredientId]; - if (!def?.nutritionPer100g) return null; + if (!def) return null; + const productId = S.productSelections[ingredientId] || 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: Math.round(def.nutritionPer100g.kcal * f), - protein: Math.round(def.nutritionPer100g.protein * f * 10) / 10, - fat: Math.round(def.nutritionPer100g.fat * f * 10) / 10, - carbs: Math.round(def.nutritionPer100g.carbs * f * 10) / 10, + kcal: Math.round(nutrition.kcal * f), + protein: Math.round(nutrition.protein * f * 10) / 10, + fat: Math.round(nutrition.fat * f * 10) / 10, + carbs: Math.round(nutrition.carbs * f * 10) / 10, }; } @@ -291,8 +297,14 @@ export function setupMealPlanEditor() { const modDot = modified ? '' : ''; html += `
`; + const selectedProductId = S.productSelections[eid]; + const selectedProduct = selectedProductId ? PRODUCTS[selectedProductId] : null; + const productBadge = selectedProduct + ? `
${esc(selectedProduct.name)}
` + : ''; + html += `
`; - html += `
${esc(eName)}
`; + html += `
${esc(eName)}${productBadge}
`; html += `
`; html += shuffleBtn; html += ``; + } + pickerHtml += '
'; + row?.insertAdjacentHTML('beforeend', pickerHtml); + return; + } + + const prodPick = e.target.closest('.mpe-prod-pick'); + if (prodPick) { + const eid = prodPick.dataset.eid; + const prodId = prodPick.dataset.prodId; + if (prodId) { + S.productSelections[eid] = prodId; + saveLastProductSelection(eid, prodId); + } + renderIngList(); renderNutrition(); + return; + } + const editAmt = e.target.closest('.mpe-edit-amt'); if (editAmt && !editAmt.disabled) { startAmountEdit(editAmt); diff --git a/js/views/Filter.js b/js/views/Filter.js index 8009f10..812ce67 100644 --- a/js/views/Filter.js +++ b/js/views/Filter.js @@ -1,4 +1,4 @@ -import { RECIPES } from '../data/catalog.js?v=2'; +import { RECIPES } from '../data/catalog.js?v=6'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { applyFilters, getFilterState } from './RecipeList.js'; diff --git a/js/views/MealPlanner.js b/js/views/MealPlanner.js index c2c4f13..8b036cd 100644 --- a/js/views/MealPlanner.js +++ b/js/views/MealPlanner.js @@ -1,4 +1,4 @@ -import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=2'; +import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=6'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { addMonths, @@ -15,15 +15,15 @@ import { countDayShortfalls, dayHasAnyMeal, sumDayNutrition, -} from '../services/planIngredients.js'; -import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js'; +} from '../services/planIngredients.js?v=3'; +import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js?v=2'; import { dateKey, getDayPlan, loadPlans, newPlanEntryId, savePlans, -} from '../services/planStore.js'; +} from '../services/planStore.js?v=2'; import { CALENDAR_HANDLE_CLASS, CALENDAR_MONTHS_SHORT, diff --git a/js/views/Pantry.js b/js/views/Pantry.js index ea34f4b..3ec2127 100644 --- a/js/views/Pantry.js +++ b/js/views/Pantry.js @@ -1,9 +1,12 @@ import { INGREDIENTS, CATEGORY_LABELS, + PRODUCTS, pantryQtyStep, -} from '../data/catalog.js?v=2'; -import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js'; + getProductsForIngredient, + ingredientHasProducts, +} from '../data/catalog.js?v=6'; +import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts, getPantryGeneric } from '../services/pantryShopping.js?v=2'; import { showAppToast } from '../ui/toast.js'; /* ── helpers ── */ @@ -97,6 +100,8 @@ export function getPantryHTML() {
+
+
@@ -172,7 +177,7 @@ function getFilteredIds(searchRaw) { function chipHtml(id, pantry) { const def = INGREDIENTS[id]; - const qty = Number(pantry[id]) || 0; + const qty = getPantryTotal(id, pantry); const u = unitLabel(def.pantryUnit); if (qty > 0) { @@ -209,7 +214,7 @@ function renderBoard() { const pantry = loadPantry(); const allFiltered = getFilteredIds(q); const visible = showOnlyStock - ? allFiltered.filter(id => (Number(pantry[id]) || 0) > 0) + ? allFiltered.filter(id => getPantryTotal(id, pantry) > 0) : allFiltered; if (visible.length === 0) { @@ -271,7 +276,7 @@ function openEditSheet(ingredientId) { editingId = ingredientId; const pantry = loadPantry(); - const qty = Number(pantry[ingredientId]) || 0; + const qty = getPantryTotal(ingredientId, pantry); const u = unitLabel(def.pantryUnit); const step = pantryQtyStep(ingredientId); const pack = def.purchasePack; @@ -311,6 +316,16 @@ function openEditSheet(ingredientId) { } } + // Hide main +/- when products exist (total is sum of product rows) + const hasProds = ingredientHasProducts(ingredientId); + const mainMinus = document.getElementById('pv2-edit-minus'); + const mainPlus = document.getElementById('pv2-edit-plus'); + const mainQtyInput = document.getElementById('pv2-edit-qty'); + if (mainMinus) mainMinus.classList.toggle('hidden', hasProds); + if (mainPlus) mainPlus.classList.toggle('hidden', hasProds); + if (mainQtyInput) mainQtyInput.readOnly = hasProds; + + renderProductBreakdown(ingredientId, pantry); renderNutritionInSheet(def); const bg = document.getElementById('pv2-edit-bg'); @@ -331,6 +346,83 @@ function nutritionListRow(label, valueHtml) { `; } +function renderProductBreakdown(ingredientId, pantry) { + const wrap = document.getElementById('pv2-product-breakdown'); + if (!wrap) return; + const products = getProductsForIngredient(ingredientId); + if (products.length === 0) { wrap.innerHTML = ''; return; } + + const def = INGREDIENTS[ingredientId]; + const u = unitLabel(def.pantryUnit); + const pantryProducts = getPantryProducts(ingredientId, pantry); + const generic = getPantryGeneric(ingredientId, pantry); + + const productQty = (pid) => { + const item = pantryProducts.find(i => i.productId === pid); + return item ? item.qty : 0; + }; + + const rows = products.map(p => { + const q = Math.round(productQty(p.id)); + return `
+ ${esc(p.name)} + + ${q} ${esc(u)} + +
`; + }).join(''); + + const genericRow = `
+ Nieokreślony + + ${Math.round(generic)} ${esc(u)} + +
`; + + wrap.innerHTML = ` +
+

Produkty

+ ${rows} + ${genericRow} +
`; + + // Bind product +/- buttons + wrap.querySelectorAll('.pv2-prod-plus, .pv2-prod-minus').forEach(btn => { + btn.addEventListener('click', () => { + if (!editingId) return; + const pid = btn.dataset.pid; + const step = Number(btn.dataset.step) || 1; + const isPlus = btn.classList.contains('pv2-prod-plus'); + const pantry = loadPantry(); + + if (pid === '_generic') { + const cur = getPantryGeneric(editingId, pantry); + const next = Math.max(0, cur + (isPlus ? step : -step)); + setPantryQty(editingId, next); + } else { + const items = getPantryProducts(editingId, pantry); + const cur = items.find(i => i.productId === pid)?.qty || 0; + const next = Math.max(0, cur + (isPlus ? step : -step)); + setPantryProductQty(editingId, pid, next); + } + + // Re-render breakdown and total + const freshPantry = loadPantry(); + renderProductBreakdown(editingId, freshPantry); + const totalQty = getPantryTotal(editingId, freshPantry); + setEditQty(totalQty); + }); + }); +} + function renderNutritionInSheet(def) { const wrap = document.getElementById('pv2-edit-nutrition'); if (!wrap) return; diff --git a/js/views/RecipeDetailV2.js b/js/views/RecipeDetailV2.js index 7edf859..b48ce5d 100644 --- a/js/views/RecipeDetailV2.js +++ b/js/views/RecipeDetailV2.js @@ -1,4 +1,4 @@ -import { RECIPES, INGREDIENTS } from '../data/catalog.js?v=2'; +import { RECIPES, INGREDIENTS } from '../data/catalog.js?v=6'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; function escapeHtml(s) { diff --git a/js/views/RecipeList.js b/js/views/RecipeList.js index bba4156..7571fbc 100644 --- a/js/views/RecipeList.js +++ b/js/views/RecipeList.js @@ -1,4 +1,4 @@ -import { RECIPES } from '../data/catalog.js?v=2'; +import { RECIPES } from '../data/catalog.js?v=6'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; function escapeHtml(s) {