import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
startOfWeekMonday,
weekContains,
} 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_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT,
bindCalendarDayClicks,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
isCalendarOnToday,
renderCalendarGrid,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=14';
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,
);
}
export function getMealPlannerHTML() {
return `
Plan posiłków
${createCalendarTopbarHTML({
todayId: 'cal-go-today',
wrapperClass: 'flex shrink-0 items-center justify-end',
})}
${createCalendarWeekdayHeaderHTML()}
${createCalendarWeekdayHeaderHTML()}
`;
}
function syncModeToggle(mode) {
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
});
const icon = document.getElementById('calendar-handle-icon');
if (icon) icon.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
function bindCalendarSwipeGesture(state, rerender) {
const zone = document.getElementById('calendar-swipe-zone');
if (!zone) return;
const ANIMATION_MS = 260;
let startX = 0;
let startY = 0;
let ptrId = null;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let dragWrap = null;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let prevWrapPosition = '';
let prevWrapOverflow = '';
const getActiveWrap = () => document.getElementById(
state.mode === 'week' ? 'calendar-week-wrap' : 'calendar-month-wrap',
);
const resolveDayStateForGhost = (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 buildGhost = (anchorDate, mode) => {
if (!dragWrap) return null;
const ghost = dragWrap.cloneNode(true);
ghost.removeAttribute('id');
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ghostGridEl = null;
ghost.querySelectorAll('.grid-cols-7').forEach((g) => {
if (!g.classList.contains('text-center')) ghostGridEl = g;
});
if (ghostGridEl) {
renderCalendarGrid({
gridEl: ghostGridEl,
mode,
anchorDate,
selectedDate: state.selected,
resolveDayState: resolveDayStateForGhost,
});
}
return ghost;
};
const activateCarousel = () => {
dragWrap = getActiveWrap();
if (!dragWrap) return;
wrapWidth = dragWrap.getBoundingClientRect().width || zone.getBoundingClientRect().width;
prevWrapPosition = dragWrap.style.position;
prevWrapOverflow = dragWrap.style.overflow;
dragWrap.style.position = 'relative';
dragWrap.style.overflow = 'visible';
const prevAnchor = state.mode === 'week'
? addWeeks(state.weekStart, -1)
: addMonths(state.monthAnchor, -1);
const nextAnchor = state.mode === 'week'
? addWeeks(state.weekStart, 1)
: addMonths(state.monthAnchor, 1);
prevGhost = buildGhost(prevAnchor, state.mode);
nextGhost = buildGhost(nextAnchor, state.mode);
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
dragWrap.appendChild(prevGhost);
}
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
dragWrap.appendChild(nextGhost);
}
dragWrap.style.willChange = 'transform';
dragWrap.style.transition = 'none';
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (dragWrap) {
dragWrap.style.transition = '';
dragWrap.style.transform = '';
dragWrap.style.willChange = '';
dragWrap.style.position = prevWrapPosition;
dragWrap.style.overflow = prevWrapOverflow;
}
dragWrap = null;
};
const setTranslate = (x, ms) => {
if (!dragWrap) return;
dragWrap.style.transition = ms ? `transform ${ms}ms ease` : 'none';
dragWrap.style.transform = `translate3d(${x}px, 0, 0)`;
};
zone.addEventListener('pointerdown', (e) => {
if (ptrId !== null || animatingNav) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
ptrId = e.pointerId;
moved = false;
axisLocked = null;
try { zone.setPointerCapture(e.pointerId); } catch (_) {}
});
zone.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
moved = true;
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axisLocked === 'x') activateCarousel();
}
if (axisLocked === 'x' && dragWrap) {
setTranslate(dx, 0);
}
});
zone.addEventListener('pointerup', (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
ptrId = null;
if (!moved) return;
const horizontal = axisLocked === 'x';
if (horizontal && dragWrap) {
if (Math.abs(dx) < 40) {
setTranslate(0, ANIMATION_MS);
setTimeout(clearCarousel, ANIMATION_MS + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, ANIMATION_MS);
setTimeout(() => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, dx > 0 ? -1 : 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, dx > 0 ? -1 : 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
clearCarousel();
rerender();
animatingNav = false;
}, ANIMATION_MS);
return;
}
if (!horizontal && Math.abs(dy) >= 30) {
let triggered = false;
if (state.mode === 'week' && dy > 0) {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
triggered = true;
} else if (state.mode === 'month' && dy < 0) {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
triggered = true;
}
if (triggered) {
suppressClickUntil = performance.now() + 350;
rerender();
}
}
});
zone.addEventListener('click', (ev) => {
if (performance.now() < suppressClickUntil) {
ev.stopPropagation();
ev.preventDefault();
suppressClickUntil = 0;
}
}, { capture: true });
zone.addEventListener('pointercancel', () => {
ptrId = null;
moved = false;
if (dragWrap) {
setTranslate(0, ANIMATION_MS);
setTimeout(clearCarousel, ANIMATION_MS + 20);
}
});
}
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 `
`;
}).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
${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()} ${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 rerender = () => {
syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
const today = startOfDay(new Date());
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => {
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),
};
},
});
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();
};
bindCalendarSwipeGesture(state, rerender);
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;
});
}