Files
homelab/stacks/recipe/js/views/MealPlanner.js
2026-03-23 21:45:00 +01:00

1094 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MONTHS_SHORT = [
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
];
const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
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));
}
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
const today = startOfDay(new Date());
if (!sameDay(selected, today)) return false;
if (mode === 'week') return weekContains(weekStart, today);
return sameMonth(monthAnchor, today);
}
function syncTodayButton(mode, weekStart, monthAnchor, selected) {
const btn = document.getElementById('cal-go-today');
if (!btn) return;
const onToday = isCalendarOnToday(mode, weekStart, monthAnchor, selected);
const active = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors';
const dim = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-100 bg-gray-50 px-2 text-[10px] font-semibold text-gray-400 shadow-none cursor-default transition-colors';
btn.className = onToday ? dim : active;
btn.disabled = onToday;
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
<div class="shrink-0 bg-white border-b border-gray-200 mt-3">
<div class="px-3 pt-2 pb-1.5 flex items-center gap-1">
<button type="button" id="cal-prev" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors" aria-label="Poprzedni okres">
<i class="fas fa-chevron-left text-xs" aria-hidden="true"></i>
</button>
<p id="cal-period-label" class="flex-1 min-w-0 text-xs font-medium text-gray-900 text-center tabular-nums leading-none px-1 truncate"></p>
<button type="button" id="cal-next" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors" aria-label="Następny okres">
<i class="fas fa-chevron-right text-xs" aria-hidden="true"></i>
</button>
</div>
<div class="px-3 pb-2 flex items-center justify-between gap-3">
<button type="button" id="cal-go-today" title="Dziś" aria-label="Przejdź do dzisiejszego dnia"
class="h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2.5 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors">
<i class="fas fa-calendar-day text-[9px] opacity-70" aria-hidden="true"></i>
Dziś
</button>
<div id="planner-cal-mode" class="inline-flex items-center shrink-0 rounded-md border border-gray-200 bg-gray-50 p-px gap-px shadow-sm" role="tablist" aria-label="Skala kalendarza">
<button type="button" data-cal-mode="week" id="planner-mode-week" title="Tydzień" aria-label="Widok tygodnia"
class="planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors bg-white text-gray-900 shadow-sm">
<i class="fas fa-calendar-week text-[10px]" aria-hidden="true"></i>
</button>
<button type="button" data-cal-mode="month" id="planner-mode-month" title="Miesiąc" aria-label="Widok miesiąca"
class="planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors text-gray-400 hover:text-gray-600">
<i class="fas fa-calendar-days text-[10px]" aria-hidden="true"></i>
</button>
</div>
</div>
<div id="calendar-week-wrap" class="px-3 pb-2.5">
<div class="grid grid-cols-7 gap-0.5 text-center text-[9px] font-medium text-gray-400 uppercase tracking-wide mb-0.5 leading-none">
${WEEKDAYS_SHORT.map((d) => `<div>${d}</div>`).join('')}
</div>
<div id="calendar-week-grid" class="grid grid-cols-7 gap-0.5"></div>
</div>
<div id="calendar-month-wrap" class="hidden px-3 pb-2.5">
<div class="grid grid-cols-7 gap-0.5 text-center text-[9px] font-medium text-gray-400 uppercase tracking-wide mb-0.5 leading-none">
${WEEKDAYS_SHORT.map((d) => `<div>${d}</div>`).join('')}
</div>
<div id="calendar-month-grid" class="grid grid-cols-7 gap-0.5"></div>
</div>
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4">
<p id="planner-day-heading" class="text-sm font-semibold text-gray-900 tabular-nums mb-2"></p>
<div id="planner-summary-card" class="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 to-white p-3 shadow-sm mb-3">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<p class="text-[10px] font-semibold uppercase tracking-wide text-amber-900/70">Dziś — podsumowanie</p>
<p id="planner-summary-kcal" class="text-2xl font-bold text-gray-900 tabular-nums leading-tight mt-0.5">0 <span class="text-sm font-semibold text-gray-500">kcal</span></p>
</div>
<button type="button" id="planner-toggle-nutrition" class="shrink-0 flex items-center gap-1 text-[11px] font-semibold text-amber-900/80 hover:text-gray-900 py-1 px-2 rounded-lg hover:bg-amber-100/50 transition-colors" aria-expanded="false">
Szczegóły
<i class="fas fa-chevron-down text-[9px] transition-transform" id="planner-nutrition-chevron" aria-hidden="true"></i>
</button>
</div>
<div id="planner-macro-row" class="flex gap-2 mb-0">
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
<p class="text-[9px] font-semibold text-gray-500 uppercase">B</p>
<p id="planner-macro-p" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
</div>
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
<p class="text-[9px] font-semibold text-gray-500 uppercase">T</p>
<p id="planner-macro-f" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
</div>
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
<p class="text-[9px] font-semibold text-gray-500 uppercase">W</p>
<p id="planner-macro-c" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
</div>
</div>
<div id="planner-nutrition-details" class="hidden mt-3 pt-3 border-t border-amber-200/60">
<ul class="space-y-0 divide-y divide-amber-100/80 text-sm">
<li class="flex justify-between py-2 font-bold"><span class="text-gray-800">Kalorie</span><span id="planner-detail-kcal" class="text-gray-900 tabular-nums">0 kcal</span></li>
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Białko</span><span id="planner-detail-p" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Tłuszcze</span><span id="planner-detail-f" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Węglowodany</span><span id="planner-detail-c" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
</ul>
<p id="planner-summary-hint" class="text-[11px] text-gray-500 mt-2">Suma z zaplanowanych posiłków (porcje × wartości z przepisu).</p>
</div>
</div>
<button type="button" id="planner-open-ingredients" class="w-full mb-4 flex items-center justify-center gap-2 py-3 rounded-xl border border-dashed border-gray-300 bg-white text-sm font-semibold text-gray-800 hover:border-gray-400 hover:bg-gray-50 transition-colors">
<i class="fas fa-shopping-basket text-gray-500" aria-hidden="true"></i>
Składniki na ten dzień
</button>
<div id="planner-meal-slots" class="space-y-3 pb-2"></div>
</div>
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
<div class="shrink-0 p-4 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-3" aria-hidden="true"></div>
<h2 id="planner-picker-title" class="text-lg font-bold text-gray-900 leading-tight pr-2">Wybierz przepis</h2>
<p id="planner-picker-sub" class="text-xs text-gray-500 mt-1"></p>
</div>
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-3 pb-8 space-y-2"></div>
</div>
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 p-4 pb-2 border-b border-gray-100 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-3" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-lg font-bold text-gray-900 leading-tight pr-2">Składniki na dziś</h2>
<p class="text-xs text-gray-500 mt-1">Dodaj wszystko lub zaznacz wiersze. Zamknij: przeciągnij w dół lub dotknij tła.</p>
</div>
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
<div class="shrink-0 p-4 pt-2 pb-5 border-t border-gray-100 bg-white space-y-2">
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm flex items-center justify-center gap-2">
<i class="fas fa-cart-plus" aria-hidden="true"></i>
Dodaj wszystkie do listy zakupów
</button>
<button type="button" id="planner-ing-add-btn" class="w-full border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-check-square text-gray-500 text-xs" aria-hidden="true"></i>
Dodaj tylko zaznaczone
</button>
</div>
</div>
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[55] opacity-0 translate-y-2 transition-all duration-300" role="status">
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
</div>
</div>
`;
}
function renderWeekGrid(weekStart, selected, plans) {
const grid = document.getElementById('calendar-week-grid');
if (!grid) return;
const cells = [];
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i);
const isSel = selected && sameDay(day, selected);
const isToday = sameDay(day, new Date());
const hasMeals = dayHasAnyMeal(plans, day);
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
${isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100'}
${isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
<span>${day.getDate()}</span>
${hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
</button>
`);
}
grid.innerHTML = cells.join('');
}
function renderMonthGrid(monthAnchor, selected, plans) {
const grid = document.getElementById('calendar-month-grid');
if (!grid) return;
const first = startOfMonth(monthAnchor);
const startGrid = startOfWeekMonday(first);
const cells = [];
for (let i = 0; i < 42; i++) {
const day = addDays(startGrid, i);
const inMonth = day.getMonth() === first.getMonth();
const isSel = selected && sameDay(day, selected);
const isToday = sameDay(day, new Date());
const hasMeals = inMonth && dayHasAnyMeal(plans, day);
cells.push(`
<button type="button" data-planner-day="${day.getTime()}"
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
${!inMonth ? 'text-gray-300' : (isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100')}
${inMonth && isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
<span>${day.getDate()}</span>
${inMonth && hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
</button>
`);
}
grid.innerHTML = cells.join('');
}
function updatePeriodLabel(mode, weekStart, monthAnchor) {
const el = document.getElementById('cal-period-label');
if (!el) return;
if (mode === 'week') {
const end = addDays(weekStart, 6);
const y = weekStart.getFullYear();
if (weekStart.getMonth() === end.getMonth()) {
el.textContent = `${weekStart.getDate()}${end.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${y}`;
} else {
el.textContent = `${weekStart.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${end.getDate()} ${MONTHS_SHORT[end.getMonth()]} ${y}`;
}
} else {
const m = monthAnchor.getMonth();
const y = monthAnchor.getFullYear();
const monthLong = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
][m];
el.textContent = `${monthLong} ${y}`;
}
}
function syncModeToggle(mode) {
const w = document.getElementById('planner-mode-week');
const m = document.getElementById('planner-mode-month');
const weekWrap = document.getElementById('calendar-week-wrap');
const monthWrap = document.getElementById('calendar-month-wrap');
const base = 'planner-cal-mode-btn w-7 h-6 flex items-center justify-center rounded-[0.3125rem] transition-colors';
const active = `${base} bg-white text-gray-900 shadow-sm`;
const idle = `${base} text-gray-400 hover:text-gray-600`;
if (w && m) {
if (mode === 'week') {
w.className = active;
m.className = idle;
} else {
w.className = idle;
m.className = active;
}
}
if (weekWrap && monthWrap) {
weekWrap.classList.toggle('hidden', mode !== 'week');
monthWrap.classList.toggle('hidden', mode !== 'month');
}
}
function bindDayClicks(container, state, rerender) {
container?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-planner-day]');
if (!btn) return;
const ts = Number(btn.getAttribute('data-planner-day'));
state.selected = new Date(ts);
rerender();
});
}
function showPlannerToast(message) {
const wrap = document.getElementById('planner-toast');
const text = document.getElementById('planner-toast-text');
if (!wrap || !text) return;
text.textContent = message;
wrap.classList.remove('opacity-0', 'translate-y-2');
wrap.classList.add('opacity-100', 'translate-y-0');
clearTimeout(showPlannerToast._t);
showPlannerToast._t = setTimeout(() => {
wrap.classList.add('opacity-0', 'translate-y-2');
wrap.classList.remove('opacity-100', 'translate-y-0');
}, 2600);
}
function openSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
backdrop.classList.remove('hidden');
requestAnimationFrame(() => {
backdrop.classList.remove('opacity-0');
});
}
function closeSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
backdrop.classList.add('opacity-0');
setTimeout(() => backdrop.classList.add('hidden'), 300);
}
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn) {
const zone = sheet.querySelector('[data-planner-sheet-drag-zone]');
if (!zone || !sheet) return;
let startY = 0;
let pulling = false;
let ptrId = null;
const resetVisual = () => {
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
};
zone.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
pulling = true;
ptrId = e.pointerId;
startY = e.clientY;
sheet.style.transition = 'none';
zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = Math.max(0, e.clientY - startY);
sheet.style.transform = `translateY(${dy}px)`;
});
zone.addEventListener('pointerup', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
pulling = false;
ptrId = null;
try {
zone.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
if (dy > 56) {
closeFn();
return;
}
resetVisual();
});
zone.addEventListener('pointercancel', () => {
pulling = false;
ptrId = null;
resetVisual();
});
}
function renderDayContent(state) {
const sel = state.selected;
const heading = document.getElementById('planner-day-heading');
if (heading) {
const wd = WEEKDAYS_LONG[sel.getDay()];
heading.textContent = `${wd}, ${sel.getDate()} ${MONTHS_SHORT[sel.getMonth()]}`;
}
const dayPlan = getDayPlan(state.plans, sel);
const totals = sumDayNutrition(dayPlan);
const kcalEl = document.getElementById('planner-summary-kcal');
if (kcalEl) {
kcalEl.innerHTML = totals.mealCount === 0
? `— <span class="text-sm font-semibold text-gray-500">kcal</span>`
: `${totals.kcal} <span class="text-sm font-semibold text-gray-500">kcal</span>`;
}
const fmt = (n) => `${n} g`;
document.getElementById('planner-macro-p').textContent = totals.mealCount ? fmt(totals.protein) : '—';
document.getElementById('planner-macro-f').textContent = totals.mealCount ? fmt(totals.fat) : '—';
document.getElementById('planner-macro-c').textContent = totals.mealCount ? fmt(totals.carbs) : '—';
document.getElementById('planner-detail-kcal').textContent = `${totals.kcal} kcal`;
document.getElementById('planner-detail-p').textContent = fmt(totals.protein);
document.getElementById('planner-detail-f').textContent = fmt(totals.fat);
document.getElementById('planner-detail-c').textContent = fmt(totals.carbs);
const ingBtn = document.getElementById('planner-open-ingredients');
if (ingBtn) {
ingBtn.disabled = totals.mealCount === 0;
ingBtn.classList.toggle('opacity-50', totals.mealCount === 0);
ingBtn.classList.toggle('cursor-not-allowed', totals.mealCount === 0);
}
const slotsRoot = document.getElementById('planner-meal-slots');
if (!slotsRoot) return;
slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => {
const entries = Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : [];
const countLabel = entries.length > 1
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0 ml-auto">${entries.length} dania</span>`
: '';
const entryCards = entries.map((entry) => {
const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null;
if (!recipe) return '';
const servings = Math.max(1, Number(entry.servings) || 1);
const n = recipe.nutritionPerServing;
const kcal = Math.round(n.kcal * servings);
const eid = escapeHtml(entry.id);
return `
<div class="rounded-lg border border-gray-200 bg-white p-2.5 shadow-sm" data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="w-9 h-9 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
<span class="text-white text-[9px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>
</div>
<div class="min-w-0">
<p class="text-sm font-bold text-gray-900 truncate">${escapeHtml(recipe.title)}</p>
<p class="text-[11px] text-gray-500 mt-0.5 tabular-nums">
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-gray-300">·</span>
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${kcal} kcal
</p>
</div>
</div>
<button type="button" class="planner-clear-meal w-7 h-7 shrink-0 rounded-full border border-gray-200 text-gray-400 hover:text-red-600 hover:border-red-200 hover:bg-red-50 transition-colors flex items-center justify-center" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Usuń ten przepis">
<i class="fas fa-times text-[10px]" aria-hidden="true"></i>
</button>
</div>
<div class="flex items-center justify-between gap-2 mt-2 pt-2 border-t border-gray-100">
<span class="text-[11px] font-medium text-gray-500">Porcje</span>
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
<button type="button" class="planner-serv-minus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Mniej porcji"><i class="fas fa-minus text-[9px]"></i></button>
<span class="planner-serv-count font-bold text-gray-900 text-xs w-5 text-center tabular-nums">${servings}</span>
<button type="button" class="planner-serv-plus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Więcej porcji"><i class="fas fa-plus text-[9px]"></i></button>
</div>
</div>
</div>`;
}).join('');
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny przepis';
const addClasses = entries.length === 0
? 'planner-add-meal w-full py-2.5 rounded-lg border border-dashed border-gray-200 text-sm font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors'
: 'planner-add-meal w-full py-2 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors';
return `
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-gray-100 bg-gray-50/90">
<span class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 shrink-0">
<i class="fas ${slot.icon} text-sm" aria-hidden="true"></i>
</span>
<span class="text-sm font-semibold text-gray-900 truncate min-w-0 flex-1">${slot.label}</span>
${countLabel}
</div>
<div class="p-3 space-y-2">
${entryCards}
<button type="button" class="${addClasses}" data-slot-id="${slot.id}">
<i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>
${addLabel}
</button>
</div>
</div>`;
}).join('');
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderPickerList(slotId) {
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
const list = document.getElementById('planner-picker-list');
const title = document.getElementById('planner-picker-title');
const sub = document.getElementById('planner-picker-sub');
if (!list || !title || !sub) return;
title.textContent = 'Wybierz przepis';
sub.textContent = slot ? `Dla: ${slot.label}. Przeciągnij nagłówek w dół lub dotknij tła, by zamknąć.` : '';
const recipes = recipesForSlot(slotId);
if (recipes.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak dopasowanych przepisów.</p>';
return;
}
list.innerHTML = recipes.map((r) => `
<button type="button" class="planner-pick-recipe w-full flex gap-3 p-3 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
<div class="w-14 h-14 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
<span class="text-white text-[10px] font-medium">${escapeHtml(r.thumbLabel)}</span>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-bold text-gray-900 line-clamp-2">${escapeHtml(r.title)}</p>
<p class="text-[11px] text-gray-500 mt-1 tabular-nums">
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${r.nutritionPerServing.kcal} kcal
<span class="mx-1 text-gray-300">·</span>
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${r.minutes} min
</p>
</div>
</button>
`).join('');
}
function renderIngredientsSheet(state) {
const body = document.getElementById('planner-ing-body');
if (!body) return;
const dayPlan = getDayPlan(state.plans, state.selected);
const blocks = aggregateDayIngredientsBySlot(dayPlan);
if (blocks.length === 0) {
body.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Najpierw zaplanuj posiłki.</p>';
return;
}
body.innerHTML = blocks.map((block) => `
<div class="mb-6 last:mb-2">
<p class="text-xs font-bold text-gray-400 uppercase tracking-wide mb-2">${escapeHtml(block.mealLabel)}</p>
<div class="space-y-3">
${block.recipes.map((rec) => `
<div>
<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">
<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>
</li>
`).join('')}
</ul>
</div>
`).join('')}
</div>
</div>
`).join('');
}
function formatAmount(n) {
return Number.isInteger(n) ? String(n) : String(n);
}
function seedDemoIfEmpty(plans) {
const todayKey = dateKey(new Date());
if (Object.keys(plans).length > 0) return plans;
return {
...plans,
[todayKey]: {
sniadanie: [{ id: newPlanEntryId(), recipeId: 'owsianka', servings: 1 }],
obiad: [{ id: newPlanEntryId(), recipeId: 'salatka', servings: 1 }],
kolacja: [{ id: newPlanEntryId(), recipeId: 'makaron', servings: 1 }],
},
};
}
export function setupMealPlanner() {
let plans = loadPlans();
plans = seedDemoIfEmpty(plans);
savePlans(plans);
const state = {
mode: 'week',
weekStart: startOfWeekMonday(new Date()),
monthAnchor: startOfDay(new Date()),
selected: startOfDay(new Date()),
plans,
nutritionExpanded: false,
pickerSlot: null,
};
const weekGrid = document.getElementById('calendar-week-grid');
const monthGrid = document.getElementById('calendar-month-grid');
const pickerBackdrop = document.getElementById('planner-picker-backdrop');
const pickerSheet = document.getElementById('planner-picker-sheet');
const ingBackdrop = document.getElementById('planner-ing-backdrop');
const ingSheet = document.getElementById('planner-ing-sheet');
const rerender = () => {
syncModeToggle(state.mode);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
if (state.mode === 'week') {
renderWeekGrid(state.weekStart, state.selected, state.plans);
} else {
renderMonthGrid(state.monthAnchor, state.selected, state.plans);
}
renderDayContent(state);
};
const persist = () => {
savePlans(state.plans);
rerender();
};
bindDayClicks(weekGrid?.parentElement, state, rerender);
bindDayClicks(monthGrid?.parentElement, state, rerender);
document.getElementById('planner-cal-mode')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-cal-mode]');
if (!btn) return;
const mode = btn.getAttribute('data-cal-mode');
if (mode !== 'week' && mode !== 'month') return;
state.mode = mode;
if (mode === 'week') {
state.weekStart = startOfWeekMonday(state.selected);
} else {
state.monthAnchor = startOfMonth(state.selected);
}
rerender();
});
document.getElementById('cal-prev')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, -1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, -1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-next')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
state.selected = today;
state.weekStart = startOfWeekMonday(today);
state.monthAnchor = startOfMonth(today);
rerender();
});
document.getElementById('planner-toggle-nutrition')?.addEventListener('click', () => {
state.nutritionExpanded = !state.nutritionExpanded;
const details = document.getElementById('planner-nutrition-details');
const chev = document.getElementById('planner-nutrition-chevron');
const btn = document.getElementById('planner-toggle-nutrition');
if (details) details.classList.toggle('hidden', !state.nutritionExpanded);
if (chev) chev.classList.toggle('rotate-180', state.nutritionExpanded);
if (btn) btn.setAttribute('aria-expanded', state.nutritionExpanded ? 'true' : 'false');
});
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
const addBtn = e.target.closest('.planner-add-meal');
if (addBtn) {
const slotId = addBtn.getAttribute('data-slot-id');
state.pickerSlot = slotId;
renderPickerList(slotId);
openSheet(pickerBackdrop, pickerSheet);
return;
}
const clearBtn = e.target.closest('.planner-clear-meal');
if (clearBtn) {
const slotId = clearBtn.getAttribute('data-slot-id');
const entryId = clearBtn.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const arr = state.plans[key]?.[slotId];
if (!Array.isArray(arr) || !entryId) return;
const next = arr.filter((x) => x && x.id !== entryId);
if (!state.plans[key]) state.plans[key] = {};
if (next.length === 0) delete state.plans[key][slotId];
else state.plans[key][slotId] = next;
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
persist();
return;
}
const minus = e.target.closest('.planner-serv-minus');
const plus = e.target.closest('.planner-serv-plus');
const slotId = (minus || plus)?.getAttribute('data-slot-id');
const entryId = (minus || plus)?.getAttribute('data-entry-id');
if (!slotId || !entryId) return;
const key = dateKey(state.selected);
const arr = state.plans[key]?.[slotId];
if (!Array.isArray(arr)) return;
const entry = arr.find((x) => x && x.id === entryId);
if (!entry) return;
let s = Math.max(1, Number(entry.servings) || 1);
if (minus) s = Math.max(1, s - 1);
if (plus) s = Math.min(12, s + 1);
entry.servings = s;
persist();
});
const closePicker = () => {
state.pickerSlot = null;
closeSheet(pickerBackdrop, pickerSheet);
};
bindPlannerSheetDragClose(pickerSheet, closePicker);
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
pickerBackdrop?.addEventListener('click', closePicker);
document.getElementById('planner-picker-list')?.addEventListener('click', (e) => {
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;
const key = dateKey(state.selected);
if (!state.plans[key]) state.plans[key] = {};
const slotId = state.pickerSlot;
if (!state.plans[key][slotId]) state.plans[key][slotId] = [];
state.plans[key][slotId].push({ id: newPlanEntryId(), recipeId, servings: 1 });
closePicker();
persist();
});
document.getElementById('planner-open-ingredients')?.addEventListener('click', () => {
if (sumDayNutrition(getDayPlan(state.plans, state.selected)).mealCount === 0) return;
renderIngredientsSheet(state);
openSheet(ingBackdrop, ingSheet);
});
ingBackdrop?.addEventListener('click', () => {
closeSheet(ingBackdrop, ingSheet);
});
ingSheet?.addEventListener('click', (e) => {
const row = e.target.closest('.planner-ing-row');
if (!row || !ingSheet.contains(row)) return;
row.classList.toggle('ingredient-active');
});
document.getElementById('planner-ing-add-all')?.addEventListener('click', () => {
const body = document.getElementById('planner-ing-body');
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).`);
closeSheet(ingBackdrop, ingSheet);
});
document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => {
const body = document.getElementById('planner-ing-body');
const selected = body?.querySelectorAll('.planner-ing-row.ingredient-active');
const n = selected?.length ?? 0;
if (n === 0) {
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).`);
closeSheet(ingBackdrop, ingSheet);
});
rerender();
}