Add pantry and shopping lists
This commit is contained in:
51
stacks/recipe/js/services/dateUtils.js
Normal file
51
stacks/recipe/js/services/dateUtils.js
Normal file
@@ -0,0 +1,51 @@
|
||||
export function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function sameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function addDays(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
|
||||
export function startOfWeekMonday(d) {
|
||||
const date = startOfDay(d);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
return addDays(date, diff);
|
||||
}
|
||||
|
||||
export function startOfMonth(d) {
|
||||
const x = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
export function addMonths(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setMonth(x.getMonth() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
export function addWeeks(d, n) {
|
||||
return addDays(d, n * 7);
|
||||
}
|
||||
|
||||
export function weekContains(weekStart, d) {
|
||||
const t = startOfDay(d).getTime();
|
||||
const ws = weekStart.getTime();
|
||||
const we = addDays(weekStart, 6).getTime();
|
||||
return t >= ws && t <= we;
|
||||
}
|
||||
|
||||
export function sameMonth(a, b) {
|
||||
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
413
stacks/recipe/js/services/pantryShopping.js
Normal file
413
stacks/recipe/js/services/pantryShopping.js
Normal file
@@ -0,0 +1,413 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna sztuka / domyślna jednostka magazynowa — na listę kuchenną ze spiżarni.
|
||||
*/
|
||||
export function addIngredientToKitchenList(ingredientId, amount = 1) {
|
||||
const def = INGREDIENTS[ingredientId];
|
||||
if (!def) return;
|
||||
const unit = displayUnit(def.pantryUnit);
|
||||
addOrMergeShoppingLines([{
|
||||
ingredientId,
|
||||
amount,
|
||||
unit,
|
||||
name: def.name,
|
||||
category: def.category,
|
||||
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 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 };
|
||||
}
|
||||
141
stacks/recipe/js/services/planIngredients.js
Normal file
141
stacks/recipe/js/services/planIngredients.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { INGREDIENTS, RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import { addDays } from './dateUtils.js';
|
||||
import { getDayPlan } from './planStore.js';
|
||||
|
||||
export function dayHasAnyMeal(plans, d) {
|
||||
const p = getDayPlan(plans, d);
|
||||
return MEAL_SLOTS.some((s) => {
|
||||
const arr = p[s.id];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumDayNutrition(dayPlan) {
|
||||
let kcal = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let mealCount = 0;
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
mealCount += 1;
|
||||
kcal += r.nutritionPerServing.kcal * s;
|
||||
protein += r.nutritionPerServing.protein * s;
|
||||
fat += r.nutritionPerServing.fat * s;
|
||||
carbs += r.nutritionPerServing.carbs * s;
|
||||
});
|
||||
});
|
||||
return {
|
||||
kcal: Math.round(kcal),
|
||||
protein: Math.round(protein * 10) / 10,
|
||||
fat: Math.round(fat * 10) / 10,
|
||||
carbs: Math.round(carbs * 10) / 10,
|
||||
mealCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLine(ing, scaledAmount) {
|
||||
const def = INGREDIENTS[ing.ingredientId];
|
||||
return {
|
||||
ingredientId: ing.ingredientId,
|
||||
name: def?.name ?? ing.ingredientId,
|
||||
category: def?.category ?? 'inne',
|
||||
amount: scaledAmount,
|
||||
unit: ing.unit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Płaska lista składników z jednego dnia (wszystkie pory). */
|
||||
export function flattenDayIngredientLines(dayPlan) {
|
||||
/** @type {ReturnType<typeof resolveLine>[]} */
|
||||
const out = [];
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r || !Array.isArray(r.ingredients)) return;
|
||||
const serv = Math.max(1, Number(entry.servings) || 1);
|
||||
r.ingredients.forEach((ing) => {
|
||||
const scaled = Math.round(ing.amount * serv * 10) / 10;
|
||||
out.push(resolveLine(ing, scaled));
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sumuje po (ingredientId + unit) — ta sama jednostka jak w przepisie. */
|
||||
export function mergeIngredientLines(lines) {
|
||||
const m = new Map();
|
||||
for (const L of lines) {
|
||||
const key = `${L.ingredientId}\t${L.unit}`;
|
||||
const cur = m.get(key);
|
||||
if (!cur) {
|
||||
m.set(key, { ...L });
|
||||
} else {
|
||||
cur.amount = Math.round((cur.amount + L.amount) * 10) / 10;
|
||||
}
|
||||
}
|
||||
return [...m.values()].sort((a, b) => {
|
||||
const c = a.category.localeCompare(b.category);
|
||||
return c !== 0 ? c : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapotrzebowanie składników od weekStart (włącznie) przez 7 dni.
|
||||
* @param {Record<string, unknown>} plans
|
||||
* @param {Date} weekStart
|
||||
*/
|
||||
export function aggregateWeekIngredientNeed(plans, weekStart) {
|
||||
const all = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(weekStart, i);
|
||||
const dayPlan = getDayPlan(plans, day);
|
||||
all.push(...flattenDayIngredientLines(dayPlan));
|
||||
}
|
||||
return mergeIngredientLines(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
||||
*/
|
||||
export function aggregateDayIngredientsBySlot(dayPlan) {
|
||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: string }[] }[] }[]} */
|
||||
const blocks = [];
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||
const recipes = [];
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
const items = r.ingredients.map((ing) => {
|
||||
const def = INGREDIENTS[ing.ingredientId];
|
||||
return {
|
||||
ingredientId: ing.ingredientId,
|
||||
name: def?.name ?? ing.ingredientId,
|
||||
category: def?.category ?? 'inne',
|
||||
amount: Math.round(ing.amount * s * 10) / 10,
|
||||
unit: ing.unit,
|
||||
};
|
||||
});
|
||||
recipes.push({ recipeTitle: r.title, items });
|
||||
});
|
||||
if (recipes.length > 0) {
|
||||
blocks.push({ mealLabel: slot.label, recipes });
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
85
stacks/recipe/js/services/planStore.js
Normal file
85
stacks/recipe/js/services/planStore.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import { PLANS_STORAGE_KEY } from '../storageKeys.js';
|
||||
import { startOfDay } from './dateUtils.js';
|
||||
|
||||
export function dateKey(d) {
|
||||
const x = startOfDay(d);
|
||||
const y = x.getFullYear();
|
||||
const m = String(x.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(x.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function newPlanEntryId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } */
|
||||
export function normalizeSlotValue(v) {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
.filter((x) => x && x.recipeId && RECIPES[x.recipeId])
|
||||
.map((x) => ({
|
||||
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
|
||||
recipeId: x.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
|
||||
}));
|
||||
}
|
||||
if (typeof v === 'object' && v.recipeId && RECIPES[v.recipeId]) {
|
||||
return [{
|
||||
id: newPlanEntryId(),
|
||||
recipeId: v.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeDayPlan(day) {
|
||||
if (!day || typeof day !== 'object') return {};
|
||||
const out = {};
|
||||
MEAL_SLOTS.forEach((s) => {
|
||||
const arr = normalizeSlotValue(day[s.id]);
|
||||
if (arr.length > 0) out[s.id] = arr;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeAllPlans(plans) {
|
||||
if (!plans || typeof plans !== 'object') return {};
|
||||
const out = {};
|
||||
Object.keys(plans).forEach((key) => {
|
||||
const d = normalizeDayPlan(plans[key]);
|
||||
if (Object.keys(d).length > 0) out[key] = d;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function loadPlans() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
return normalizeAllPlans(parsed);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlans(plans) {
|
||||
try {
|
||||
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getDayPlan(plans, d) {
|
||||
const key = dateKey(d);
|
||||
const day = plans[key];
|
||||
return day && typeof day === 'object' ? day : {};
|
||||
}
|
||||
Reference in New Issue
Block a user