From e2b15956a0873f24c552fdd6df07e9d4bd0d77eb Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Wed, 8 Apr 2026 16:02:12 +0200 Subject: [PATCH] Redesign products --- js/services/pantryShopping.js | 88 +++++++------- js/ui/mealPlanEditor.js | 94 ++++++++++++++- js/views/Pantry.js | 213 ++++++++++++++++++++-------------- 3 files changed, 259 insertions(+), 136 deletions(-) diff --git a/js/services/pantryShopping.js b/js/services/pantryShopping.js index 7ce0c5f..c4a049a 100644 --- a/js/services/pantryShopping.js +++ b/js/services/pantryShopping.js @@ -383,19 +383,19 @@ export function categoryLabel(cat) { * 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 + * number — składnik bez produktów (generyczny) + * { _total, items: [{productId, qty}] } — składnik z produktami * - * _total = generic + sum(items[].qty) (cache, zawsze przeliczany przy zapisie) + * Brak pojęcia "generic" — jeśli składnik ma produkty, każda ilość + * musi być przypisana do konkretnego produktu. * ══════════════════════════════════════════════════════════════════════ */ /** @typedef {{ productId: string, qty: number }} PantryProductItem */ -/** @typedef {{ _total: number, items: PantryProductItem[], generic: number }} PantryProductEntry */ +/** @typedef {{ _total: number, items: PantryProductItem[] }} 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; + entry._total = Math.round(entry.items.reduce((s, i) => s + i.qty, 0) * 1000) / 1000; return entry; } @@ -406,13 +406,30 @@ function normalizePantryEntry(ingredientId, val) { .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 })) : []; + // Migrate generic stock → first product const generic = Math.max(0, Number(val.generic) || 0); - return recalcTotal({ items, generic, _total: 0 }); + if (generic > 0 && ingredientHasProducts(ingredientId)) { + const products = getProductsForIngredient(ingredientId); + if (products.length > 0) { + const firstPid = products[0].id; + const existing = items.find(i => i.productId === firstPid); + if (existing) existing.qty = Math.round((existing.qty + generic) * 1000) / 1000; + else items.push({ productId: firstPid, qty: Math.round(generic * 1000) / 1000 }); + } + } + return recalcTotal({ items, _total: 0 }); } const n = Number(val); if (!Number.isFinite(n) || n < 0) return null; if (ingredientHasProducts(ingredientId)) { - return recalcTotal({ items: [], generic: n, _total: 0 }); + // Migrate number → first product + if (n > 0) { + const products = getProductsForIngredient(ingredientId); + if (products.length > 0) { + return recalcTotal({ items: [{ productId: products[0].id, qty: n }], _total: 0 }); + } + } + return recalcTotal({ items: [], _total: 0 }); } return n; } @@ -480,30 +497,18 @@ export function getPantryProducts(ingredientId, pantry) { return val.items || []; } -/** Ilość generyczna (nieprzypisana do produktu). */ +/** @deprecated No generic stock for ingredients with products. Returns 0 for those. */ export function getPantryGeneric(ingredientId, pantry) { const val = pantry[ingredientId]; if (typeof val === 'number') return val; - if (!val) return 0; - return val.generic || 0; + return 0; } -/** Ustaw ilość generyczną składnika (bez produktu). */ +/** Ustaw ilość składnika BEZ produktów. Dla składników z produktami użyj setPantryProductQty. */ export function setPantryQty(ingredientId, qty) { const pantry = loadPantry(); - 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; - } - } + if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId]; + else pantry[ingredientId] = Math.round(qty * 1000) / 1000; savePantry(pantry); return pantry; } @@ -513,8 +518,7 @@ 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 }; + val = { items: [], _total: 0 }; pantry[ingredientId] = val; } const idx = val.items.findIndex(i => i.productId === productId); @@ -545,27 +549,21 @@ export function applyCheckedKitchenListToPantry() { const def = INGREDIENTS[it.ingredientId]; if (!def) continue; if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue; - 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; + + if (it.productId && PRODUCTS[it.productId]) { + // Add to specific product + let val = pantry[it.ingredientId]; + if (!val || typeof val === 'number') { + val = { items: [], _total: 0 }; + pantry[it.ingredientId] = val; } + 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 }); 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; + // Generic ingredient (no products) + const cur = typeof pantry[it.ingredientId] === 'number' ? pantry[it.ingredientId] : 0; pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000; } } diff --git a/js/ui/mealPlanEditor.js b/js/ui/mealPlanEditor.js index 149d00c..22c899a 100644 --- a/js/ui/mealPlanEditor.js +++ b/js/ui/mealPlanEditor.js @@ -15,7 +15,7 @@ import { savePlans, } from '../services/planStore.js?v=2'; import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4'; -import { loadPantry } from '../services/pantryShopping.js?v=2'; +import { loadPantry, getPantryTotal, getPantryProducts, setPantryQty, setPantryProductQty, addOrMergeShoppingLines } from '../services/pantryShopping.js?v=2'; import { showAppToast } from './toast.js'; import { bindCalendarDayClicks, @@ -80,6 +80,8 @@ function getProductCardHTML() { +
+
`; @@ -131,10 +133,100 @@ function openProductCard(ingredientId, productId) { document.getElementById('mpe-pc-carbs').textContent = nutrition.carbs + 'g'; } + // Stock section + renderPlannerCardStock(ingredientId, productId); + + // Shop section + renderPlannerCardShop(ingredientId, productId); + overlay.classList.remove('hidden'); overlay.style.pointerEvents = 'auto'; } +function renderPlannerCardStock(ingredientId, productId) { + const wrap = document.getElementById('mpe-pc-stock'); + if (!wrap) return; + const def = INGREDIENTS[ingredientId]; + if (!def) return; + const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit; + const pantry = loadPantry(); + const product = productId ? PRODUCTS[productId] : null; + + let qty, step, pid; + if (product) { + const items = getPantryProducts(ingredientId, pantry); + qty = items.find(i => i.productId === productId)?.qty || 0; + step = product.packSize || 1; + pid = productId; + } else { + qty = getPantryTotal(ingredientId, pantry); + step = def.purchasePack?.amount || (def.pantryUnit === 'szt' ? 1 : 10); + pid = '_generic'; + } + + wrap.innerHTML = ` +

Zapas

+
+ + ${Math.round(qty)} ${esc(u)} + +
`; + + wrap.querySelectorAll('.mpe-pc-stock-btn').forEach(btn => { + btn.addEventListener('click', () => { + const bpid = btn.dataset.pid; + const bstep = Number(btn.dataset.step) || 1; + const dir = Number(btn.dataset.dir); + const p = loadPantry(); + if (bpid === '_generic') { + const cur = getPantryTotal(ingredientId, p); + setPantryQty(ingredientId, Math.max(0, cur + bstep * dir)); + } else { + const items = getPantryProducts(ingredientId, p); + const cur = items.find(i => i.productId === bpid)?.qty || 0; + setPantryProductQty(ingredientId, bpid, Math.max(0, cur + bstep * dir)); + } + renderPlannerCardStock(ingredientId, productId); + }); + }); +} + +function renderPlannerCardShop(ingredientId, productId) { + const wrap = document.getElementById('mpe-pc-shop'); + if (!wrap) return; + const def = INGREDIENTS[ingredientId]; + if (!def) return; + const u = def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit; + const product = productId ? PRODUCTS[productId] : null; + const packSize = product?.packSize || def.purchasePack?.amount; + const packLabel = product?.packLabel || def.purchasePack?.label; + const usesPacks = Boolean(packSize && packSize > 0); + const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę'; + + wrap.innerHTML = ` + `; + + document.getElementById('mpe-pc-add-list')?.addEventListener('click', () => { + const amt = usesPacks ? packSize : (def.pantryUnit === 'szt' ? 1 : 10); + const line = { + ingredientId, + amount: amt, + unit: u, + name: product?.name || def.name, + category: def.category, + sourceNote: packLabel || 'Z planera', + }; + if (productId) line.productId = productId; + addOrMergeShoppingLines([line]); + // Quick visual feedback + const btn = document.getElementById('mpe-pc-add-list'); + if (btn) { btn.textContent = '✓ Dodano'; setTimeout(() => { btn.innerHTML = `${esc(btnLabel)}`; }, 1200); } + window.refreshShopping?.(); + }); +} + function closeProductCard() { const overlay = document.getElementById('mpe-product-card-overlay'); if (overlay) { diff --git a/js/views/Pantry.js b/js/views/Pantry.js index b7f6207..7dbcc22 100644 --- a/js/views/Pantry.js +++ b/js/views/Pantry.js @@ -6,7 +6,7 @@ import { getProductsForIngredient, ingredientHasProducts, } from '../data/catalog.js?v=8'; -import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts, getPantryGeneric } from '../services/pantryShopping.js?v=2'; +import { addIngredientToKitchenList, addOrMergeShoppingLines, categoryLabel, loadPantry, setPantryQty, setPantryProductQty, getPantryTotal, getPantryProducts } from '../services/pantryShopping.js?v=2'; import { showAppToast } from '../ui/toast.js'; /* ── helpers ── */ @@ -225,35 +225,67 @@ function getFilteredIds(searchRaw) { }).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl')); } -function rowHtml(id, pantry) { +function ingredientRowHtml(id, pantry) { const def = INGREDIENTS[id]; - const qty = getPantryTotal(id, pantry); - const u = unitLabel(def.pantryUnit); - const hasStock = qty > 0; - const icon = CATEGORY_ICONS[def.category] || 'fa-jar'; const products = getProductsForIngredient(id); - const productCount = products.length; + const icon = CATEGORY_ICONS[def.category] || 'fa-jar'; + const u = unitLabel(def.pantryUnit); - const avatar = def.image - ? `` - : `
`; - - const qtyColor = hasStock ? '#6ee7b7' : '#6d6c67'; - const qtyText = hasStock ? `${Math.round(qty)} ${esc(u)}` : `0 ${esc(u)}`; - - let meta = esc(categoryLabel(def.category)); - if (productCount > 0) meta += ` · ${productCount} ${productCount === 1 ? 'produkt' : productCount < 5 ? 'produkty' : 'produktów'}`; - - return ``; + `; + } + + // Group — ingredient header + product rows + const totalQty = getPantryTotal(id, pantry); + const pantryItems = getPantryProducts(id, pantry); + const totalColor = totalQty > 0 ? '#6ee7b7' : '#6d6c67'; + + let html = `
`; + // Group header + html += `
`; + const avatar = def.image + ? `` + : `
`; + html += avatar; + html += `
+ ${esc(def.name)} +
`; + html += `${totalQty > 0 ? Math.round(totalQty) : 0} ${esc(u)}`; + html += `
`; + + // Product rows + for (const p of products) { + const pQty = pantryItems.find(i => i.productId === p.id)?.qty || 0; + const pQtyColor = pQty > 0 ? '#ddd6ca' : '#6d6c67'; + const pAvatar = p.image + ? `` + : `
`; + html += ``; + } + html += `
`; + return html; } function groupByCategory(ids) { @@ -302,14 +334,17 @@ function renderBoard() {

${esc(categoryLabel(cat))}

-
${ids.map(id => rowHtml(id, pantry)).join('')}
+
${ids.map(id => ingredientRowHtml(id, pantry)).join('')}
`; } root.innerHTML = html; root.querySelectorAll('.pv2-chip').forEach(btn => { - btn.addEventListener('click', () => openIngredientCard(btn.dataset.id)); + btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, null)); + }); + root.querySelectorAll('.pv2-product-row').forEach(btn => { + btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, btn.dataset.productId)); }); } @@ -318,46 +353,51 @@ function renderBoard() { /* ══════════════════════ INGREDIENT CARD ══════════════════════ */ -function openIngredientCard(ingredientId) { +let editingProductId = null; + +function openIngredientCard(ingredientId, productId) { const def = INGREDIENTS[ingredientId]; if (!def) return; editingId = ingredientId; + editingProductId = productId || null; + const product = editingProductId ? PRODUCTS[editingProductId] : null; const pantry = loadPantry(); - const qty = getPantryTotal(ingredientId, pantry); const u = unitLabel(def.pantryUnit); const icon = CATEGORY_ICONS[def.category] || 'fa-jar'; - // Hero image + // Hero image — product image > ingredient image > fallback + const image = product?.image || def.image; const img = document.getElementById('pv2-card-img'); const fallback = document.getElementById('pv2-card-fallback'); const fallbackIcon = document.getElementById('pv2-card-fallback-icon'); - if (def.image) { - img.src = def.image; img.alt = def.name; img.classList.remove('hidden'); fallback.classList.add('hidden'); + if (image) { + img.src = image; img.alt = product?.name || def.name; img.classList.remove('hidden'); fallback.classList.add('hidden'); } else { img.classList.add('hidden'); fallback.classList.remove('hidden'); if (fallbackIcon) fallbackIcon.className = `fas ${icon} text-3xl`; } - // Header - document.getElementById('pv2-card-category').textContent = categoryLabel(def.category); - document.getElementById('pv2-card-name').textContent = def.name; + // Header — show product info or ingredient info + document.getElementById('pv2-card-category').textContent = product?.brand || categoryLabel(def.category); + document.getElementById('pv2-card-name').textContent = product?.name || def.name; const packEl = document.getElementById('pv2-card-pack'); - if (def.purchasePack) { - packEl.textContent = def.purchasePack.label || `${def.purchasePack.amount} ${u}`; + const packLabel = product?.packLabel || def.purchasePack?.label; + if (packLabel) { + packEl.textContent = packLabel; packEl.classList.remove('hidden'); } else { packEl.classList.add('hidden'); } - // Nutrition - renderCardNutrition(def); + // Nutrition — use product values if available + renderCardNutrition(def, product); // Stock - renderCardStock(ingredientId, pantry); + renderCardStock(ingredientId, editingProductId, pantry); // Shopping - renderCardShop(ingredientId); + renderCardShop(ingredientId, editingProductId); // Show const overlay = document.getElementById('pv2-card-overlay'); @@ -371,10 +411,10 @@ function closeIngredientCard() { renderBoard(); } -function renderCardNutrition(def) { +function renderCardNutrition(def, product) { const wrap = document.getElementById('pv2-card-nutrition'); if (!wrap) return; - const n = def.nutritionPer100g; + const n = product?.nutritionPer100g || def.nutritionPer100g; if (!n) { wrap.innerHTML = ''; return; } const label = def.pantryUnit === 'ml' ? 'na 100 ml' : 'na 100 g'; wrap.innerHTML = ` @@ -399,43 +439,29 @@ function renderCardNutrition(def) { `; } -function renderCardStock(ingredientId, pantry) { +function renderCardStock(ingredientId, productId, pantry) { const wrap = document.getElementById('pv2-card-stock-section'); if (!wrap) return; const def = INGREDIENTS[ingredientId]; if (!def) return; const u = unitLabel(def.pantryUnit); - const qty = getPantryTotal(ingredientId, pantry); - const products = getProductsForIngredient(ingredientId); - const hasProds = products.length > 0; let html = `

Zapas

`; - if (hasProds) { - const pantryProducts = getPantryProducts(ingredientId, pantry); - const generic = getPantryGeneric(ingredientId, pantry); - const productQty = (pid) => pantryProducts.find(i => i.productId === pid)?.qty || 0; - - html += `
`; - html += `
Łącznie${Math.round(qty)} ${esc(u)}
`; - html += `
`; - for (const p of products) { - const q = Math.round(productQty(p.id)); - html += `
- ${esc(p.name)} - - ${q} ${esc(u)} - -
`; - } - html += `
- Nieokreślony - - ${Math.round(generic)} ${esc(u)} - + if (productId) { + // Product card — show just this product's stock + const product = PRODUCTS[productId]; + const pantryItems = getPantryProducts(ingredientId, pantry); + const qty = pantryItems.find(i => i.productId === productId)?.qty || 0; + const step = product?.packSize || pantryQtyStep(ingredientId); + html += `
+ + ${Math.round(qty)} ${esc(u)} +
`; - html += `
`; } else { + // Generic ingredient — simple +/- + const qty = getPantryTotal(ingredientId, pantry); const step = pantryQtyStep(ingredientId); html += `
@@ -446,7 +472,6 @@ function renderCardStock(ingredientId, pantry) { wrap.innerHTML = html; - // Bind stock buttons wrap.querySelectorAll('.pv2-stock-btn').forEach(btn => { btn.addEventListener('click', () => { if (!editingId) return; @@ -455,48 +480,56 @@ function renderCardStock(ingredientId, pantry) { const dir = Number(btn.dataset.dir); const p = loadPantry(); if (pid === '_generic') { - const cur = getPantryGeneric(editingId, p); + const cur = getPantryTotal(editingId, p); setPantryQty(editingId, Math.max(0, cur + step * dir)); } else { const items = getPantryProducts(editingId, p); const cur = items.find(i => i.productId === pid)?.qty || 0; setPantryProductQty(editingId, pid, Math.max(0, cur + step * dir)); } - renderCardStock(editingId, loadPantry()); + renderCardStock(editingId, editingProductId, loadPantry()); }); }); } -function renderCardShop(ingredientId) { +function renderCardShop(ingredientId, productId) { const wrap = document.getElementById('pv2-card-shop-section'); if (!wrap) return; const def = INGREDIENTS[ingredientId]; if (!def) return; const u = unitLabel(def.pantryUnit); - const pack = def.purchasePack; - const usesPacks = Boolean(pack && pack.amount > 0); - const hint = usesPacks ? `1 opak. = ${pack.label || `${pack.amount} ${u}`}` : ''; + const product = productId ? PRODUCTS[productId] : null; + const packSize = product?.packSize || def.purchasePack?.amount; + const packLabel = product?.packLabel || def.purchasePack?.label; + const usesPacks = Boolean(packSize && packSize > 0); + const btnLabel = usesPacks ? `Dodaj na listę (${packLabel || `${packSize} ${u}`})` : 'Dodaj na listę'; wrap.innerHTML = ` - ${hint ? `

${esc(hint)}

` : ''}`; + ${esc(btnLabel)} + `; document.getElementById('pv2-card-add-list')?.addEventListener('click', () => { if (!editingId) return; const d = INGREDIENTS[editingId]; if (!d) return; const uLabel = unitLabel(d.pantryUnit); - if (usesPacks && d.purchasePack) { - const amt = d.purchasePack.amount; - addIngredientToKitchenList(editingId, amt, d.purchasePack.label || `${amt} ${uLabel}`); - showAppToast(`Dodano 1 opak. na listę.`); - } else { - const step = pantryQtyStep(editingId); - addIngredientToKitchenList(editingId, step); - showAppToast(`Dodano ${step} ${uLabel} na listę.`); - } + const amt = usesPacks ? packSize : pantryQtyStep(editingId); + const note = usesPacks ? (packLabel || `${packSize} ${uLabel}`) : undefined; + + // Use addOrMergeShoppingLines to include productId + const line = { + ingredientId: editingId, + amount: amt, + unit: uLabel, + name: product?.name || d.name, + category: d.category, + sourceNote: note || 'Ze spiżarni', + }; + if (productId) line.productId = productId; + addOrMergeShoppingLines([line]); + + showAppToast(`Dodano ${product?.name || d.name} na listę.`); window.refreshShopping?.(); }); }