import { RECIPES } from '../data/catalog.js'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { addDays, addMonths, addWeeks, sameDay, sameMonth, startOfDay, startOfMonth, startOfWeekMonday, weekContains, } from '../services/dateUtils.js'; import { aggregateDayIngredientsBySlot, dayHasAnyMeal, sumDayNutrition, } from '../services/planIngredients.js'; import { addOrMergeShoppingLines } from '../services/pantryShopping.js'; import { dateKey, getDayPlan, loadPlans, newPlanEntryId, savePlans, } from '../services/planStore.js'; const MONTHS_SHORT = [ 'sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru', ]; const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd']; const WEEKDAYS_LONG = [ 'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota', ]; /** 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 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 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 ? `— 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 ? 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, 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 || !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; const lines = []; rows.forEach((row) => { const id = row.getAttribute('data-ingredient-id'); const amount = parseFloat(row.getAttribute('data-amount') || ''); const unit = row.getAttribute('data-unit') || ''; const category = row.getAttribute('data-category') || ''; if (!id || !Number.isFinite(amount)) return; lines.push({ ingredientId: id, amount, unit, category, sourceNote: 'Z planu dnia', }); }); addOrMergeShoppingLines(lines); showPlannerToast(`Dodano ${lines.length} składników na listę.`); window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); 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; } const lines = []; selected.forEach((row) => { const id = row.getAttribute('data-ingredient-id'); const amount = parseFloat(row.getAttribute('data-amount') || ''); const unit = row.getAttribute('data-unit') || ''; const category = row.getAttribute('data-category') || ''; if (!id || !Number.isFinite(amount)) return; lines.push({ ingredientId: id, amount, unit, category, sourceNote: 'Z planu dnia', }); }); addOrMergeShoppingLines(lines); showPlannerToast(`Dodano ${lines.length} pozycji na listę.`); window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); rerender(); }