import { INGREDIENTS, 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 { computeFullForecast, countDayShortfalls, dayHasAnyMeal, sumDayNutrition, } from '../services/planIngredients.js'; import { addOrMergeShoppingLines, loadPantry } 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', ]; const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd']; const WEEKDAYS_LONG = [ 'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota', ]; const PLANNER_SHEET_BOTTOM_INSET = '5.25rem'; const PLANNER_SHEET_MAX_HEIGHT = '70vh'; const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`; function recipesForSlot(slotId) { return Object.values(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 ` `; } 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(` `); } 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(` `); } 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 weekWrap = document.getElementById('calendar-week-wrap'); const monthWrap = document.getElementById('calendar-month-wrap'); const handleIcon = document.getElementById('calendar-handle-icon'); if (weekWrap) { weekWrap.style.maxHeight = mode === 'week' ? '10rem' : '0'; weekWrap.style.opacity = mode === 'week' ? '1' : '0'; } if (monthWrap) { monthWrap.style.maxHeight = mode === 'month' ? '25rem' : '0'; monthWrap.style.opacity = mode === 'month' ? '1' : '0'; } if (handleIcon) { handleIcon.className = mode === 'week' ? 'fas fa-chevron-down text-[8px] text-gray-300' : 'fas fa-chevron-up text-[8px] text-gray-300'; } } 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 bindCalendarSwipeGesture(state, rerender) { const zone = document.getElementById('calendar-swipe-zone'); if (!zone) return; let startY = 0; let ptrId = null; let moved = false; zone.addEventListener('pointerdown', (e) => { if (ptrId !== null) return; startY = e.clientY; ptrId = e.pointerId; moved = false; }); zone.addEventListener('pointermove', (e) => { if (e.pointerId !== ptrId) return; if (Math.abs(e.clientY - startY) > 10) moved = true; }); zone.addEventListener('pointerup', (e) => { if (e.pointerId !== ptrId) return; const dy = e.clientY - startY; ptrId = null; if (!moved || Math.abs(dy) < 30) return; let switched = false; if (state.mode === 'week' && dy > 30) { state.mode = 'month'; state.monthAnchor = startOfMonth(state.selected); switched = true; } else if (state.mode === 'month' && dy < -30) { state.mode = 'week'; state.weekStart = startOfWeekMonday(state.selected); switched = true; } if (switched) { zone.addEventListener('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }, { capture: true, once: true }); rerender(); } }); zone.addEventListener('pointercancel', () => { ptrId = null; moved = false; }); } 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.visibility = 'visible'; 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'); sheet.style.visibility = '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) { const noMeals = totals.mealCount === 0; ingBtn.disabled = noMeals; ingBtn.classList.toggle('opacity-50', noMeals); ingBtn.classList.toggle('cursor-not-allowed', noMeals); if (!noMeals) { const shortCount = countDayShortfalls(dayPlan, loadPantry()); if (shortCount > 0) { ingBtn.innerHTML = ` Składniki na ten dzień ${shortCount}`; } else { ingBtn.innerHTML = ` Składniki na ten dzień OK`; } } else { ingBtn.innerHTML = ` Składniki na ten dzień`; } } const slotsRoot = document.getElementById('planner-meal-slots'); if (!slotsRoot) return; const skipped = dayPlan._skipped || {}; slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => { const isSkipped = skipped[slot.id] === true; const entries = isSkipped ? [] : (Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : []); let slotKcal = 0; entries.forEach((entry) => { const r = entry?.recipeId ? RECIPES[entry.recipeId] : null; if (r) slotKcal += Math.round(r.nutritionPerServing.kcal * Math.max(1, Number(entry.servings) || 1)); }); const kcalBadge = slotKcal > 0 ? `${slotKcal} kcal` : ''; const countLabel = entries.length > 1 ? `${entries.length} dania` : ''; const entryCards = entries.map((entry) => { 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; const kcal = Math.round(n.kcal * servings); const eid = escapeHtml(entry.id); return `
${recipe.image ? `` : `${escapeHtml(recipe.thumbLabel)}`}

${escapeHtml(recipe.title)}

${recipe.minutes} min · ${kcal} kcal

