Add ingredients' products
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m20s
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m20s
This commit is contained in:
@@ -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<string, number>} 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<string, number | PantryProductEntry>} 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);
|
||||
|
||||
Reference in New Issue
Block a user