import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { sameDay, startOfDay, startOfMonth, startOfWeekMonday, } from '../services/dateUtils.js'; import { computeEntryNutrition, computeFullForecast, countDayShortfalls, dayHasAnyMeal, sumDayNutrition, } from '../services/planIngredients.js?v=4'; import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js?v=2'; import { dateKey, getDayPlan, loadPlans, newPlanEntryId, savePlans, } from '../services/planStore.js?v=2'; import { CALENDAR_MONTHS_SHORT, bindCollapsibleCalendarSwipeGesture, bindCalendarDayClicks, createCollapsibleCalendarHTML, createCalendarTopbarHTML, formatCalendarPeriodLabel, isCalendarOnToday, renderCollapsibleCalendar, syncCalendarTodayButton, syncCollapsibleCalendarMode, syncCollapsibleCalendarToggleIcon, } from '../ui/mealCalendar.js?v=15'; import { filterRecipesByQuery, renderRecipeGrid, } from '../ui/recipeGrid.js'; import { getRecipeSearchFieldHTML } from '../ui/recipeSearchField.js'; 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}))`; const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)'; const PLANNER_MEAL_SWIPE_MAX = 132; const PLANNER_MEAL_SWIPE_DELETE_THRESHOLD = 96; const PLANNER_MEAL_PENDING_DELETE_MS = 2000; const PLANNER_MEAL_PENDING_DELETE_TICK_MS = 100; const PICKER_FILTER_MIN_MINUTES = 5; const PICKER_FILTER_MAX_MINUTES = 120; function recipesForSlot(slotId) { return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId)); } function syncTodayButton(mode, weekStart, monthAnchor, selected) { syncCalendarTodayButton( document.getElementById('cal-go-today'), isCalendarOnToday(mode, weekStart, monthAnchor, selected), selected, { labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor), }, ); } export function getMealPlannerHTML() { return `
`; } function syncModeToggle(mode) { syncCollapsibleCalendarMode({ mode, weekWrapEl: document.getElementById('calendar-week-wrap'), monthWrapEl: document.getElementById('calendar-month-wrap'), }); syncCollapsibleCalendarToggleIcon(document.getElementById('calendar-handle-icon'), mode); } 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 getSheetOffTransform(sheet) { return sheet?.dataset.offTransform || PLANNER_SHEET_OFF_TRANSFORM; } function closeSheet(backdrop, sheet) { if (!backdrop || !sheet) return; sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; sheet.style.transform = getSheetOffTransform(sheet); backdrop.classList.add('opacity-0'); setTimeout(() => { backdrop.classList.add('hidden'); sheet.style.visibility = 'hidden'; }, 300); } /** Zamykanie panelu: przeciągnięcie w dół (pointer). */ function bindPlannerSheetDragClose(sheet, closeFn, options = {}) { const zone = options.dragSurface || sheet.querySelector('[data-planner-sheet-drag-zone]'); const scrollEl = options.scrollEl || null; if (!zone || !sheet) return; let startX = 0; let startY = 0; let ptrId = null; let engaged = false; let startFromHandle = false; let suppressClickUntil = 0; const resetVisual = () => { sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; sheet.style.transform = 'translateY(0)'; }; const stopTracking = () => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerCancel); ptrId = null; engaged = false; startFromHandle = false; }; const shouldIgnoreTarget = (target) => target instanceof Element && Boolean(target.closest('input, textarea, select, [contenteditable="true"]')); zone.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; if (shouldIgnoreTarget(e.target)) return; if (scrollEl && scrollEl.scrollTop > 0 && !(e.target instanceof Element && e.target.closest('[data-planner-sheet-drag-zone]'))) { return; } ptrId = e.pointerId; startX = e.clientX; startY = e.clientY; engaged = false; startFromHandle = e.target instanceof Element && Boolean(e.target.closest('[data-planner-sheet-drag-zone]')); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); }); const onPointerMove = (e) => { if (e.pointerId !== ptrId) return; const dx = e.clientX - startX; const rawDy = e.clientY - startY; if (!engaged) { if (Math.abs(dx) < 8 && Math.abs(rawDy) < 8) return; if (rawDy <= 0 || Math.abs(dx) > Math.abs(rawDy)) { stopTracking(); resetVisual(); return; } if (!startFromHandle && scrollEl && scrollEl.scrollTop > 0) { stopTracking(); resetVisual(); return; } engaged = true; suppressClickUntil = Date.now() + 250; sheet.style.transition = 'none'; } const dy = Math.max(0, rawDy); sheet.style.transform = `translateY(${dy}px)`; if (e.cancelable) e.preventDefault(); }; const onPointerUp = (e) => { if (e.pointerId !== ptrId) return; const dy = e.clientY - startY; const shouldClose = engaged && dy > 56; stopTracking(); if (shouldClose) { closeFn(); return; } resetVisual(); }; const onPointerCancel = (e) => { if (ptrId !== null && e.pointerId !== ptrId) return; stopTracking(); resetVisual(); }; zone.addEventListener('click', (e) => { if (Date.now() < suppressClickUntil) { e.preventDefault(); e.stopPropagation(); } }, true); zone.addEventListener('pointercancel', (e) => { onPointerCancel(e); }); } function renderDayContent(state, onMealRemoved = null) { const sel = state.selected; syncPendingMealRemovals(state); const selectedDayKey = dateKey(sel); const dayPlan = getDayPlan(state.plans, sel); const totals = sumDayNutrition(dayPlan); const setText = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; }; const setGrams = (id, value) => { const el = document.getElementById(id); if (!el) return; el.textContent = value === null ? '—' : `${value}g`; }; const hasMeals = totals.mealCount > 0; setText('planner-nutrition-kcal', hasMeals ? String(totals.kcal) : '—'); setGrams('planner-nutrition-p', hasMeals ? totals.protein : null); setGrams('planner-nutrition-f', hasMeals ? totals.fat : null); setGrams('planner-nutrition-c', hasMeals ? totals.carbs : null); 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) => { if (entry?.recipeId && RECIPES[entry.recipeId]) slotKcal += computeEntryNutrition(entry).kcal; }); 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 entryN = computeEntryNutrition(entry); const eid = escapeHtml(entry.id); const isPendingDelete = Boolean(getPendingMealRemoval(state, selectedDayKey, slot.id, entry.id)); const hasCustom = (entry.excludedIngredients?.length > 0) || (entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) || (entry.addedIngredients?.length > 0) || (entry.substitutions && Object.keys(entry.substitutions).length > 0); const customDot = hasCustom ? '' : ''; const servLabel = servings > 1 ? `·×${servings}` : ''; const rowStyle = `--planner-swipe-progress:${isPendingDelete ? '1' : '0'};`; const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : ''; const backgroundStyle = isPendingDelete ? 'background:linear-gradient(90deg, rgba(var(--border-card-rgb),0.08), rgba(var(--border-card-rgb),0.2));' : 'background:rgba(var(--danger-rgb), calc(0.18 + var(--planner-swipe-progress) * 0.5));'; const backgroundLabel = isPendingDelete ? ` Usuwanie ` : ` Usuń `; const titleClass = isPendingDelete ? 'text-[13px] font-normal text-[rgb(var(--text-muted-rgb))] truncate' : 'text-[13px] font-normal text-[rgb(var(--text-body-rgb))] truncate'; const metaClass = isPendingDelete ? 'text-[11px] text-[rgb(var(--text-faint-rgb))] mt-0.5 tabular-nums' : 'text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5 tabular-nums'; const actionWrapClass = isPendingDelete ? 'relative z-[2] flex items-center shrink-0 self-center' : 'flex items-center gap-1 shrink-0 self-center'; const contentToneStyle = isPendingDelete ? 'opacity:0.48; filter:saturate(0.72); transform:scale(0.985); transform-origin:left center; transition:opacity 180ms ease, transform 180ms ease, filter 180ms ease;' : ''; const remainingProgress = isPendingDelete ? getPendingMealRemovalProgress(state, selectedDayKey, slot.id, entry.id) : 0; const entryAction = isPendingDelete ? `` : ``; return `
${backgroundLabel}
${recipe.image ? `` : `${escapeHtml(recipe.thumbLabel)}`}

${escapeHtml(recipe.title)}

${customDot}

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

${entryAction}
`; }).join(''); const addBtn = ``; const kcalPill = slotKcal > 0 ? `${slotKcal} kcal` : ''; const filledCard = `
${slot.label} ${kcalPill} ${addBtn}
${entries.length > 0 ? `
${entryCards}
` : ''}
`; if (entries.length > 0) return filledCard; return `
${slot.label} ${addBtn}

Zaplanuj posiłek

`; }).join(''); bindMealEntrySwipe(state, onMealRemoved); syncPendingMealRemovalTicker(state); } function getPendingMealRemovalKey(dayKey, slotId, entryId) { return `${dayKey}::${slotId}::${entryId}`; } function getPendingMealRemoval(state, dayKey, slotId, entryId) { if (!(state.pendingMealRemovals instanceof Map)) return null; return state.pendingMealRemovals.get(getPendingMealRemovalKey(dayKey, slotId, entryId)) || null; } function getPendingMealRemovalProgress(state, dayKey, slotId, entryId) { const pending = getPendingMealRemoval(state, dayKey, slotId, entryId); if (!pending) return 0; return Math.max(0, Math.min(1, (pending.expiresAt - Date.now()) / PLANNER_MEAL_PENDING_DELETE_MS)); } function getPendingMealRemovalButtonStyle(progress) { const clamped = Math.max(0, Math.min(1, progress)); const angle = Math.round(clamped * 360); return `conic-gradient(from -90deg, rgba(var(--text-body-rgb),0.96) 0deg, rgba(var(--text-body-rgb),0.96) ${angle}deg, rgba(var(--text-subdued-rgb),0.24) ${angle}deg, rgba(var(--text-subdued-rgb),0.24) 360deg)`; } function stopPendingMealRemovalTicker(state) { if (state.pendingMealRemovalTickerId) { clearInterval(state.pendingMealRemovalTickerId); state.pendingMealRemovalTickerId = null; } } function refreshPendingMealRemovalButtons(state) { const buttons = document.querySelectorAll('[data-pending-delete-progress]'); buttons.forEach((button) => { const dayKey = button.getAttribute('data-day-key'); const slotId = button.getAttribute('data-slot-id'); const entryId = button.getAttribute('data-entry-id'); if (!dayKey || !slotId || !entryId) return; const progress = getPendingMealRemovalProgress(state, dayKey, slotId, entryId); button.style.background = getPendingMealRemovalButtonStyle(progress); }); } function syncPendingMealRemovalTicker(state) { const hasPending = state.pendingMealRemovals instanceof Map && state.pendingMealRemovals.size > 0; if (!hasPending) { stopPendingMealRemovalTicker(state); return; } refreshPendingMealRemovalButtons(state); if (state.pendingMealRemovalTickerId) return; state.pendingMealRemovalTickerId = window.setInterval(() => { syncPendingMealRemovals(state); refreshPendingMealRemovalButtons(state); if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) { stopPendingMealRemovalTicker(state); } }, PLANNER_MEAL_PENDING_DELETE_TICK_MS); } function clearPendingMealRemoval(state, pendingKey) { if (!(state.pendingMealRemovals instanceof Map)) return false; const pending = state.pendingMealRemovals.get(pendingKey); if (!pending) return false; clearTimeout(pending.timeoutId); state.pendingMealRemovals.delete(pendingKey); if (state.pendingMealRemovals.size === 0) stopPendingMealRemovalTicker(state); return true; } function syncPendingMealRemovals(state) { if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) return; for (const [pendingKey, pending] of state.pendingMealRemovals.entries()) { const arr = state.plans[pending.dayKey]?.[pending.slotId]; const stillExists = Array.isArray(arr) && arr.some((entry) => entry && entry.id === pending.entryId); if (!stillExists) clearPendingMealRemoval(state, pendingKey); } } function queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved = null) { if (!dayKey || !slotId || !entryId) return false; if (!(state.pendingMealRemovals instanceof Map)) state.pendingMealRemovals = new Map(); const pendingKey = getPendingMealRemovalKey(dayKey, slotId, entryId); if (state.pendingMealRemovals.has(pendingKey)) return false; const timeoutId = window.setTimeout(() => { state.pendingMealRemovals.delete(pendingKey); const removed = removeMealEntry(state, dayKey, slotId, entryId); if (typeof onMealRemoved === 'function') onMealRemoved(); else { if (removed) savePlans(state.plans); renderDayContent(state); } if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) { stopPendingMealRemovalTicker(state); } }, PLANNER_MEAL_PENDING_DELETE_MS); state.pendingMealRemovals.set(pendingKey, { dayKey, slotId, entryId, expiresAt: Date.now() + PLANNER_MEAL_PENDING_DELETE_MS, timeoutId, }); return true; } function cancelQueuedMealEntryRemoval(state, dayKey, slotId, entryId) { if (!dayKey || !slotId || !entryId) return false; return clearPendingMealRemoval(state, getPendingMealRemovalKey(dayKey, slotId, entryId)); } function removeMealEntry(state, dayKey, slotId, entryId) { const day = state.plans[dayKey]; const arr = day?.[slotId]; if (!Array.isArray(arr) || !entryId) return false; const next = arr.filter((x) => x && x.id !== entryId); if (next.length === arr.length) return false; if (next.length === 0) delete day[slotId]; else day[slotId] = next; if (Object.keys(day).length === 0) delete state.plans[dayKey]; return true; } function bindMealEntrySwipe(state, onMealRemoved = null) { const cards = document.querySelectorAll('[data-planner-swipe-card]'); cards.forEach((card) => { const row = card.closest('[data-planner-swipe-row]'); if (!row) return; let pointerId = null; let startX = 0; let startY = 0; let dx = 0; let engaged = false; let suppressClickUntil = 0; const resetVisual = () => { row.classList.remove('planner-meal-row-swiping'); row.style.setProperty('--planner-swipe-progress', '0'); row.style.pointerEvents = ''; card.style.willChange = ''; card.style.transition = 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease'; card.style.transform = 'translateX(0)'; card.style.opacity = '1'; }; const stopTracking = () => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerCancel); pointerId = null; }; const onPointerMove = (e) => { if (e.pointerId !== pointerId) return; dx = e.clientX - startX; const dy = e.clientY - startY; if (!engaged) { if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; suppressClickUntil = Date.now() + 250; if (Math.abs(dy) > Math.abs(dx) || dx >= 0) { stopTracking(); resetVisual(); return; } engaged = true; row.classList.add('planner-meal-row-swiping'); card.style.transition = 'none'; card.style.willChange = 'transform'; } const translateX = Math.max(-PLANNER_MEAL_SWIPE_MAX, Math.min(0, dx)); const progress = Math.min(1, Math.abs(translateX) / PLANNER_MEAL_SWIPE_DELETE_THRESHOLD); row.style.setProperty('--planner-swipe-progress', String(progress)); card.style.transform = `translateX(${translateX}px)`; if (e.cancelable) e.preventDefault(); }; const onPointerUp = (e) => { if (e.pointerId !== pointerId) return; const shouldDelete = engaged && dx <= -PLANNER_MEAL_SWIPE_DELETE_THRESHOLD; stopTracking(); if (shouldDelete) { const dayKey = dateKey(state.selected); const slotId = card.getAttribute('data-slot-id'); const entryId = card.getAttribute('data-entry-id'); resetVisual(); if (queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved)) { renderDayContent(state, onMealRemoved); } return; } engaged = false; dx = 0; resetVisual(); }; const onPointerCancel = (e) => { if (pointerId !== null && e.pointerId !== pointerId) return; stopTracking(); engaged = false; dx = 0; resetVisual(); }; card.addEventListener('click', (e) => { if (Date.now() < suppressClickUntil) { e.preventDefault(); e.stopPropagation(); } }, true); card.addEventListener('pointerdown', (e) => { if (pointerId !== null) return; if (e.pointerType === 'mouse' && e.button !== 0) return; if (row.getAttribute('data-pending-delete') === 'true') return; pointerId = e.pointerId; startX = e.clientX; startY = e.clientY; dx = 0; engaged = false; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); }); }); } 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 sortPickerRecipes(recipes, plans) { const recentIndex = new Map( getRecentRecipeIds(plans, 12).map((recipeId, index) => [recipeId, index]), ); return [...recipes].sort((a, b) => { const aRecent = recentIndex.has(a.id) ? recentIndex.get(a.id) : Number.POSITIVE_INFINITY; const bRecent = recentIndex.has(b.id) ? recentIndex.get(b.id) : Number.POSITIVE_INFINITY; if (aRecent !== bRecent) return aRecent - bRecent; return a.title.localeCompare(b.title, 'pl'); }); } function matchesPickerFilters(recipe, filterState) { const slots = Array.isArray(filterState?.slots) ? filterState.slots : []; const tags = Array.isArray(filterState?.tags) ? filterState.tags : []; const minMinutes = Number.isFinite(filterState?.minMinutes) ? filterState.minMinutes : PICKER_FILTER_MIN_MINUTES; const maxMinutes = Number.isFinite(filterState?.maxMinutes) ? filterState.maxMinutes : PICKER_FILTER_MAX_MINUTES; if (slots.length > 0 && !recipe.allowedSlots.some((slotId) => slots.includes(slotId))) return false; if (tags.length > 0) { const recipeTags = (recipe.tags || []).map((tag) => tag.toLowerCase()); if (!tags.some((tag) => recipeTags.includes(tag.toLowerCase()))) return false; } if (minMinutes > PICKER_FILTER_MIN_MINUTES && recipe.minutes < minMinutes) return false; if (maxMinutes < PICKER_FILTER_MAX_MINUTES && recipe.minutes > maxMinutes) return false; return true; } function renderPickerGrid(slotId, plans, query = '') { const grid = document.getElementById('planner-picker-grid'); const emptyState = document.getElementById('planner-picker-empty-state'); if (!grid || !emptyState) return; const pickerFilters = window.getPlannerPickerFilterState?.() || { slots: [], tags: [], minMinutes: PICKER_FILTER_MIN_MINUTES, maxMinutes: PICKER_FILTER_MAX_MINUTES, }; const filtered = filterRecipesByQuery(recipesForSlot(slotId), query) .filter((recipe) => matchesPickerFilters(recipe, pickerFilters)); const sorted = sortPickerRecipes(filtered, plans); renderRecipeGrid({ gridEl: grid, emptyStateEl: emptyState, recipes: sorted, showSlotLabels: false, cardClassName: 'recipe-list-card', }); } 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()} ${CALENDAR_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()} ${CALENDAR_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, pendingMealRemovals: new Map(), pendingMealRemovalTickerId: null, 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 pickerScroll = document.getElementById('planner-picker-scroll'); const ingBackdrop = document.getElementById('planner-ing-backdrop'); const ingSheet = document.getElementById('planner-ing-sheet'); const pickerFilterState = { slots: [], tags: [], minMinutes: PICKER_FILTER_MIN_MINUTES, maxMinutes: PICKER_FILTER_MAX_MINUTES, }; const resolveCalendarDayState = (day, meta) => { const today = startOfDay(new Date()); const isSelected = sameDay(day, state.selected); const isPast = day.getTime() < today.getTime(); return { disabled: isPast && !isSelected, dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected, showIndicator: meta.mode === 'month' ? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day) : dayHasAnyMeal(state.plans, day), }; }; const rerender = () => { syncModeToggle(state.mode); syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected); renderCollapsibleCalendar({ weekGridEl: weekGrid, monthGridEl: monthGrid, weekAnchorDate: state.weekStart, monthAnchorDate: state.monthAnchor, selectedDate: state.selected, resolveDayState: resolveCalendarDayState, }); renderDayContent(state, persist); }; const persist = () => { savePlans(state.plans); rerender(); }; /* ── calendar scroll shadow ─────────────────── */ const plannerScroll = document.getElementById('planner-scroll'); const calBar = document.getElementById('planner-cal-bar'); if (plannerScroll && calBar) { const shadow = document.createElement('div'); shadow.style.cssText = 'position:absolute;left:0;right:0;bottom:-8px;height:8px;background:linear-gradient(to bottom,rgba(var(--overlay-rgb),0.25),transparent);opacity:0;transition:opacity 0.2s;pointer-events:none;'; calBar.appendChild(shadow); plannerScroll.addEventListener('scroll', () => { shadow.style.opacity = plannerScroll.scrollTop > 2 ? '1' : '0'; }); } bindCalendarDayClicks(weekGrid, (date) => { state.selected = date; rerender(); }); bindCalendarDayClicks(monthGrid, (date) => { state.selected = date; 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-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 cancelPendingRemoveBtn = e.target.closest('.planner-cancel-pending-remove'); if (cancelPendingRemoveBtn) { const dayKey = cancelPendingRemoveBtn.getAttribute('data-day-key'); const slotId = cancelPendingRemoveBtn.getAttribute('data-slot-id'); const entryId = cancelPendingRemoveBtn.getAttribute('data-entry-id'); if (!dayKey || !slotId || !entryId) return; if (cancelQueuedMealEntryRemoval(state, dayKey, slotId, entryId)) { renderDayContent(state, persist); } 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 = ''; const pickerScroll = document.getElementById('planner-picker-scroll'); if (pickerScroll) pickerScroll.scrollTop = 0; renderPickerGrid(slotId, state.plans); openSheet(pickerBackdrop, pickerSheet); window.setTimeout(() => { if (pickerScroll) pickerScroll.scrollTop = 0; }, 40); return; } const editBtn = e.target.closest('.planner-edit-meal'); if (editBtn) { const slotId = editBtn.getAttribute('data-slot-id'); const entryId = editBtn.getAttribute('data-entry-id'); 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; window.openMealPlanEditor?.({ mode: 'edit', recipeId: entry.recipeId, date: state.selected, slotId, entry, }); return; } const openRecipe = e.target.closest('.planner-open-recipe'); if (openRecipe) { const recipeId = openRecipe.getAttribute('data-recipe-id'); if (recipeId && window.openRecipeDetail) { const slotId = openRecipe.closest('[data-slot-id]')?.getAttribute('data-slot-id'); const entryId = openRecipe.closest('[data-entry-id]')?.getAttribute('data-entry-id'); const key = dateKey(state.selected); const entries = slotId ? state.plans[key]?.[slotId] : null; const entry = Array.isArray(entries) ? entries.find((item) => item && item.id === entryId) : null; window.openRecipeDetail(recipeId, entry ? { plannedEntry: entry } : {}); } return; } }); const closePicker = () => { state.pickerSlot = null; const searchInput = document.getElementById('planner-picker-search'); if (searchInput) searchInput.value = ''; if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); closeSheet(pickerBackdrop, pickerSheet); }; window.getPlannerPickerFilterState = () => ({ ...pickerFilterState, slots: [...pickerFilterState.slots], tags: [...pickerFilterState.tags], }); window.applyPlannerPickerFilters = (nextState = {}) => { Object.assign(pickerFilterState, nextState); pickerFilterState.slots = Array.isArray(nextState.slots) ? [...nextState.slots] : [...pickerFilterState.slots]; pickerFilterState.tags = Array.isArray(nextState.tags) ? [...nextState.tags] : [...pickerFilterState.tags]; if (state.pickerSlot) { const searchValue = document.getElementById('planner-picker-search')?.value || ''; renderPickerGrid(state.pickerSlot, state.plans, searchValue); } }; document.getElementById('planner-picker-search')?.addEventListener('input', (e) => { if (state.pickerSlot) { renderPickerGrid(state.pickerSlot, state.plans, e.target.value); } }); bindPlannerSheetDragClose(pickerSheet, closePicker, { dragSurface: pickerSheet, scrollEl: pickerScroll, }); bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet)); pickerBackdrop?.addEventListener('click', closePicker); document.getElementById('planner-picker-grid')?.addEventListener('click', (e) => { const pick = e.target.closest('.recipe-browser-card'); if (!pick || !state.pickerSlot) return; const recipeId = pick.getAttribute('data-recipe-id'); if (!recipeId || !RECIPES[recipeId]) return; const slotId = state.pickerSlot; closePicker(); window.requestAnimationFrame(() => { window.openMealPlanEditor?.({ mode: 'add', recipeId, date: state.selected, slotId, }); }); }); 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 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(); }; bindCollapsibleCalendarSwipeGesture({ zoneEl: document.getElementById('calendar-swipe-zone'), weekWrapEl: document.getElementById('calendar-week-wrap'), monthWrapEl: document.getElementById('calendar-month-wrap'), getMode: () => state.mode, setMode: (mode) => { state.mode = mode; }, getWeekAnchor: () => state.weekStart, setWeekAnchor: (date) => { state.weekStart = startOfWeekMonday(date); }, getMonthAnchor: () => state.monthAnchor, setMonthAnchor: (date) => { state.monthAnchor = startOfMonth(date); }, getSelectedDate: () => state.selected, setSelectedDate: (date) => { state.selected = startOfDay(date); }, rerender, resolveDayState: resolveCalendarDayState, selectOnNavigateOutside: false, }); document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => { if (state.mode === 'week') { state.mode = 'month'; state.monthAnchor = startOfMonth(state.selected); } else { state.mode = 'week'; state.weekStart = startOfWeekMonday(state.selected); } 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; }); }