import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=8'; 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'; function newId(prefix) { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } /** @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 */ /** * @typedef {{ * lists: ShoppingListDef[], * activeListId: string * }} ShoppingState */ function defaultShoppingState() { return { lists: [ { id: KITCHEN_LIST_ID, name: 'Kuchnia', type: 'kitchen', items: [] }, { id: MISC_LIST_ID, name: 'Inne zakupy', type: 'freeform', items: [] }, ], activeListId: KITCHEN_LIST_ID, }; } /** @param {unknown} x */ function normalizeKitchenItem(x) { if (!x || typeof x !== 'object') return null; const o = /** @type {Record} */ (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); const item = { id, ingredientId: ingId, name: String(o.name || INGREDIENTS[ingId]?.name || ingId), amount: Math.round(Number(o.amount) * 100) / 100, unit: String(o.unit || ''), category: String(o.category || INGREDIENTS[ingId]?.category || 'inne'), 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 */ function normalizeFreeformItem(x) { if (!x || typeof x !== 'object') return null; const o = /** @type {Record} */ (x); if (!o.text || !String(o.text).trim()) return null; return { id: String(o.id && String(o.id).length ? o.id : newId('f')), text: String(o.text).trim(), note: o.note ? String(o.note) : undefined, checked: Boolean(o.checked), }; } /** @returns {ShoppingState} */ function normalizeShoppingState(raw) { const base = defaultShoppingState(); if (!raw || typeof raw !== 'object') return base; const p = /** @type {Record} */ (raw); if (Array.isArray(p)) { const items = p.map(normalizeKitchenItem).filter(Boolean); const kitchen = base.lists.find((l) => l.id === KITCHEN_LIST_ID); if (kitchen && kitchen.type === 'kitchen') { kitchen.items = /** @type {KitchenShoppingItem[]} */ (items); } return base; } if (!Array.isArray(p.lists)) return base; /** @type {ShoppingListDef[]} */ const lists = []; for (const L of p.lists) { if (!L || typeof L !== 'object') continue; const row = /** @type {Record} */ (L); const id = String(row.id || ''); const name = String(row.name || 'Lista'); let type = row.type === 'freeform' ? 'freeform' : 'kitchen'; if (id === KITCHEN_LIST_ID) type = 'kitchen'; if (id === MISC_LIST_ID) type = 'freeform'; if (!id) continue; if (type === 'kitchen') { const items = Array.isArray(row.items) ? row.items.map(normalizeKitchenItem).filter(Boolean) : []; lists.push({ id, name, type: 'kitchen', items: /** @type {KitchenShoppingItem[]} */ (items) }); } else { const items = Array.isArray(row.items) ? row.items.map(normalizeFreeformItem).filter(Boolean) : []; lists.push({ id, name, type: 'freeform', items: /** @type {FreeformShoppingItem[]} */ (items) }); } } if (lists.length === 0) return base; const hasKitchen = lists.some((l) => l.id === KITCHEN_LIST_ID); if (!hasKitchen) { lists.unshift(/** @type {ShoppingListDef} */ (base.lists[0])); } const hasMisc = lists.some((l) => l.id === MISC_LIST_ID); if (!hasMisc) { lists.push(/** @type {ShoppingListDef} */ (base.lists[1])); } let activeListId = String(p.activeListId || KITCHEN_LIST_ID); if (!lists.some((l) => l.id === activeListId)) activeListId = lists[0].id; return { lists, activeListId }; } export function loadShoppingState() { try { const raw = localStorage.getItem(SHOPPING_STORAGE_KEY); if (!raw) return defaultShoppingState(); const parsed = JSON.parse(raw); return normalizeShoppingState(parsed); } catch { return defaultShoppingState(); } } export function saveShoppingState(state) { try { localStorage.setItem(SHOPPING_STORAGE_KEY, JSON.stringify(state)); } catch { /* ignore */ } } export function getListSummaries() { const { lists, activeListId } = loadShoppingState(); return { lists: lists.map((l) => ({ id: l.id, name: l.name, type: l.type, openCount: l.items.filter((i) => !i.checked).length, })), activeListId, }; } export function setActiveListId(listId) { const s = loadShoppingState(); if (!s.lists.some((l) => l.id === listId)) return s; s.activeListId = listId; saveShoppingState(s); return s; } export function getListById(listId) { return loadShoppingState().lists.find((l) => l.id === listId) ?? null; } export function getActiveList() { const s = loadShoppingState(); return s.lists.find((l) => l.id === s.activeListId) ?? s.lists[0]; } /** * @param {string} name * @returns {string} new list id */ export function addFreeformList(name) { const s = loadShoppingState(); const id = newId('list'); s.lists.push({ id, name: name.trim() || 'Nowa lista', type: 'freeform', items: [], }); s.activeListId = id; saveShoppingState(s); return id; } /** * @param {string} listId * @returns {boolean} */ export function deleteList(listId) { if (listId === KITCHEN_LIST_ID) return false; const s = loadShoppingState(); const idx = s.lists.findIndex((l) => l.id === listId); if (idx < 0) return false; s.lists.splice(idx, 1); if (s.activeListId === listId) { s.activeListId = s.lists[0]?.id || KITCHEN_LIST_ID; } saveShoppingState(s); return true; } /** * Dodaje linie tylko do listy kuchennej (składniki z katalogu). * @param {{ ingredientId: string, amount: number, unit: string, name?: string, category?: string, sourceNote?: string }[]} lines * @param {string} [listId] */ export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) { const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen'); if (!list || list.type !== 'kitchen') return s; const items = /** @type {KitchenShoppingItem[]} */ (list.items); const open = items.filter((x) => !x.checked); const closed = items.filter((x) => x.checked); for (const L of lines) { 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.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 { const item = { id: newId('s'), ingredientId: L.ingredientId, 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); } } list.items = [...open, ...closed]; saveShoppingState(s); return s; } /** * Na listę kuchenną ze spiżarni (amount w pantryUnit: g, ml lub szt.). * @param {string} [sourceNote] — nadpisuje domyślne „Ze spiżarni” */ export function addIngredientToKitchenList(ingredientId, amount = 1, sourceNote) { const def = INGREDIENTS[ingredientId]; if (!def) return; const unit = displayUnit(def.pantryUnit); addOrMergeShoppingLines([{ ingredientId, amount, unit, name: def.name, category: def.category, sourceNote: sourceNote ?? 'Ze spiżarni', }], KITCHEN_LIST_ID); } /** * @param {string} listId * @param {string} text * @param {string} [note] */ export function addFreeformLine(listId, text, note) { const t = text.trim(); if (!t) return; const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId && l.type === 'freeform'); if (!list) return; const items = /** @type {FreeformShoppingItem[]} */ (list.items); items.push({ id: newId('f'), text: t, note: note?.trim() || undefined, checked: false, }); saveShoppingState(s); } export function toggleItemInList(listId, itemId) { const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId); if (!list) return s; const it = list.items.find((i) => i.id === itemId); if (it) it.checked = !it.checked; saveShoppingState(s); return s; } export function updateKitchenItemAmount(listId, itemId, newAmount) { const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen'); if (!list) return s; const it = /** @type {KitchenShoppingItem[]} */ (list.items).find((i) => i.id === itemId); if (!it) return s; it.amount = Math.max(0, Math.round(newAmount * 100) / 100); if (it.amount <= 0) { list.items = list.items.filter((i) => i.id !== itemId); } saveShoppingState(s); return s; } export function removeItemFromList(listId, itemId) { const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId); if (!list) return s; list.items = list.items.filter((i) => i.id !== itemId); saveShoppingState(s); return s; } export function clearCheckedInList(listId) { const s = loadShoppingState(); const list = s.lists.find((l) => l.id === listId); if (!list) return s; list.items = list.items.filter((i) => !i.checked); saveShoppingState(s); return s; } export function computeShortfalls(needLines, pantry = loadPantry()) { const short = []; for (const L of needLines) { const def = INGREDIENTS[L.ingredientId]; if (!def) continue; const u = normalizeUnitForPantry(L.unit, def.pantryUnit); if (u === null) { short.push({ ...L, pantryQty: 0, shortfall: L.amount, unitMismatch: true }); continue; } const have = getPantryTotal(L.ingredientId, pantry); const miss = Math.max(0, Math.round((L.amount - have) * 100) / 100); if (miss > 0) { short.push({ ...L, unit: displayUnit(def.pantryUnit), pantryQty: have, shortfall: miss, unitMismatch: false, }); } } return short; } function displayUnit(pantryUnit) { if (pantryUnit === 'szt') return 'szt.'; return pantryUnit; } function normalizeUnitForPantry(recipeUnit, pantryUnit) { const r = String(recipeUnit).trim().toLowerCase().replace(/\.$/, ''); if (pantryUnit === 'g' && (r === 'g' || r === 'gram')) return 'g'; if (pantryUnit === 'ml' && (r === 'ml' || r === 'mililitr')) return 'ml'; if (pantryUnit === 'szt' && (r === 'szt' || r === 'sztuka' || r === 'kromki' || r === 'kromka')) return 'szt'; return null; } export function categoryLabel(cat) { return CATEGORY_LABELS[cat] || CATEGORY_LABELS.inne; } /* ══════════════════════════════════════════════════════════════════════ * Pantry v2 — hybrydowy format. * * Wartość dla składnika może być: * number — składnik bez produktów (generyczny) * { _total, items: [{productId, qty}] } — składnik z produktami * * 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[] }} PantryProductEntry */ /** @typedef {Record} PantryV2 */ function recalcTotal(entry) { entry._total = Math.round(entry.items.reduce((s, i) => s + i.qty, 0) * 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 })) : []; // Migrate generic stock → first product const generic = Math.max(0, Number(val.generic) || 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)) { // 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; } 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 = {}; 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_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 || []; } /** @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; return 0; } /** Ustaw ilość składnika BEZ produktów. Dla składników z produktami użyj setPantryProductQty. */ 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; 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') { val = { items: [], _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; } /** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */ export function applyCheckedKitchenListToPantry() { const s = loadShoppingState(); const kitchen = s.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen'); if (!kitchen) return { pantry: loadPantry(), state: s }; const pantry = loadPantry(); const items = /** @type {KitchenShoppingItem[]} */ (kitchen.items); for (const it of items) { if (!it.checked) continue; const def = INGREDIENTS[it.ingredientId]; if (!def) continue; if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue; 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 { // 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; } } kitchen.items = items.filter((i) => !i.checked); savePantry(pantry); saveShoppingState(s); return { pantry, state: s }; }