Porcje
${servings}
`; }).join(''); if (isSkipped) { return `
${slot.label}
Pominięto
`; } const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny'; const addClasses = entries.length === 0 ? 'planner-add-meal flex-1 py-2 rounded-lg border border-dashed border-gray-200 text-[13px] font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors' : 'planner-add-meal w-full py-1.5 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'; const skipBtn = entries.length === 0 ? `` : ''; const bottomRow = entries.length === 0 ? `
${``}${skipBtn}
` : ``; return `
${slot.label} ${countLabel} ${kcalBadge}
${entryCards} ${bottomRow}
`; }).join(''); } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function getRecentRecipeIds(plans, limit = 5) { const seen = new Map(); const keys = Object.keys(plans).sort().reverse(); for (const key of keys) { const day = plans[key]; if (!day) continue; for (const slotId of Object.keys(day)) { if (slotId === '_skipped') continue; const entries = day[slotId]; if (!Array.isArray(entries)) continue; for (const e of entries) { if (e?.recipeId && RECIPES[e.recipeId] && !seen.has(e.recipeId)) { seen.set(e.recipeId, true); if (seen.size >= limit) return [...seen.keys()]; } } } } return [...seen.keys()]; } function recipeCardHtml(r) { return ` `; } let _pickerSlotRecipes = []; let _pickerPlans = {}; function renderPickerList(slotId, plans, query = '') { 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}` : ''; const allRecipes = recipesForSlot(slotId); _pickerSlotRecipes = allRecipes; _pickerPlans = plans; const q = query.trim().toLowerCase(); const filtered = q ? allRecipes.filter((r) => r.title.toLowerCase().includes(q) || (r.tags || []).some((t) => t.toLowerCase().includes(q))) : allRecipes; if (filtered.length === 0 && q) { list.innerHTML = '

Brak wyników.

'; return; } if (filtered.length === 0) { list.innerHTML = '

Brak dopasowanych przepisów.

'; return; } let html = ''; if (!q) { const recentIds = getRecentRecipeIds(plans); const recentInSlot = recentIds.map((id) => RECIPES[id]).filter((r) => r && r.allowedSlots.includes(slotId)); if (recentInSlot.length > 0) { html += `

Ostatnio używane

`; html += recentInSlot.map(recipeCardHtml).join(''); html += `
`; html += `

Wszystkie

`; } } html += filtered.map(recipeCardHtml).join(''); list.innerHTML = html; } function plIngredientWord(n) { if (n === 1) return 'składnik'; const m10 = n % 10; const m100 = n % 100; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'składniki'; return 'składników'; } function updateIngButtons(state) { const btn1 = document.getElementById('planner-ing-add-all'); const btn2 = document.getElementById('planner-ing-add-btn'); const todayCount = (state._todayShortfalls || []).length; const allCount = (state._allForecastShortfalls || []).length; if (btn1) { if (todayCount > 0) { btn1.classList.remove('hidden'); btn1.disabled = false; btn1.innerHTML = ` Dodaj braki na dziś do listy`; } else { btn1.classList.add('hidden'); } } if (btn2) { if (allCount > todayCount) { btn2.classList.remove('hidden'); btn2.innerHTML = ` Dodaj braki na cały tydzień`; } else { btn2.classList.add('hidden'); } } } function renderIngredientsSheet(state) { const body = document.getElementById('planner-ing-body'); const titleEl = document.getElementById('planner-ing-title'); const subEl = document.getElementById('planner-ing-sub'); if (!body) return; const pantry = loadPantry(); const forecast = computeFullForecast(state.plans, pantry, state.selected); const today = forecast.length > 0 && forecast[0].dayIndex === 0 ? forecast[0] : null; const upcoming = forecast.filter((d) => d.dayIndex > 0 && d.hasShortfall); state._todayShortfalls = today ? today.items.filter((it) => !it.enough) : []; state._allForecastShortfalls = []; for (const d of forecast) { for (const it of d.items) { if (!it.enough) state._allForecastShortfalls.push(it); } } if (titleEl) { const wd = WEEKDAYS_LONG[state.selected.getDay()]; titleEl.textContent = `${wd}, ${state.selected.getDate()} ${MONTHS_SHORT[state.selected.getMonth()]} — składniki`; } if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.'; if (!today || today.items.length === 0) { body.innerHTML = '

Najpierw zaplanuj posiłki.

'; updateIngButtons(state); return; } const shortItems = today.items.filter((it) => !it.enough); const okItems = today.items.filter((it) => it.enough); let html = ''; if (shortItems.length === 0) { html += `

Wszystko masz w spiżarni

${today.items.length} ${plIngredientWord(today.items.length)} — zapasy wystarczą

`; } else { html += `

${shortItems.length} ${plIngredientWord(shortItems.length)} do kupienia

Brakuje składników na zaplanowane posiłki

`; } if (shortItems.length > 0) { html += `

Do kupienia

`; } if (okItems.length > 0) { html += `

W spiżarni

`; } if (upcoming.length > 0) { html += `

Nadchodzące braki

${upcoming.map((day) => { const wd = WEEKDAYS_LONG[day.date.getDay()]; const label = `${wd}, ${day.date.getDate()} ${MONTHS_SHORT[day.date.getMonth()]}`; const shorts = day.items.filter((it) => !it.enough); return `

${escapeHtml(label)}

    ${shorts.map((it) => `
  • ${escapeHtml(it.name)} −${formatAmount(it.shortfall)} ${escapeHtml(it.pantryUnit)}
  • `).join('')}
`; }).join('')}
`; } body.innerHTML = html; updateIngButtons(state); } 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: 'jajecznica', servings: 1 }], obiad: [{ id: newPlanEntryId(), recipeId: 'makaron_ricotta', servings: 1 }], kolacja: [{ id: newPlanEntryId(), recipeId: 'kanapka_losos', 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 copyBackdrop = document.getElementById('planner-copy-backdrop'); const copySheet = document.getElementById('planner-copy-sheet'); const rerender = () => { syncModeToggle(state.mode); updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor); syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected); renderWeekGrid(state.weekStart, state.selected, state.plans); 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('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 skipBtn = e.target.closest('.planner-skip-meal'); if (skipBtn) { const slotId = skipBtn.getAttribute('data-slot-id'); if (!slotId) return; const key = dateKey(state.selected); if (!state.plans[key]) state.plans[key] = {}; if (!state.plans[key]._skipped) state.plans[key]._skipped = {}; state.plans[key]._skipped[slotId] = true; persist(); return; } const unskipBtn = e.target.closest('.planner-unskip'); if (unskipBtn) { const slotId = unskipBtn.getAttribute('data-slot-id'); if (!slotId) return; const key = dateKey(state.selected); if (state.plans[key]?._skipped) { delete state.plans[key]._skipped[slotId]; if (Object.keys(state.plans[key]._skipped).length === 0) delete state.plans[key]._skipped; if (Object.keys(state.plans[key]).length === 0) delete state.plans[key]; } persist(); return; } const openRecipe = e.target.closest('.planner-open-recipe'); if (openRecipe) { const recipeId = openRecipe.getAttribute('data-recipe-id'); if (recipeId && window.openRecipeDetail) { window.openRecipeDetail(recipeId); } return; } const addBtn = e.target.closest('.planner-add-meal'); if (addBtn) { const slotId = addBtn.getAttribute('data-slot-id'); state.pickerSlot = slotId; const searchInput = document.getElementById('planner-picker-search'); if (searchInput) searchInput.value = ''; renderPickerList(slotId, state.plans); 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); }; document.getElementById('planner-picker-search')?.addEventListener('input', (e) => { if (state.pickerSlot) { renderPickerList(state.pickerSlot, state.plans, e.target.value); } }); 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 || !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); }); const closeCopy = () => closeSheet(copyBackdrop, copySheet); bindPlannerSheetDragClose(copySheet, closeCopy); copyBackdrop?.addEventListener('click', closeCopy); document.getElementById('planner-copy-day')?.addEventListener('click', () => { const srcKey = dateKey(state.selected); const srcPlan = state.plans[srcKey]; if (!srcPlan || Object.keys(srcPlan).length === 0) { showPlannerToast('Ten dzień jest pusty — nie ma co kopiować.'); return; } const copyList = document.getElementById('planner-copy-list'); if (!copyList) return; const days = []; for (let i = -3; i <= 10; i++) { if (i === 0) continue; const d = addDays(state.selected, i); days.push(d); } copyList.innerHTML = days.map((d) => { const wd = WEEKDAYS_LONG[d.getDay()]; const label = `${wd}, ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`; const hasMeals = dayHasAnyMeal(state.plans, d); const badge = hasMeals ? 'ma posiłki' : ''; return ``; }).join(''); openSheet(copyBackdrop, copySheet); }); document.getElementById('planner-copy-list')?.addEventListener('click', (e) => { const btn = e.target.closest('.planner-copy-target'); if (!btn) return; const targetDate = new Date(Number(btn.getAttribute('data-target-ts'))); const srcKey = dateKey(state.selected); const tgtKey = dateKey(targetDate); const srcPlan = state.plans[srcKey]; if (!srcPlan) return; const copy = {}; for (const [slotId, entries] of Object.entries(srcPlan)) { if (slotId === '_skipped') { copy._skipped = { ...entries }; continue; } if (Array.isArray(entries)) { copy[slotId] = entries.map((e) => ({ ...e, id: newPlanEntryId() })); } } state.plans[tgtKey] = copy; closeCopy(); persist(); showPlannerToast('Plan skopiowany!'); }); 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 items = state._todayShortfalls || []; if (items.length === 0) return; const lines = items.map((it) => ({ ingredientId: it.ingredientId, amount: it.shortfall, unit: it.pantryUnit, category: it.category, sourceNote: 'Braki z planu dnia', })); addOrMergeShoppingLines(lines); showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`); window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => { const items = state._allForecastShortfalls || []; if (items.length === 0) return; const map = new Map(); for (const it of items) { const key = it.ingredientId; if (map.has(key)) { const cur = map.get(key); cur.amount = Math.round((cur.amount + it.shortfall) * 10) / 10; } else { map.set(key, { ingredientId: it.ingredientId, amount: it.shortfall, unit: it.pantryUnit, category: it.category, sourceNote: 'Braki z planu tygodnia', }); } } const lines = [...map.values()]; addOrMergeShoppingLines(lines); showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`); window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); rerender(); window.refreshPlanner = () => { state.plans = loadPlans(); rerender(); }; bindCalendarSwipeGesture(state, rerender); requestAnimationFrame(() => { const ww = document.getElementById('calendar-week-wrap'); const mw = document.getElementById('calendar-month-wrap'); const t = 'max-height 300ms ease, opacity 200ms ease'; if (ww) ww.style.transition = t; if (mw) mw.style.transition = t; }); }