Files
recipe-mockup/js/views/MealPlanner.js
ulfrxdev 08a275093c
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s
Unify calendar code
2026-04-20 23:44:18 +02:00

1380 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
sameDay,
startOfDay,
startOfMonth,
startOfWeekMonday,
} from '../services/dateUtils.js';
import {
computeEntryNutrition,
computeFullForecast,
countDayShortfalls,
dayHasAnyMeal,
sumDayNutrition,
} from '../services/planIngredients.js?v=4';
import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js?v=2';
import {
dateKey,
getDayPlan,
loadPlans,
newPlanEntryId,
savePlans,
} from '../services/planStore.js?v=2';
import {
CALENDAR_MONTHS_SHORT,
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML,
formatCalendarPeriodLabel,
isCalendarOnToday,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
syncCollapsibleCalendarToggleIcon,
} from '../ui/mealCalendar.js?v=15';
import {
filterRecipesByQuery,
renderRecipeGrid,
} from '../ui/recipeGrid.js';
import { getRecipeSearchFieldHTML } from '../ui/recipeSearchField.js';
const WEEKDAYS_LONG = [
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
];
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
const PLANNER_SHEET_MAX_HEIGHT = '70vh';
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)';
const PLANNER_MEAL_SWIPE_MAX = 132;
const PLANNER_MEAL_SWIPE_DELETE_THRESHOLD = 96;
const PLANNER_MEAL_PENDING_DELETE_MS = 2000;
const PLANNER_MEAL_PENDING_DELETE_TICK_MS = 100;
const PICKER_FILTER_MIN_MINUTES = 5;
const PICKER_FILTER_MAX_MINUTES = 120;
function recipesForSlot(slotId) {
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
}
function syncTodayButton(mode, weekStart, monthAnchor, selected) {
syncCalendarTodayButton(
document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected),
selected,
{
labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor),
},
);
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[rgb(var(--app-bg-rgb))] z-10">
<div id="planner-cal-bar" class="shrink-0 bg-[rgb(var(--app-bg-rgb))] border-b border-[rgb(var(--card-strong-rgb))] mt-3 relative z-10">
<div class="min-h-12 px-4 pt-4 pb-3 flex items-center justify-between gap-3 min-w-0">
<h1 class="min-w-0 flex-1 truncate" style="margin:0;padding:0;color:rgb(var(--text-emphasis-rgb));font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Plan posiłków</h1>
${createCalendarTopbarHTML({
todayId: 'cal-go-today',
wrapperClass: 'flex shrink-0 items-center justify-end',
})}
</div>
${createCollapsibleCalendarHTML({ idPrefix: 'calendar' })}
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[rgb(var(--app-bg-rgb))]">
<div id="planner-summary-card" class="mb-3">
<div class="h-full flex flex-col" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; box-shadow:none !important;">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Wartości odżywcze</p>
<div class="flex-1 flex items-center">
<div class="grid grid-cols-4 gap-1.5 w-full">
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-kcal" class="text-[15px] font-bold text-gray-100 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">kcal</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-p" class="text-[15px] font-bold text-blue-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">białko</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-f" class="text-[15px] font-bold text-amber-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">tłuszcz</p>
</div>
<div class="rounded-xl px-2 py-[0.5625rem] text-center" style="background:rgb(var(--card-rgb));">
<p id="planner-nutrition-c" class="text-[15px] font-bold text-orange-400 tabular-nums leading-tight">—</p>
<p class="text-[9px] text-gray-500 font-medium">węglowodany</p>
</div>
</div>
</div>
</div>
</div>
<button type="button" id="planner-open-ingredients" class="hidden w-full mb-3 flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] text-[13px] font-semibold text-[rgb(var(--text-body-soft-rgb))] hover:border-[rgb(var(--text-subdued-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] transition-colors">
<i class="fas fa-shopping-basket text-[rgb(var(--text-dim-rgb))] text-xs" aria-hidden="true"></i>
Składniki na ten dzień
</button>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[rgb(var(--app-bg-rgb))]"></div>
</div>
</div>
<div id="planner-picker-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-picker-sheet" data-off-transform="${PLANNER_PICKER_OFF_TRANSFORM}" class="absolute inset-x-0 bottom-0 z-[60] flex flex-col will-change-transform rounded-t-[1.85rem] overflow-hidden" style="top:calc(env(safe-area-inset-top) + 0.35rem); visibility: hidden; transform: ${PLANNER_PICKER_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;" role="dialog" aria-label="Wybierz przepis" aria-modal="true">
<div class="pointer-events-none absolute inset-x-0 top-0 z-[2] px-4 pt-3">
<div class="pointer-events-auto pb-4 touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[rgb(var(--text-subdued-rgb))]/75 rounded-full mx-auto" aria-hidden="true"></div>
</div>
<div class="pointer-events-auto">
${getRecipeSearchFieldHTML({
shellId: 'planner-picker-search-shell',
inputId: 'planner-picker-search',
placeholder: 'Wybierz przepis',
inputAriaLabel: 'Wybierz przepis',
filterButtonId: 'planner-picker-filter-btn',
filterButtonAction: 'openFilters(\'plannerPicker\')',
filterButtonLabel: 'Otwórz filtry',
})}
</div>
</div>
<div id="planner-picker-scroll" class="relative min-h-0 flex-1 overflow-y-auto px-4 pt-28 pb-8 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important;">
<div id="planner-picker-grid" class="grid grid-cols-3 gap-2 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important;"></div>
<div id="planner-picker-empty-state" class="hidden flex flex-col items-center justify-center py-16 text-center">
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<i class="fas fa-search text-2xl text-gray-300" aria-hidden="true"></i>
</div>
<p class="text-sm font-semibold text-gray-700">Brak wyników</p>
<p class="text-xs text-gray-500 mt-1 max-w-[220px] leading-relaxed">Spróbuj innej nazwy przepisu</p>
</div>
</div>
</div>
<div id="planner-ing-backdrop" class="absolute inset-0 z-[55] bg-black/45 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 bottom-0 z-[60] rounded-t-3xl shadow-[0_-10px_40px_rgba(var(--overlay-rgb),0.12)] flex flex-col will-change-transform" style="visibility: hidden; height: auto; max-height: calc(100vh - env(safe-area-inset-top) - 1rem); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[rgb(var(--card-strong-rgb))] touch-none cursor-grab active:cursor-grabbing select-none" data-planner-sheet-drag-zone aria-label="Przeciągnij w dół, by zamknąć">
<div class="w-10 h-1 bg-[rgb(var(--text-subdued-rgb))]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[rgb(var(--text-body-rgb))] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-1">Porównanie potrzeb z zapasami.</p>
</div>
<div id="planner-ing-body" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2 pb-2"></div>
<div id="planner-ing-footer" class="shrink-0 p-4 pt-2 pb-5 border-t border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] space-y-2">
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3 rounded-xl font-semibold shadow-sm transition-colors text-[13px] flex items-center justify-center gap-2">
<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i>
Dodaj braki na dziś do listy
</button>
<button type="button" id="planner-ing-add-btn" class="hidden w-full border border-[rgb(var(--card-strong-rgb))] bg-[rgb(var(--app-bg-rgb))] text-[rgb(var(--text-body-soft-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] py-2.5 rounded-xl font-semibold text-[13px] flex items-center justify-center gap-2 transition-colors">
<i class="fas fa-calendar-week text-[rgb(var(--text-dim-rgb))] text-[11px]" aria-hidden="true"></i>
Dodaj braki na cały tydzień
</button>
</div>
</div>
<div id="planner-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[65] opacity-0 translate-y-2 transition-all duration-300" role="status">
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="planner-toast-text"></div>
</div>
`;
}
function syncModeToggle(mode) {
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
});
syncCollapsibleCalendarToggleIcon(document.getElementById('calendar-handle-icon'), mode);
}
function showPlannerToast(message) {
const wrap = document.getElementById('planner-toast');
const text = document.getElementById('planner-toast-text');
if (!wrap || !text) return;
text.textContent = message;
wrap.classList.remove('opacity-0', 'translate-y-2');
wrap.classList.add('opacity-100', 'translate-y-0');
clearTimeout(showPlannerToast._t);
showPlannerToast._t = setTimeout(() => {
wrap.classList.add('opacity-0', 'translate-y-2');
wrap.classList.remove('opacity-100', 'translate-y-0');
}, 2600);
}
function openSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.visibility = 'visible';
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
backdrop.classList.remove('hidden');
requestAnimationFrame(() => {
backdrop.classList.remove('opacity-0');
});
}
function getSheetOffTransform(sheet) {
return sheet?.dataset.offTransform || PLANNER_SHEET_OFF_TRANSFORM;
}
function closeSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = getSheetOffTransform(sheet);
backdrop.classList.add('opacity-0');
setTimeout(() => {
backdrop.classList.add('hidden');
sheet.style.visibility = 'hidden';
}, 300);
}
/** Zamykanie panelu: przeciągnięcie w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn, options = {}) {
const zone = options.dragSurface || sheet.querySelector('[data-planner-sheet-drag-zone]');
const scrollEl = options.scrollEl || null;
if (!zone || !sheet) return;
let startX = 0;
let startY = 0;
let ptrId = null;
let engaged = false;
let startFromHandle = false;
let suppressClickUntil = 0;
const resetVisual = () => {
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
};
const stopTracking = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
ptrId = null;
engaged = false;
startFromHandle = false;
};
const shouldIgnoreTarget = (target) => target instanceof Element
&& Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
zone.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (shouldIgnoreTarget(e.target)) return;
if (scrollEl && scrollEl.scrollTop > 0 && !(e.target instanceof Element && e.target.closest('[data-planner-sheet-drag-zone]'))) {
return;
}
ptrId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
engaged = false;
startFromHandle = e.target instanceof Element && Boolean(e.target.closest('[data-planner-sheet-drag-zone]'));
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
});
const onPointerMove = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
const rawDy = e.clientY - startY;
if (!engaged) {
if (Math.abs(dx) < 8 && Math.abs(rawDy) < 8) return;
if (rawDy <= 0 || Math.abs(dx) > Math.abs(rawDy)) {
stopTracking();
resetVisual();
return;
}
if (!startFromHandle && scrollEl && scrollEl.scrollTop > 0) {
stopTracking();
resetVisual();
return;
}
engaged = true;
suppressClickUntil = Date.now() + 250;
sheet.style.transition = 'none';
}
const dy = Math.max(0, rawDy);
sheet.style.transform = `translateY(${dy}px)`;
if (e.cancelable) e.preventDefault();
};
const onPointerUp = (e) => {
if (e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
const shouldClose = engaged && dy > 56;
stopTracking();
if (shouldClose) {
closeFn();
return;
}
resetVisual();
};
const onPointerCancel = (e) => {
if (ptrId !== null && e.pointerId !== ptrId) return;
stopTracking();
resetVisual();
};
zone.addEventListener('click', (e) => {
if (Date.now() < suppressClickUntil) {
e.preventDefault();
e.stopPropagation();
}
}, true);
zone.addEventListener('pointercancel', (e) => {
onPointerCancel(e);
});
}
function renderDayContent(state, onMealRemoved = null) {
const sel = state.selected;
syncPendingMealRemovals(state);
const selectedDayKey = dateKey(sel);
const dayPlan = getDayPlan(state.plans, sel);
const totals = sumDayNutrition(dayPlan);
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
const setGrams = (id, value) => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = value === null ? '—' : `${value}g`;
};
const hasMeals = totals.mealCount > 0;
setText('planner-nutrition-kcal', hasMeals ? String(totals.kcal) : '—');
setGrams('planner-nutrition-p', hasMeals ? totals.protein : null);
setGrams('planner-nutrition-f', hasMeals ? totals.fat : null);
setGrams('planner-nutrition-c', hasMeals ? totals.carbs : null);
const ingBtn = document.getElementById('planner-open-ingredients');
if (ingBtn) {
const noMeals = totals.mealCount === 0;
ingBtn.disabled = noMeals;
ingBtn.classList.toggle('opacity-50', noMeals);
ingBtn.classList.toggle('cursor-not-allowed', noMeals);
if (!noMeals) {
const shortCount = countDayShortfalls(dayPlan, loadPantry());
if (shortCount > 0) {
ingBtn.innerHTML = `<i class="fas fa-shopping-basket text-xs" aria-hidden="true"></i>
Składniki na ten dzień
<span class="ml-auto bg-red-500 text-white text-[10px] font-bold rounded-full w-5 h-5 inline-flex items-center justify-center">${shortCount}</span>`;
} else {
ingBtn.innerHTML = `<i class="fas fa-check-circle text-emerald-500 text-xs" aria-hidden="true"></i>
Składniki na ten dzień
<span class="ml-auto text-[10px] font-semibold text-emerald-600">OK</span>`;
}
} else {
ingBtn.innerHTML = `<i class="fas fa-shopping-basket text-gray-500 text-xs" aria-hidden="true"></i> 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 ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : '';
const servLabel = servings > 1 ? `<span class="mx-1.5 text-[rgb(var(--text-subdued-rgb))]">·</span>×${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
? `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(var(--text-body-soft-rgb),0.42);">
<i class="fas fa-hourglass-half text-[10px]" aria-hidden="true"></i>
Usuwanie
</span>`
: `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(var(--text-emphasis-rgb), calc(0.55 + var(--planner-swipe-progress) * 0.45));">
<i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
Usuń
</span>`;
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
? `<button type="button" class="planner-cancel-pending-remove rounded-full p-[1.5px] transition-transform hover:scale-[1.02] active:scale-[0.98]" style="background:${getPendingMealRemovalButtonStyle(remainingProgress)};" data-pending-delete-progress data-day-key="${selectedDayKey}" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Anuluj usunięcie posiłku">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-[rgb(var(--card-raised-rgb))] text-[rgb(var(--text-body-rgb))] shadow-[0_1px_2px_rgba(var(--overlay-rgb),0.28)]">
<i class="fas fa-rotate-left text-[10px]" aria-hidden="true"></i>
</span>
</button>`
: `<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[rgb(var(--card-strong-rgb))] text-[rgb(var(--text-dim-rgb))] hover:text-[rgb(var(--text-body-rgb))] hover:border-[rgb(var(--text-subdued-rgb))] hover:bg-[rgb(var(--card-raised-rgb))] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
</button>`;
return `
<div class="relative overflow-hidden rounded-lg ${isPendingDelete ? 'ring-1 ring-white/5' : ''}" data-planner-swipe-row style="${rowStyle}" data-slot-id="${slot.id}" data-entry-id="${eid}" ${rowAttrs}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="${backgroundStyle}">
${backgroundLabel}
</div>
<div class="relative z-[1] rounded-lg p-2 planner-open-recipe cursor-pointer" style="background:${isPendingDelete ? 'rgba(var(--app-bg-rgb),0.76)' : 'rgb(var(--app-bg-rgb))'}; box-shadow:inset 0 1px 3px rgba(var(--overlay-rgb),0.3); transform:${isPendingDelete ? 'translateX(0) scale(0.988)' : 'translateX(0)'}; transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:1;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="relative flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0" style="${contentToneStyle}">
<div class="w-8 h-8 rounded-lg bg-[rgb(var(--card-raised-rgb))] overflow-hidden shrink-0">
${recipe.image
? `<img src="${escapeHtml(recipe.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
</div>
<div class="min-w-0">
<div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="${metaClass}">
<i class="fas fa-clock text-[rgb(var(--text-faint-rgb))] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[rgb(var(--text-subdued-rgb))]">·</span>
<i class="fas fa-fire text-[rgb(var(--text-faint-rgb))] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p>
</div>
</div>
<div class="${actionWrapClass}">
${entryAction}
</div>
</div>
</div>
</div>`;
}).join('');
const addBtn = `<button type="button" class="planner-add-meal w-7 h-7 rounded-full border border-[rgb(var(--card-strong-rgb))] text-[rgb(var(--text-dim-rgb))] flex items-center justify-center shrink-0" data-slot-id="${slot.id}" aria-label="Dodaj przepis"><i class="fas fa-plus text-[10px]"></i></button>`;
const kcalPill = slotKcal > 0
? `<span class="text-[10px] font-semibold tabular-nums shrink-0 px-2 py-0.5 rounded-full" style="background:rgb(var(--app-bg-rgb)); color:rgb(var(--text-body-soft-rgb));">${slotKcal} kcal</span>`
: '';
const filledCard = `
<div class="rounded-xl bg-[rgb(var(--card-rgb))] overflow-hidden" style="background:rgb(var(--card-rgb)) !important; box-shadow:var(--shadow-card);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5 bg-[rgb(var(--card-rgb))]" style="background:rgb(var(--card-rgb)) !important;">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[rgb(var(--text-dim-rgb))] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span>
${kcalPill}
${addBtn}
</div>
${entries.length > 0 ? `<div class="px-2.5 pb-2.5 space-y-2 border-t border-[rgb(var(--card-strong-rgb))]" style="padding-top:0.625rem;">${entryCards}</div>` : ''}
</div>`;
if (entries.length > 0) return filledCard;
return `
<div class="rounded-xl bg-[rgb(var(--card-rgb))] overflow-hidden" style="background:rgb(var(--card-rgb)) !important; box-shadow:var(--shadow-card);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[rgb(var(--text-dim-rgb))] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))] truncate min-w-0">${slot.label}</span>
<span class="ml-auto"></span>
${addBtn}
</div>
<div class="px-3 pb-2.5 -mt-0.5">
<p class="text-[11px] text-[rgb(var(--text-faint-rgb))] italic pl-9">Zaplanuj posiłek</p>
</div>
</div>`;
}).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = `<i class="fas fa-cart-plus text-xs" aria-hidden="true"></i> Dodaj braki na dziś do listy`;
} else {
btn1.classList.add('hidden');
}
}
if (btn2) {
if (allCount > todayCount) {
btn2.classList.remove('hidden');
btn2.innerHTML = `<i class="fas fa-calendar-week text-gray-500 text-[11px]" aria-hidden="true"></i> 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 = '<p class="text-sm text-[rgb(var(--text-dim-rgb))] text-center py-8">Najpierw zaplanuj posiłki.</p>';
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 += `<div class="rounded-xl bg-[rgb(var(--app-bg-rgb))] border border-emerald-400/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[rgba(var(--success-rgb), 0.14)] flex items-center justify-center shrink-0">
<i class="fas fa-check text-emerald-600 text-sm"></i>
</div>
<div>
<p class="text-[13px] font-semibold text-emerald-300">Wszystko masz w spiżarni</p>
<p class="text-[11px] text-emerald-600/80">${today.items.length} ${plIngredientWord(today.items.length)} — zapasy wystarczą</p>
</div>
</div>`;
} else {
html += `<div class="rounded-xl bg-[rgb(var(--app-bg-rgb))] border border-red-300/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[rgba(var(--danger-rgb), 0.14)] flex items-center justify-center shrink-0">
<i class="fas fa-exclamation text-red-500 text-sm"></i>
</div>
<div>
<p class="text-[13px] font-semibold text-red-300">${shortItems.length} ${plIngredientWord(shortItems.length)} do kupienia</p>
<p class="text-[11px] text-red-600/80">Brakuje składników na zaplanowane posiłki</p>
</div>
</div>`;
}
if (shortItems.length > 0) {
html += `<div class="mb-5">
<p class="text-[10px] font-bold text-red-400 uppercase tracking-wider mb-2 px-0.5">
<i class="fas fa-cart-shopping text-[9px] mr-1"></i>Do kupienia
</p>
<ul class="border border-red-300/30 rounded-xl overflow-hidden bg-[rgb(var(--app-bg-rgb))] divide-y divide-[rgba(var(--danger-rgb), 0.14)]">
${shortItems.map((ing) => `
<li class="flex items-start gap-3 py-3 px-3">
<div class="w-2 h-2 rounded-full bg-red-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[rgb(var(--text-body-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[rgb(var(--text-subdued-rgb))]">&middot;</span>
w spiżarni <span class="font-medium ${ing.pantryQty > 0 ? 'text-amber-600' : 'text-gray-400'}">${ing.pantryQty > 0 ? formatAmount(ing.pantryQty) + ' ' + escapeHtml(ing.pantryUnit) : 'brak'}</span>
</p>
</div>
<div class="text-right shrink-0 pt-0.5">
<p class="text-[13px] font-bold text-red-600 tabular-nums leading-tight">&minus;${formatAmount(ing.shortfall)}</p>
<p class="text-[9px] text-red-400 font-medium">${escapeHtml(ing.pantryUnit)}</p>
</div>
</li>`).join('')}
</ul>
</div>`;
}
if (okItems.length > 0) {
html += `<div class="mb-5">
<p class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-2 px-0.5">
<i class="fas fa-check text-[9px] mr-1"></i>W spiżarni
</p>
<ul class="border border-[rgb(var(--card-strong-rgb))] rounded-xl overflow-hidden bg-[rgb(var(--app-bg-rgb))] divide-y divide-[rgb(var(--card-raised-rgb))]">
${okItems.map((ing) => `
<li class="flex items-start gap-3 py-2.5 px-3">
<div class="w-2 h-2 rounded-full bg-emerald-400 mt-1.5 shrink-0"></div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-[rgb(var(--text-body-soft-rgb))]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[rgb(var(--text-dim-rgb))] mt-0.5">
potrzeba <span class="font-medium text-[rgb(var(--text-body-soft-rgb))]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[rgb(var(--text-subdued-rgb))]">&middot;</span>
masz <span class="font-medium text-emerald-600">${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)}</span>
</p>
</div>
</li>`).join('')}
</ul>
</div>`;
}
if (upcoming.length > 0) {
html += `<div class="mb-2">
<p class="text-[10px] font-bold text-amber-500 uppercase tracking-wider mb-2 px-0.5">
<i class="fas fa-calendar-alt text-[9px] mr-1"></i>Nadchodzące braki
</p>
<div class="space-y-2">
${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 `<div class="rounded-xl border border-amber-200/80 bg-[rgb(var(--app-bg-rgb))] p-3">
<p class="text-[12px] font-semibold text-amber-900">
<i class="fas fa-calendar-day text-[10px] mr-1.5 text-amber-500"></i>${escapeHtml(label)}
</p>
<ul class="mt-2 space-y-1.5">
${shorts.map((it) => `
<li class="flex items-center justify-between text-[11px]">
<span class="font-medium text-amber-900">${escapeHtml(it.name)}</span>
<span class="font-semibold text-red-600 tabular-nums">&minus;${formatAmount(it.shortfall)} ${escapeHtml(it.pantryUnit)}</span>
</li>`).join('')}
</ul>
</div>`;
}).join('')}
</div>
</div>`;
}
body.innerHTML = html;
updateIngButtons(state);
}
function formatAmount(n) {
return Number.isInteger(n) ? String(n) : String(n);
}
function seedDemoIfEmpty(plans) {
const todayKey = dateKey(new Date());
if (Object.keys(plans).length > 0) return plans;
return {
...plans,
[todayKey]: {
sniadanie: [{ id: newPlanEntryId(), recipeId: 'jajecznica', servings: 1 }],
obiad: [{ id: newPlanEntryId(), recipeId: 'makaron_ricotta', servings: 1 }],
kolacja: [{ id: newPlanEntryId(), recipeId: 'kanapka_losos', servings: 1 }],
},
};
}
export function setupMealPlanner() {
let plans = loadPlans();
plans = seedDemoIfEmpty(plans);
savePlans(plans);
const state = {
mode: 'week',
weekStart: startOfWeekMonday(new Date()),
monthAnchor: startOfDay(new Date()),
selected: startOfDay(new Date()),
plans,
pendingMealRemovals: new Map(),
pendingMealRemovalTickerId: null,
pickerSlot: null,
};
const weekGrid = document.getElementById('calendar-week-grid');
const monthGrid = document.getElementById('calendar-month-grid');
const pickerBackdrop = document.getElementById('planner-picker-backdrop');
const pickerSheet = document.getElementById('planner-picker-sheet');
const pickerScroll = document.getElementById('planner-picker-scroll');
const ingBackdrop = document.getElementById('planner-ing-backdrop');
const ingSheet = document.getElementById('planner-ing-sheet');
const pickerFilterState = {
slots: [],
tags: [],
minMinutes: PICKER_FILTER_MIN_MINUTES,
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const resolveCalendarDayState = (day, meta) => {
const today = startOfDay(new Date());
const isSelected = sameDay(day, state.selected);
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast && !isSelected,
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
};
};
const rerender = () => {
syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: resolveCalendarDayState,
});
renderDayContent(state, persist);
};
const persist = () => {
savePlans(state.plans);
rerender();
};
/* ── calendar scroll shadow ─────────────────── */
const plannerScroll = document.getElementById('planner-scroll');
const calBar = document.getElementById('planner-cal-bar');
if (plannerScroll && calBar) {
const shadow = document.createElement('div');
shadow.style.cssText = 'position:absolute;left:0;right:0;bottom:-8px;height:8px;background:linear-gradient(to bottom,rgba(var(--overlay-rgb),0.25),transparent);opacity:0;transition:opacity 0.2s;pointer-events:none;';
calBar.appendChild(shadow);
plannerScroll.addEventListener('scroll', () => {
shadow.style.opacity = plannerScroll.scrollTop > 2 ? '1' : '0';
});
}
bindCalendarDayClicks(weekGrid, (date) => {
state.selected = date;
rerender();
});
bindCalendarDayClicks(monthGrid, (date) => {
state.selected = date;
rerender();
});
document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
state.selected = today;
state.weekStart = startOfWeekMonday(today);
state.monthAnchor = startOfMonth(today);
rerender();
});
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
const skipBtn = e.target.closest('.planner-skip-meal');
if (skipBtn) {
const slotId = skipBtn.getAttribute('data-slot-id');
if (!slotId) return;
const key = dateKey(state.selected);
if (!state.plans[key]) state.plans[key] = {};
if (!state.plans[key]._skipped) state.plans[key]._skipped = {};
state.plans[key]._skipped[slotId] = true;
persist();
return;
}
const unskipBtn = e.target.closest('.planner-unskip');
if (unskipBtn) {
const slotId = unskipBtn.getAttribute('data-slot-id');
if (!slotId) return;
const key = dateKey(state.selected);
if (state.plans[key]?._skipped) {
delete state.plans[key]._skipped[slotId];
if (Object.keys(state.plans[key]._skipped).length === 0) delete state.plans[key]._skipped;
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
}
persist();
return;
}
const cancelPendingRemoveBtn = e.target.closest('.planner-cancel-pending-remove');
if (cancelPendingRemoveBtn) {
const dayKey = cancelPendingRemoveBtn.getAttribute('data-day-key');
const slotId = cancelPendingRemoveBtn.getAttribute('data-slot-id');
const entryId = cancelPendingRemoveBtn.getAttribute('data-entry-id');
if (!dayKey || !slotId || !entryId) return;
if (cancelQueuedMealEntryRemoval(state, dayKey, slotId, entryId)) {
renderDayContent(state, persist);
}
return;
}
const addBtn = e.target.closest('.planner-add-meal');
if (addBtn) {
const slotId = addBtn.getAttribute('data-slot-id');
state.pickerSlot = slotId;
const searchInput = document.getElementById('planner-picker-search');
if (searchInput) searchInput.value = '';
const pickerScroll = document.getElementById('planner-picker-scroll');
if (pickerScroll) pickerScroll.scrollTop = 0;
renderPickerGrid(slotId, state.plans);
openSheet(pickerBackdrop, pickerSheet);
window.setTimeout(() => {
if (pickerScroll) pickerScroll.scrollTop = 0;
}, 40);
return;
}
const editBtn = e.target.closest('.planner-edit-meal');
if (editBtn) {
const slotId = editBtn.getAttribute('data-slot-id');
const entryId = editBtn.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const arr = state.plans[key]?.[slotId];
if (!Array.isArray(arr)) return;
const entry = arr.find((x) => x && x.id === entryId);
if (!entry) return;
window.openMealPlanEditor?.({
mode: 'edit',
recipeId: entry.recipeId,
date: state.selected,
slotId,
entry,
});
return;
}
const openRecipe = e.target.closest('.planner-open-recipe');
if (openRecipe) {
const recipeId = openRecipe.getAttribute('data-recipe-id');
if (recipeId && window.openRecipeDetail) {
const slotId = openRecipe.closest('[data-slot-id]')?.getAttribute('data-slot-id');
const entryId = openRecipe.closest('[data-entry-id]')?.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const entries = slotId ? state.plans[key]?.[slotId] : null;
const entry = Array.isArray(entries) ? entries.find((item) => item && item.id === entryId) : null;
window.openRecipeDetail(recipeId, entry ? { plannedEntry: entry } : {});
}
return;
}
});
const closePicker = () => {
state.pickerSlot = null;
const searchInput = document.getElementById('planner-picker-search');
if (searchInput) searchInput.value = '';
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
closeSheet(pickerBackdrop, pickerSheet);
};
window.getPlannerPickerFilterState = () => ({
...pickerFilterState,
slots: [...pickerFilterState.slots],
tags: [...pickerFilterState.tags],
});
window.applyPlannerPickerFilters = (nextState = {}) => {
Object.assign(pickerFilterState, nextState);
pickerFilterState.slots = Array.isArray(nextState.slots)
? [...nextState.slots]
: [...pickerFilterState.slots];
pickerFilterState.tags = Array.isArray(nextState.tags)
? [...nextState.tags]
: [...pickerFilterState.tags];
if (state.pickerSlot) {
const searchValue = document.getElementById('planner-picker-search')?.value || '';
renderPickerGrid(state.pickerSlot, state.plans, searchValue);
}
};
document.getElementById('planner-picker-search')?.addEventListener('input', (e) => {
if (state.pickerSlot) {
renderPickerGrid(state.pickerSlot, state.plans, e.target.value);
}
});
bindPlannerSheetDragClose(pickerSheet, closePicker, {
dragSurface: pickerSheet,
scrollEl: pickerScroll,
});
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
pickerBackdrop?.addEventListener('click', closePicker);
document.getElementById('planner-picker-grid')?.addEventListener('click', (e) => {
const pick = e.target.closest('.recipe-browser-card');
if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id');
if (!recipeId || !RECIPES[recipeId]) return;
const slotId = state.pickerSlot;
closePicker();
window.requestAnimationFrame(() => {
window.openMealPlanEditor?.({
mode: 'add',
recipeId,
date: state.selected,
slotId,
});
});
});
document.getElementById('planner-open-ingredients')?.addEventListener('click', () => {
if (sumDayNutrition(getDayPlan(state.plans, state.selected)).mealCount === 0) return;
renderIngredientsSheet(state);
openSheet(ingBackdrop, ingSheet);
});
ingBackdrop?.addEventListener('click', () => {
closeSheet(ingBackdrop, ingSheet);
});
ingSheet?.addEventListener('click', (e) => {
const row = e.target.closest('.planner-ing-row');
if (!row || !ingSheet.contains(row)) return;
row.classList.toggle('ingredient-active');
});
document.getElementById('planner-ing-add-all')?.addEventListener('click', () => {
const items = state._todayShortfalls || [];
if (items.length === 0) return;
const lines = items.map((it) => ({
ingredientId: it.ingredientId,
amount: it.shortfall,
unit: it.pantryUnit,
category: it.category,
sourceNote: 'Braki z planu dnia',
}));
addOrMergeShoppingLines(lines);
showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`);
window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});
document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => {
const items = state._allForecastShortfalls || [];
if (items.length === 0) return;
const map = new Map();
for (const it of items) {
const key = it.ingredientId;
if (map.has(key)) {
const cur = map.get(key);
cur.amount = Math.round((cur.amount + it.shortfall) * 10) / 10;
} else {
map.set(key, {
ingredientId: it.ingredientId,
amount: it.shortfall,
unit: it.pantryUnit,
category: it.category,
sourceNote: 'Braki z planu tygodnia',
});
}
}
const lines = [...map.values()];
addOrMergeShoppingLines(lines);
showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`);
window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});
rerender();
window.refreshPlanner = () => {
state.plans = loadPlans();
rerender();
};
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('calendar-swipe-zone'),
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
getMode: () => state.mode,
setMode: (mode) => {
state.mode = mode;
},
getWeekAnchor: () => state.weekStart,
setWeekAnchor: (date) => {
state.weekStart = startOfWeekMonday(date);
},
getMonthAnchor: () => state.monthAnchor,
setMonthAnchor: (date) => {
state.monthAnchor = startOfMonth(date);
},
getSelectedDate: () => state.selected,
setSelectedDate: (date) => {
state.selected = startOfDay(date);
},
rerender,
resolveDayState: resolveCalendarDayState,
selectOnNavigateOutside: false,
});
document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
} else {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
}
rerender();
});
requestAnimationFrame(() => {
const ww = document.getElementById('calendar-week-wrap');
const mw = document.getElementById('calendar-month-wrap');
const t = 'max-height 300ms ease, opacity 200ms ease';
if (ww) ww.style.transition = t;
if (mw) mw.style.transition = t;
});
}