- ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
+
+
+
+ ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
+
+
-
-
-
-
- ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
+
+
+ ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
+
+
+
+
+
-
-
-
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 +189,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 +215,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(`
`);
}
@@ -192,27 +253,22 @@ function updatePeriodLabel(mode, weekStart, monthAnchor) {
}
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 handleIcon = document.getElementById('calendar-handle-icon');
- 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) {
+ weekWrap.style.maxHeight = mode === 'week' ? '10rem' : '0';
+ weekWrap.style.opacity = mode === 'week' ? '1' : '0';
}
- if (weekWrap && monthWrap) {
- weekWrap.classList.toggle('hidden', mode !== 'week');
- monthWrap.classList.toggle('hidden', mode !== 'month');
+ 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';
}
}
@@ -226,45 +282,534 @@ function bindDayClicks(container, state, 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.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) {
+ 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;
+
+ 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 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';
+
+ 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 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
+
+
+ ${shortItems.map((ing) => `
+ -
+
+
+
${escapeHtml(ing.name)}
+
+ potrzeba ${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}
+ ·
+ w spiżarni ${ing.pantryQty > 0 ? formatAmount(ing.pantryQty) + ' ' + escapeHtml(ing.pantryUnit) : 'brak'}
+
+
+
+
−${formatAmount(ing.shortfall)}
+
${escapeHtml(ing.pantryUnit)}
+
+ `).join('')}
+
+
`;
+ }
+
+ if (okItems.length > 0) {
+ html += `
+
+ W spiżarni
+
+
+ ${okItems.map((ing) => `
+ -
+
+
+
${escapeHtml(ing.name)}
+
+ potrzeba ${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}
+ ·
+ masz ${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)}
+
+
+ `).join('')}
+
+
`;
+ }
+
+ 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: '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);
- } else {
- renderMonthGrid(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('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);
@@ -303,5 +848,148 @@ 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 || !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 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();
+
+ 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;
+ });
}
diff --git a/js/views/Pantry.js b/js/views/Pantry.js
new file mode 100644
index 0000000..62f2085
--- /dev/null
+++ b/js/views/Pantry.js
@@ -0,0 +1,464 @@
+import {
+ INGREDIENTS,
+ CATEGORY_LABELS,
+ pantryQtyStep,
+} from '../data/catalog.js';
+import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js';
+import { showAppToast } from '../ui/toast.js';
+
+/* ── helpers ── */
+
+function esc(s) {
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+function unitLabel(u) {
+ return u === 'szt' ? 'szt.' : u;
+}
+
+function normalizeSearch(q) {
+ return String(q).trim().toLowerCase();
+}
+
+const CATEGORY_ICONS = {
+ pieczywo: 'fa-bread-slice',
+ nabial: 'fa-cheese',
+ mieso_ryby: 'fa-drumstick-bite',
+ warzywa: 'fa-carrot',
+ owoce: 'fa-apple-whole',
+ suche: 'fa-wheat-awn',
+ przyprawy: 'fa-leaf',
+ inne: 'fa-jar',
+};
+
+/* ── state ── */
+
+let showOnlyStock = false;
+let editingId = null;
+/** @type {Set
} */
+const selectedCategories = new Set();
+
+let editShopStep = 1;
+let editShopUsesPacks = false;
+
+const BOTTOM = '5.25rem';
+const HIDDEN_Y = `translateY(calc(100% + ${BOTTOM}))`;
+
+/* ══════════════════════ HTML SHELL ══════════════════════ */
+
+export function getPantryHTML() {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+/* ══════════════════════ CATEGORY CHIPS (multi-select) ══════════════════════ */
+
+function allCategoryKeys() {
+ const s = new Set();
+ Object.values(INGREDIENTS).forEach(d => s.add(d.category));
+ return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
+}
+
+function renderCategoryChips() {
+ const wrap = document.getElementById('pantry-category-chips');
+ if (!wrap) return;
+
+ const keys = allCategoryKeys();
+ wrap.innerHTML = keys.map(k => {
+ const active = selectedCategories.has(k);
+ const icon = CATEGORY_ICONS[k] || 'fa-jar';
+ const cls = active
+ ? 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-900 text-white transition-colors'
+ : 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors';
+ return ``;
+ }).join('');
+
+ wrap.querySelectorAll('.pv2-cat-chip').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const cat = btn.dataset.cat;
+ if (selectedCategories.has(cat)) selectedCategories.delete(cat);
+ else selectedCategories.add(cat);
+ renderCategoryChips();
+ renderBoard();
+ });
+ });
+}
+
+/* ══════════════════════ BOARD RENDERING ══════════════════════ */
+
+function getFilteredIds(searchRaw) {
+ const q = normalizeSearch(searchRaw);
+ return Object.keys(INGREDIENTS).filter(id => {
+ const d = INGREDIENTS[id];
+ if (selectedCategories.size > 0 && !selectedCategories.has(d.category)) return false;
+ if (!q) return true;
+ return d.name.toLowerCase().includes(q) || (CATEGORY_LABELS[d.category] || '').toLowerCase().includes(q);
+ }).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
+}
+
+function chipHtml(id, pantry) {
+ const def = INGREDIENTS[id];
+ const qty = Number(pantry[id]) || 0;
+ const u = unitLabel(def.pantryUnit);
+
+ if (qty > 0) {
+ return ``;
+ }
+
+ return ``;
+}
+
+function groupByCategory(ids) {
+ /** @type {Map} */
+ const groups = new Map();
+ for (const id of ids) {
+ const cat = INGREDIENTS[id].category;
+ if (!groups.has(cat)) groups.set(cat, []);
+ groups.get(cat).push(id);
+ }
+ return [...groups.keys()]
+ .sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)))
+ .map(cat => ({ cat, ids: groups.get(cat) }));
+}
+
+function renderBoard() {
+ const root = document.getElementById('pantry-board');
+ if (!root) return;
+
+ const q = document.getElementById('pantry-search')?.value || '';
+ const pantry = loadPantry();
+ const allFiltered = getFilteredIds(q);
+ const visible = showOnlyStock
+ ? allFiltered.filter(id => (Number(pantry[id]) || 0) > 0)
+ : allFiltered;
+
+ if (visible.length === 0) {
+ root.innerHTML = showOnlyStock
+ ? `
+
+
+
+
Nic na stanie
+
Wyłącz filtr, aby zobaczyć cały katalog produktów
+
`
+ : `Brak wyników — zmień wyszukiwanie lub filtry.
`;
+ return;
+ }
+
+ const groups = groupByCategory(visible);
+ let html = '';
+ for (const { cat, ids } of groups) {
+ const icon = CATEGORY_ICONS[cat] || 'fa-jar';
+ html += `
+
+
+ ${esc(categoryLabel(cat))}
+
+
${ids.map(id => chipHtml(id, pantry)).join('')}
+
`;
+ }
+
+ root.innerHTML = html;
+
+ root.querySelectorAll('.pv2-chip').forEach(btn => {
+ btn.addEventListener('click', () => openEditSheet(btn.dataset.id));
+ });
+}
+
+/* ══════════════════════ STOCK TOGGLE ══════════════════════ */
+
+function updateToggleVisuals() {
+ const btn = document.getElementById('pantry-stock-toggle');
+ if (!btn) return;
+ const thumb = btn.querySelector('span');
+ btn.setAttribute('aria-checked', String(showOnlyStock));
+ if (showOnlyStock) {
+ btn.classList.remove('bg-gray-200');
+ btn.classList.add('bg-emerald-500');
+ thumb?.classList.add('translate-x-[18px]');
+ } else {
+ btn.classList.add('bg-gray-200');
+ btn.classList.remove('bg-emerald-500');
+ thumb?.classList.remove('translate-x-[18px]');
+ }
+}
+
+/* ══════════════════════ EDIT BOTTOM SHEET ══════════════════════ */
+
+function openEditSheet(ingredientId) {
+ const def = INGREDIENTS[ingredientId];
+ if (!def) return;
+ editingId = ingredientId;
+
+ const pantry = loadPantry();
+ const qty = Number(pantry[ingredientId]) || 0;
+ const u = unitLabel(def.pantryUnit);
+ const step = pantryQtyStep(ingredientId);
+ const pack = def.purchasePack;
+
+ const nameEl = document.getElementById('pv2-edit-name');
+ if (nameEl) nameEl.textContent = def.name;
+
+ const metaEl = document.getElementById('pv2-edit-meta');
+ if (metaEl) {
+ let meta = categoryLabel(def.category);
+ if (pack) meta += ` · ${pack.label || `${pack.amount} ${u}`}`;
+ metaEl.textContent = meta;
+ }
+
+ const qtyEl = document.getElementById('pv2-edit-qty');
+ if (qtyEl) qtyEl.value = qty > 0 ? String(Math.round(qty)) : '0';
+
+ const unitEl = document.getElementById('pv2-edit-unit');
+ if (unitEl) unitEl.textContent = u;
+
+ editShopUsesPacks = Boolean(pack && pack.amount > 0);
+ editShopStep = editShopUsesPacks ? 1 : step;
+
+ const shopQtyEl = document.getElementById('pv2-shop-qty');
+ if (shopQtyEl) shopQtyEl.value = String(editShopStep);
+
+ const shopUnitEl = document.getElementById('pv2-shop-unit');
+ if (shopUnitEl) shopUnitEl.textContent = editShopUsesPacks ? 'opak.' : u;
+
+ const shopHintEl = document.getElementById('pv2-shop-hint');
+ if (shopHintEl) {
+ if (editShopUsesPacks) {
+ const lab = pack.label || `${pack.amount} ${u}`;
+ shopHintEl.textContent = `1 opak. = ${lab}`;
+ } else {
+ shopHintEl.textContent = '';
+ }
+ }
+
+ renderNutritionInSheet(def);
+
+ const bg = document.getElementById('pv2-edit-bg');
+ const sheet = document.getElementById('pv2-edit-sheet');
+ if (!bg || !sheet) return;
+ bg.classList.remove('hidden');
+ sheet.classList.remove('hidden');
+ requestAnimationFrame(() => {
+ bg.classList.remove('opacity-0');
+ sheet.style.transform = 'translateY(0)';
+ });
+}
+
+function nutritionListRow(label, valueHtml) {
+ return `
+ ${esc(label)}
+ ${valueHtml}
+ `;
+}
+
+function renderNutritionInSheet(def) {
+ const wrap = document.getElementById('pv2-edit-nutrition');
+ if (!wrap) return;
+ const n = def.nutritionPer100g;
+ if (!n) { wrap.innerHTML = ''; return; }
+
+ const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu';
+ wrap.innerHTML = `
+
+
${esc(refLabel)}
+
+ ${nutritionListRow('Energia', `${n.kcal} kcal`)}
+ ${nutritionListRow('Białko', `${n.protein} g`)}
+ ${nutritionListRow('Tłuszcz', `${n.fat} g`)}
+ ${nutritionListRow('Węglowodany', `${n.carbs} g`)}
+
+
`;
+}
+
+function closeEditSheet() {
+ editingId = null;
+ const bg = document.getElementById('pv2-edit-bg');
+ const sheet = document.getElementById('pv2-edit-sheet');
+ if (sheet) sheet.style.transform = HIDDEN_Y;
+ if (bg) bg.classList.add('opacity-0');
+ setTimeout(() => {
+ bg?.classList.add('hidden');
+ sheet?.classList.add('hidden');
+ }, 300);
+ renderBoard();
+}
+
+function getEditQty() {
+ const el = document.getElementById('pv2-edit-qty');
+ return Math.max(0, parseFloat(String(el?.value).replace(',', '.')) || 0);
+}
+
+function setEditQty(v) {
+ const el = document.getElementById('pv2-edit-qty');
+ if (el) el.value = String(Math.max(0, Math.round(v)));
+}
+
+function applyEditQty(newQty) {
+ if (!editingId) return;
+ const v = Math.max(0, Math.round(Number(newQty) * 1000) / 1000 || 0);
+ setPantryQty(editingId, v);
+ setEditQty(v);
+}
+
+function readShopQty() {
+ const el = document.getElementById('pv2-shop-qty');
+ return Math.max(1, Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0));
+}
+
+function setShopQty(v) {
+ const el = document.getElementById('pv2-shop-qty');
+ if (el) el.value = String(Math.max(1, Math.round(Number(v))));
+}
+
+function bindEditSheet() {
+ document.getElementById('pv2-edit-bg')?.addEventListener('click', closeEditSheet);
+
+ document.getElementById('pv2-edit-minus')?.addEventListener('click', () => {
+ if (!editingId) return;
+ applyEditQty(Math.max(0, getEditQty() - pantryQtyStep(editingId)));
+ });
+
+ document.getElementById('pv2-edit-plus')?.addEventListener('click', () => {
+ if (!editingId) return;
+ applyEditQty(getEditQty() + pantryQtyStep(editingId));
+ });
+
+ document.getElementById('pv2-edit-qty')?.addEventListener('change', () => {
+ applyEditQty(getEditQty());
+ });
+
+ document.getElementById('pv2-shop-minus')?.addEventListener('click', () => {
+ setShopQty(Math.max(1, readShopQty() - editShopStep));
+ });
+
+ document.getElementById('pv2-shop-plus')?.addEventListener('click', () => {
+ setShopQty(readShopQty() + editShopStep);
+ });
+
+ document.getElementById('pv2-shop-qty')?.addEventListener('change', () => {
+ setShopQty(readShopQty());
+ });
+
+ document.getElementById('pv2-shop-add')?.addEventListener('click', () => {
+ if (!editingId) return;
+ const def = INGREDIENTS[editingId];
+ if (!def) return;
+ const count = readShopQty();
+ const u = unitLabel(def.pantryUnit);
+
+ if (editShopUsesPacks && def.purchasePack) {
+ const packAmt = def.purchasePack.amount;
+ const total = count * packAmt;
+ const note = `${count}× ${def.purchasePack.label || `${packAmt} ${u}`}`;
+ addIngredientToKitchenList(editingId, total, note);
+ showAppToast(`Dodano ${count} op. (${total} ${u}) na listę.`);
+ } else {
+ addIngredientToKitchenList(editingId, count);
+ showAppToast(`Dodano ${count} ${u} na listę.`);
+ }
+ window.refreshShopping?.();
+ });
+}
+
+/* ══════════════════════ PUBLIC API ══════════════════════ */
+
+export function refreshPantry() {
+ renderCategoryChips();
+ renderBoard();
+}
+
+export function setupPantry() {
+ renderCategoryChips();
+ renderBoard();
+ bindEditSheet();
+
+ document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard());
+
+ document.getElementById('pantry-stock-toggle')?.addEventListener('click', () => {
+ showOnlyStock = !showOnlyStock;
+ updateToggleVisuals();
+ renderBoard();
+ });
+
+ window.refreshPantry = refreshPantry;
+}
diff --git a/js/views/RecipeDetail.js b/js/views/RecipeDetail.js
index d43ed9b..e8be2d3 100644
--- a/js/views/RecipeDetail.js
+++ b/js/views/RecipeDetail.js
@@ -2,144 +2,142 @@ export function getRecipeDetailHTML() {
return `
-
-