diff --git a/stacks/recipe/js/views/MealPlanner.js b/stacks/recipe/js/views/MealPlanner.js
index 96ea896..3fb41c8 100644
--- a/stacks/recipe/js/views/MealPlanner.js
+++ b/stacks/recipe/js/views/MealPlanner.js
@@ -3,6 +3,146 @@ const MONTHS_SHORT = [
'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);
@@ -56,6 +196,159 @@ 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;
@@ -75,7 +368,7 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
export function getMealPlannerHTML() {
return `
-
+
-
-
Dotknij dnia w kalendarzu, aby później przypisać posiłki. Ta sekcja na razie jest zapowiedzią rozbudowy planera.
+
+
+
+
+
+
+
+
+
+
`;
}
-function renderWeekGrid(weekStart, selected) {
+function renderWeekGrid(weekStart, selected, plans) {
const grid = document.getElementById('calendar-week-grid');
if (!grid) return;
@@ -132,19 +500,21 @@ function renderWeekGrid(weekStart, selected) {
const day = addDays(weekStart, i);
const isSel = selected && sameDay(day, selected);
const isToday = sameDay(day, new Date());
+ const hasMeals = dayHasAnyMeal(plans, day);
cells.push(`
`);
}
grid.innerHTML = cells.join('');
}
-function renderMonthGrid(monthAnchor, selected) {
+function renderMonthGrid(monthAnchor, selected, plans) {
const grid = document.getElementById('calendar-month-grid');
if (!grid) return;
@@ -156,12 +526,14 @@ function renderMonthGrid(monthAnchor, selected) {
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(`
`);
}
@@ -226,26 +598,328 @@ function bindDayClicks(container, state, 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
+ ? `—
kcal`
+ : `${totals.kcal}
kcal`;
+ }
+ 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
+ ? `
${entries.length} dania`
+ : '';
+
+ 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 `
+
+
+
+
+ ${escapeHtml(recipe.thumbLabel)}
+
+
+
${escapeHtml(recipe.title)}
+
+ ${recipe.minutes} min
+ ·
+ ${kcal} kcal
+
+
+
+
+
+
+
Porcje
+
+
+ ${servings}
+
+
+
+
`;
+ }).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 `
+
+
+
+
+
+ ${slot.label}
+ ${countLabel}
+
+
+ ${entryCards}
+
+
+
`;
+ }).join('');
+}
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+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 = '
Brak dopasowanych przepisów.
';
+ return;
+ }
+
+ list.innerHTML = recipes.map((r) => `
+
+ `).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 = '
Najpierw zaplanuj posiłki.
';
+ return;
+ }
+
+ body.innerHTML = blocks.map((block) => `
+
+
${escapeHtml(block.mealLabel)}
+
+ ${block.recipes.map((rec) => `
+
+
${escapeHtml(rec.recipeTitle)}
+
+ ${rec.items.map((ing) => `
+ -
+
+ ${escapeHtml(ing.name)}
+ ${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}
+
+ `).join('')}
+
+
+ `).join('')}
+
+
+ `).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);
+ renderWeekGrid(state.weekStart, state.selected, state.plans);
} else {
- renderMonthGrid(state.monthAnchor, state.selected);
+ renderMonthGrid(state.monthAnchor, state.selected, state.plans);
}
+ renderDayContent(state);
+ };
+
+ const persist = () => {
+ savePlans(state.plans);
+ rerender();
};
bindDayClicks(weekGrid?.parentElement, state, rerender);
@@ -303,5 +977,117 @@ export function setupMealPlanner() {
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();
}