Files
recipe-mockup/js/services/pantryShopping.js
ulfrxdev f80b115cae
All checks were successful
Build and Deploy / build-and-push (push) Successful in 23s
Reorganise the views and prepare summary
2026-03-26 22:29:06 +01:00

429 lines
14 KiB
JavaScript

import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
import { PANTRY_STORAGE_KEY, 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, 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<string, unknown>} */ (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 {
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,
};
}
/** @param {unknown} x */
function normalizeFreeformItem(x) {
if (!x || typeof x !== 'object') return null;
const o = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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 idx = open.findIndex(
(x) => x.ingredientId === L.ingredientId && x.unit === L.unit,
);
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({
id: newId('s'),
ingredientId: L.ingredientId,
name,
amount: Math.round(L.amount * 100) / 100,
unit: L.unit,
category,
checked: false,
sourceNote: L.sourceNote,
});
}
}
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 = Number(pantry[L.ingredientId]) || 0;
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;
}
/** @returns {Record<string, number>} ingredientId -> ilość w pantryUnit */
export function loadPantry() {
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;
});
return out;
} catch {
return {};
}
}
export function savePantry(pantry) {
try {
localStorage.setItem(PANTRY_STORAGE_KEY, JSON.stringify(pantry));
} catch { /* ignore */ }
}
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;
}
/** 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;
const cur = 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 };
}