Add pantry and shopping lists
This commit is contained in:
@@ -1,3 +1,30 @@
|
||||
import { RECIPES } from '../data/catalog.js';
|
||||
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
sameDay,
|
||||
sameMonth,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeekMonday,
|
||||
weekContains,
|
||||
} from '../services/dateUtils.js';
|
||||
import {
|
||||
aggregateDayIngredientsBySlot,
|
||||
dayHasAnyMeal,
|
||||
sumDayNutrition,
|
||||
} from '../services/planIngredients.js';
|
||||
import { addOrMergeShoppingLines } from '../services/pantryShopping.js';
|
||||
import {
|
||||
dateKey,
|
||||
getDayPlan,
|
||||
loadPlans,
|
||||
newPlanEntryId,
|
||||
savePlans,
|
||||
} from '../services/planStore.js';
|
||||
|
||||
const MONTHS_SHORT = [
|
||||
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
|
||||
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
|
||||
@@ -7,346 +34,12 @@ const WEEKDAYS_LONG = [
|
||||
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
||||
];
|
||||
|
||||
const MEAL_SLOTS = [
|
||||
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
|
||||
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
|
||||
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
|
||||
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
|
||||
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
|
||||
];
|
||||
|
||||
/** Katalog przepisów (spójny z listą w aplikacji) — porcja bazowa = 1 */
|
||||
const PLANNER_RECIPES = {
|
||||
placki: {
|
||||
id: 'placki',
|
||||
title: 'Puszyste placki',
|
||||
minutes: 15,
|
||||
thumbLabel: 'Placki',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
||||
ingredients: [
|
||||
{ name: 'Mąka pszenna', amount: 200, unit: 'g' },
|
||||
{ name: 'Mleko', amount: 250, unit: 'ml' },
|
||||
{ name: 'Jajka', amount: 2, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
salatka: {
|
||||
id: 'salatka',
|
||||
title: 'Sałatka z kurczakiem',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Sałatka',
|
||||
allowedSlots: ['obiad'],
|
||||
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
||||
ingredients: [
|
||||
{ name: 'Pierś z kurczaka', amount: 150, unit: 'g' },
|
||||
{ name: 'Mix sałat', amount: 100, unit: 'g' },
|
||||
{ name: 'Pomidor', amount: 1, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
makaron: {
|
||||
id: 'makaron',
|
||||
title: 'Makaron z pomidorami i bazylią',
|
||||
minutes: 30,
|
||||
thumbLabel: 'Makaron',
|
||||
allowedSlots: ['obiad', 'kolacja'],
|
||||
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
||||
ingredients: [
|
||||
{ name: 'Makaron', amount: 120, unit: 'g' },
|
||||
{ name: 'Pomidory krojone', amount: 400, unit: 'g' },
|
||||
{ name: 'Bazylia świeża', amount: 10, unit: 'g' },
|
||||
],
|
||||
},
|
||||
koktajl: {
|
||||
id: 'koktajl',
|
||||
title: 'Koktajl owocowy',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Koktajl',
|
||||
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
||||
ingredients: [
|
||||
{ name: 'Jogurt naturalny', amount: 200, unit: 'g' },
|
||||
{ name: 'Mieszanka jagód', amount: 150, unit: 'g' },
|
||||
{ name: 'Miód', amount: 15, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tost_awokado: {
|
||||
id: 'tost_awokado',
|
||||
title: 'Tost z awokado',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Tost',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
||||
ingredients: [
|
||||
{ name: 'Chleb na zakwasie', amount: 2, unit: 'kromki' },
|
||||
{ name: 'Awokado', amount: 1, unit: 'szt.' },
|
||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
losos: {
|
||||
id: 'losos',
|
||||
title: 'Grillowany łosoś',
|
||||
minutes: 25,
|
||||
thumbLabel: 'Łosoś',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
||||
ingredients: [
|
||||
{ name: 'Filet z łososia', amount: 180, unit: 'g' },
|
||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
||||
{ name: 'Koper', amount: 5, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tacos: {
|
||||
id: 'tacos',
|
||||
title: 'Tacos z wołowiną',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Tacos',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
||||
ingredients: [
|
||||
{ name: 'Mięso mielone wołowe', amount: 200, unit: 'g' },
|
||||
{ name: 'Tortille kukurydziane', amount: 4, unit: 'szt.' },
|
||||
{ name: 'Salsa pomidorowa', amount: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
owsianka: {
|
||||
id: 'owsianka',
|
||||
title: 'Miska owsianki',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Owsianka',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
||||
ingredients: [
|
||||
{ name: 'Płatki owsiane', amount: 60, unit: 'g' },
|
||||
{ name: 'Mleko', amount: 200, unit: 'ml' },
|
||||
{ name: 'Miód', amount: 20, unit: 'g' },
|
||||
],
|
||||
},
|
||||
serek_owoc: {
|
||||
id: 'serek_owoc',
|
||||
title: 'Serek wiejski z orzechami i owocami',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Serek',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
||||
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
||||
ingredients: [
|
||||
{ name: 'Serek wiejski', amount: 200, unit: 'g' },
|
||||
{ name: 'Miód', amount: 10, unit: 'g' },
|
||||
{ name: 'Orzechy włoskie', amount: 50, unit: 'g' },
|
||||
{ name: 'Truskawki', amount: 100, unit: 'g' },
|
||||
{ name: 'Borówki ameryk.', amount: 80, unit: 'g' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
|
||||
|
||||
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
|
||||
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
|
||||
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
||||
|
||||
function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
function addDays(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
|
||||
function startOfWeekMonday(d) {
|
||||
const date = startOfDay(d);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
return addDays(date, diff);
|
||||
}
|
||||
|
||||
function startOfMonth(d) {
|
||||
const x = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
function addMonths(d, n) {
|
||||
const x = new Date(d);
|
||||
x.setMonth(x.getMonth() + n);
|
||||
return startOfDay(x);
|
||||
}
|
||||
|
||||
function addWeeks(d, n) {
|
||||
return addDays(d, n * 7);
|
||||
}
|
||||
|
||||
function weekContains(weekStart, d) {
|
||||
const t = startOfDay(d).getTime();
|
||||
const ws = weekStart.getTime();
|
||||
const we = addDays(weekStart, 6).getTime();
|
||||
return t >= ws && t <= we;
|
||||
}
|
||||
|
||||
function sameMonth(a, b) {
|
||||
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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 } (stary format: jeden obiekt — migrujemy przy wczytaniu). */
|
||||
function normalizeSlotValue(v) {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
.filter((x) => x && x.recipeId && PLANNER_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 && PLANNER_RECIPES[v.recipeId]) {
|
||||
return [{
|
||||
id: newPlanEntryId(),
|
||||
recipeId: v.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePlans(plans) {
|
||||
try {
|
||||
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getDayPlan(plans, d) {
|
||||
const key = dateKey(d);
|
||||
const day = plans[key];
|
||||
return day && typeof day === 'object' ? day : {};
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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 = PLANNER_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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
||||
* @returns {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]}
|
||||
*/
|
||||
function aggregateDayIngredientsBySlot(dayPlan) {
|
||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: 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 = PLANNER_RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
const items = r.ingredients.map((ing) => ({
|
||||
name: ing.name,
|
||||
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;
|
||||
}
|
||||
|
||||
function recipesForSlot(slotId) {
|
||||
return Object.values(PLANNER_RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
||||
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
||||
}
|
||||
|
||||
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
|
||||
@@ -727,7 +420,7 @@ function renderDayContent(state) {
|
||||
: '';
|
||||
|
||||
const entryCards = entries.map((entry) => {
|
||||
const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null;
|
||||
const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null;
|
||||
if (!recipe) return '';
|
||||
const servings = Math.max(1, Number(entry.servings) || 1);
|
||||
const n = recipe.nutritionPerServing;
|
||||
@@ -851,7 +544,11 @@ function renderIngredientsSheet(state) {
|
||||
<p class="text-sm font-semibold text-gray-800 mb-1.5">${escapeHtml(rec.recipeTitle)}</p>
|
||||
<ul class="space-y-0 border border-gray-100 rounded-xl overflow-hidden bg-white">
|
||||
${rec.items.map((ing) => `
|
||||
<li class="flex items-center gap-3 py-3 px-3 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors planner-ing-row">
|
||||
<li class="flex items-center gap-3 py-3 px-3 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors planner-ing-row"
|
||||
data-ingredient-id="${escapeHtml(ing.ingredientId)}"
|
||||
data-amount="${escapeHtml(String(ing.amount))}"
|
||||
data-unit="${escapeHtml(ing.unit)}"
|
||||
data-category="${escapeHtml(ing.category)}">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors shrink-0"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-sm flex-1 ingredient-text transition-colors">${escapeHtml(ing.name)}</span>
|
||||
<span class="font-medium text-gray-900 text-sm tabular-nums">${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}</span>
|
||||
@@ -1042,7 +739,7 @@ export function setupMealPlanner() {
|
||||
const pick = e.target.closest('.planner-pick-recipe');
|
||||
if (!pick || !state.pickerSlot) return;
|
||||
const recipeId = pick.getAttribute('data-recipe-id');
|
||||
if (!recipeId || !PLANNER_RECIPES[recipeId]) return;
|
||||
if (!recipeId || !RECIPES[recipeId]) return;
|
||||
const key = dateKey(state.selected);
|
||||
if (!state.plans[key]) state.plans[key] = {};
|
||||
const slotId = state.pickerSlot;
|
||||
@@ -1073,7 +770,24 @@ export function setupMealPlanner() {
|
||||
const rows = body?.querySelectorAll('.planner-ing-row');
|
||||
const n = rows?.length ?? 0;
|
||||
if (n === 0) return;
|
||||
showPlannerToast(`Dodano ${n} składników do listy (zakładka Zakupy w przygotowaniu).`);
|
||||
const lines = [];
|
||||
rows.forEach((row) => {
|
||||
const id = row.getAttribute('data-ingredient-id');
|
||||
const amount = parseFloat(row.getAttribute('data-amount') || '');
|
||||
const unit = row.getAttribute('data-unit') || '';
|
||||
const category = row.getAttribute('data-category') || '';
|
||||
if (!id || !Number.isFinite(amount)) return;
|
||||
lines.push({
|
||||
ingredientId: id,
|
||||
amount,
|
||||
unit,
|
||||
category,
|
||||
sourceNote: 'Z planu dnia',
|
||||
});
|
||||
});
|
||||
addOrMergeShoppingLines(lines);
|
||||
showPlannerToast(`Dodano ${lines.length} składników na listę.`);
|
||||
window.refreshShopping?.();
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
@@ -1085,7 +799,24 @@ export function setupMealPlanner() {
|
||||
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
|
||||
return;
|
||||
}
|
||||
showPlannerToast(`Dodano ${n} zaznaczonych pozycji do listy (zakładka Zakupy w przygotowaniu).`);
|
||||
const lines = [];
|
||||
selected.forEach((row) => {
|
||||
const id = row.getAttribute('data-ingredient-id');
|
||||
const amount = parseFloat(row.getAttribute('data-amount') || '');
|
||||
const unit = row.getAttribute('data-unit') || '';
|
||||
const category = row.getAttribute('data-category') || '';
|
||||
if (!id || !Number.isFinite(amount)) return;
|
||||
lines.push({
|
||||
ingredientId: id,
|
||||
amount,
|
||||
unit,
|
||||
category,
|
||||
sourceNote: 'Z planu dnia',
|
||||
});
|
||||
});
|
||||
addOrMergeShoppingLines(lines);
|
||||
showPlannerToast(`Dodano ${lines.length} pozycji na listę.`);
|
||||
window.refreshShopping?.();
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user