Files
recipe-mockup/js/views/MealPlanner.js
2026-04-08 15:51:33 +02:00

1031 lines
47 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=8';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addMonths,
addWeeks,
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,
formatCalendarMonthYear,
formatCalendarSelectedDate,
isCalendarOnToday,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=1';
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}))`;
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),
);
}
export function getMealPlannerHTML() {
return `
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-[#2d2e2b] z-10 pb-24">
<div id="planner-cal-bar" class="shrink-0 bg-[#2d2e2b] border-b border-[#444442] mt-3 relative z-10">
${createCalendarTopbarHTML({
titleId: 'cal-period-label',
prevId: 'cal-prev',
todayId: 'cal-go-today',
nextId: 'cal-next',
titleClass: 'text-[18px] font-semibold text-[#ddd6ca] leading-none tracking-[-0.03em]',
})}
<div id="calendar-swipe-zone" class="overflow-x-hidden bg-[#2d2e2b]" style="touch-action: none">
<div id="calendar-week-wrap" class="px-3 overflow-x-hidden bg-[#2d2e2b]" style="overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-week-grid" class="grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden"></div>
</div>
<div id="calendar-month-wrap" class="px-3 bg-[#2d2e2b]" style="overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-month-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<div id="calendar-drag-handle" class="flex items-center justify-center pb-2 pt-0.5">
<span id="calendar-handle-icon" class="${CALENDAR_HANDLE_CLASS}" aria-hidden="true"></span>
</div>
</div>
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4 bg-[#2d2e2b]">
<div id="planner-summary-card" class="mb-3">
<div class="h-full flex flex-col" style="background:#2d2e2b !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-1.5 text-center" style="background:#393937;">
<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-1.5 text-center" style="background:#393937;">
<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-1.5 text-center" style="background:#393937;">
<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-1.5 text-center" style="background:#393937;">
<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ęgl.</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-[#444442] bg-[#2d2e2b] text-[13px] font-semibold text-[#d7d2c8] hover:border-[#6d6c67] hover:bg-[#3a3a37] transition-colors">
<i class="fas fa-shopping-basket text-[#9b978f] text-xs" aria-hidden="true"></i>
Składniki na ten dzień
</button>
<div id="planner-meal-slots" class="space-y-3 pb-2 bg-[#2d2e2b]"></div>
</div>
<div id="planner-picker-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-picker-sheet" class="absolute left-0 right-0 z-[50] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !important; background-image:none !important;" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
<div class="shrink-0 px-4 pt-3 pb-2 border-b border-[#444442] 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-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-picker-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Wybierz przepis</h2>
<p id="planner-picker-sub" class="text-[11px] text-[#9b978f] mt-1"></p>
</div>
<div class="shrink-0 px-4 pt-2 pb-2">
<input type="text" id="planner-picker-search" class="w-full rounded-xl border border-[#444442] bg-[#2f2f2d] px-3 py-2 text-sm text-[#ddd6ca] outline-none focus:border-[#6d6c67] placeholder:text-[#7d7a74]" placeholder="Szukaj przepisu…" />
</div>
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-2.5 pb-8 space-y-2"></div>
</div>
<div id="planner-ing-backdrop" class="absolute left-0 right-0 top-0 z-[45] bg-black/45 hidden opacity-0 transition-opacity duration-200" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}" aria-hidden="true"></div>
<div id="planner-ing-sheet" class="absolute left-0 right-0 z-[50] rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col will-change-transform" style="visibility: hidden; bottom: ${PLANNER_SHEET_BOTTOM_INSET}; height: auto; max-height: ${PLANNER_SHEET_MAX_HEIGHT}; transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); background:#2d2e2b !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-[#444442] 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-[#6d6c67]/75 rounded-full mx-auto mb-2.5" aria-hidden="true"></div>
<h2 id="planner-ing-title" class="text-[15px] font-bold text-[#ddd6ca] leading-tight pr-2">Składniki i spiżarnia</h2>
<p id="planner-ing-sub" class="text-[11px] text-[#9b978f] 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-[#444442] bg-[#2d2e2b] 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-[#444442] bg-[#2d2e2b] text-[#d7d2c8] hover:bg-[#3a3a37] 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-[#9b978f] 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-[55] 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>
</div>
`;
}
function updatePeriodLabel(mode, weekStart, monthAnchor, selected) {
const el = document.getElementById('cal-period-label');
if (!el) return;
if (mode === 'week') {
el.textContent = formatCalendarSelectedDate(selected);
} else {
el.textContent = formatCalendarMonthYear(monthAnchor);
}
}
function syncModeToggle(mode) {
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
handleEl: document.getElementById('calendar-handle-icon'),
});
}
function bindCalendarSwipeGesture(state, rerender) {
const zone = document.getElementById('calendar-swipe-zone');
if (!zone) return;
let startY = 0;
let ptrId = null;
let moved = false;
zone.addEventListener('pointerdown', (e) => {
if (ptrId !== null) return;
startY = e.clientY;
ptrId = e.pointerId;
moved = false;
});
zone.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId) return;
if (Math.abs(e.clientY - startY) > 10) moved = true;
});
zone.addEventListener('pointerup', (e) => {
if (e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
ptrId = null;
if (!moved || Math.abs(dy) < 30) return;
let switched = false;
if (state.mode === 'week' && dy > 30) {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
switched = true;
} else if (state.mode === 'month' && dy < -30) {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
switched = true;
}
if (switched) {
zone.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}, { capture: true, once: true });
rerender();
}
});
zone.addEventListener('pointercancel', () => {
ptrId = null;
moved = false;
});
}
function showPlannerToast(message) {
const wrap = document.getElementById('planner-toast');
const text = document.getElementById('planner-toast-text');
if (!wrap || !text) return;
text.textContent = message;
wrap.classList.remove('opacity-0', 'translate-y-2');
wrap.classList.add('opacity-100', 'translate-y-0');
clearTimeout(showPlannerToast._t);
showPlannerToast._t = setTimeout(() => {
wrap.classList.add('opacity-0', 'translate-y-2');
wrap.classList.remove('opacity-100', 'translate-y-0');
}, 2600);
}
function openSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.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 closeSheet(backdrop, sheet) {
if (!backdrop || !sheet) return;
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM;
backdrop.classList.add('opacity-0');
setTimeout(() => {
backdrop.classList.add('hidden');
sheet.style.visibility = 'hidden';
}, 300);
}
/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */
function bindPlannerSheetDragClose(sheet, closeFn) {
const zone = sheet.querySelector('[data-planner-sheet-drag-zone]');
if (!zone || !sheet) return;
let startY = 0;
let pulling = false;
let ptrId = null;
const resetVisual = () => {
sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)';
sheet.style.transform = 'translateY(0)';
};
zone.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
pulling = true;
ptrId = e.pointerId;
startY = e.clientY;
sheet.style.transition = 'none';
zone.setPointerCapture(e.pointerId);
});
zone.addEventListener('pointermove', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = Math.max(0, e.clientY - startY);
sheet.style.transform = `translateY(${dy}px)`;
});
zone.addEventListener('pointerup', (e) => {
if (!pulling || e.pointerId !== ptrId) return;
const dy = e.clientY - startY;
pulling = false;
ptrId = null;
try {
zone.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
if (dy > 56) {
closeFn();
return;
}
resetVisual();
});
zone.addEventListener('pointercancel', () => {
pulling = false;
ptrId = null;
resetVisual();
});
}
function renderDayContent(state) {
const sel = state.selected;
const 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 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-[#6d6c67]">·</span>×${servings}` : '';
return `
<div class="rounded-lg bg-[#2d2e2b] p-2" style="box-shadow:inset 0 1px 3px rgba(0,0,0,0.3);" data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] 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="text-[13px] font-bold text-[#ddd6ca] truncate underline decoration-1 underline-offset-2">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[#6d6c67]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] 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>
<button type="button" class="planner-clear-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-red-400 hover:border-red-300/60 hover:bg-[#3a2326] transition-colors flex items-center justify-center" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Usuń ten przepis">
<i class="fas fa-times text-[9px]" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`;
}).join('');
const addBtn = `<button type="button" class="planner-add-meal w-7 h-7 rounded-full border border-[#444442] text-[#9b978f] 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:#2d2e2b; color:#d7d2c8;">${slotKcal} kcal</span>`
: '';
const filledCard = `
<div class="rounded-xl bg-[#393937] overflow-hidden" style="background:#393937 !important; box-shadow:0 2px 8px rgba(0,0,0,0.25);" data-slot-id="${slot.id}">
<div class="flex items-center gap-2 px-3 py-2.5 bg-[#393937]" style="background:#393937 !important;">
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[#9b978f] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[#ddd6ca] 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-[#444442]" style="padding-top:0.625rem;">${entryCards}</div>` : ''}
</div>`;
if (entries.length > 0) return filledCard;
return `
<div class="rounded-xl bg-[#393937] overflow-hidden" style="background:#393937 !important; box-shadow:0 2px 8px rgba(0,0,0,0.25);" 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-[#9b978f] shrink-0" aria-hidden="true"></i>
<span class="text-[13px] font-semibold text-[#ddd6ca] 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-[#7d7a74] italic pl-9">Zaplanuj posiłek</p>
</div>
</div>`;
}).join('');
}
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 recipeCardHtml(r) {
return `
<button type="button" class="planner-pick-recipe w-full flex gap-2.5 p-2.5 rounded-xl border border-[#444442] bg-[#2d2e2b] hover:border-[#6d6c67] hover:bg-[#3a3a37] text-left transition-all" data-recipe-id="${r.id}">
<div class="w-11 h-11 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
${r.image
? `<img src="${escapeHtml(r.image)}" alt="" class="w-full h-full object-cover">`
: `<span class="w-full h-full flex items-center justify-center text-white text-[9px] font-medium">${escapeHtml(r.thumbLabel)}</span>`}
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-[13px] font-bold text-[#ddd6ca] line-clamp-2">${escapeHtml(r.title)}</p>
<p class="text-[11px] text-[#9b978f] mt-1 tabular-nums">
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${r.nutritionPerServing.kcal} kcal
<span class="mx-1 text-[#6d6c67]">·</span>
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${r.minutes} min
</p>
</div>
</button>`;
}
let _pickerSlotRecipes = [];
let _pickerPlans = {};
function renderPickerList(slotId, plans, query = '') {
const slot = MEAL_SLOTS.find((s) => s.id === slotId);
const list = document.getElementById('planner-picker-list');
const title = document.getElementById('planner-picker-title');
const sub = document.getElementById('planner-picker-sub');
if (!list || !title || !sub) return;
title.textContent = 'Wybierz przepis';
sub.textContent = slot ? `Dla: ${slot.label}` : '';
const allRecipes = recipesForSlot(slotId);
_pickerSlotRecipes = allRecipes;
_pickerPlans = plans;
const q = query.trim().toLowerCase();
const filtered = q
? allRecipes.filter((r) => r.title.toLowerCase().includes(q) || (r.tags || []).some((t) => t.toLowerCase().includes(q)))
: allRecipes;
if (filtered.length === 0 && q) {
list.innerHTML = '<p class="text-sm text-[#9b978f] text-center py-6">Brak wyników.</p>';
return;
}
if (filtered.length === 0) {
list.innerHTML = '<p class="text-sm text-[#9b978f] text-center py-6">Brak dopasowanych przepisów.</p>';
return;
}
let html = '';
if (!q) {
const recentIds = getRecentRecipeIds(plans);
const recentInSlot = recentIds.map((id) => RECIPES[id]).filter((r) => r && r.allowedSlots.includes(slotId));
if (recentInSlot.length > 0) {
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1"><i class="fas fa-history text-[9px] mr-1"></i>Ostatnio używane</p>`;
html += recentInSlot.map(recipeCardHtml).join('');
html += `<div class="border-t border-[#444442] my-2"></div>`;
html += `<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider px-0.5 pt-1 pb-1">Wszystkie</p>`;
}
}
html += filtered.map(recipeCardHtml).join('');
list.innerHTML = html;
}
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-[#9b978f] 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-[#2d2e2b] border border-emerald-400/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[#24352a] 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-[#2d2e2b] border border-red-300/40 p-3 mb-4 flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[#3a2326] 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-[#2d2e2b] divide-y divide-[#3a2326]">
${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-[#ddd6ca]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&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-[#444442] rounded-xl overflow-hidden bg-[#2d2e2b] divide-y divide-[#353632]">
${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-[#d7d2c8]">${escapeHtml(ing.name)}</p>
<p class="text-[11px] text-[#9b978f] mt-0.5">
potrzeba <span class="font-medium text-[#d7d2c8]">${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)}</span>
<span class="mx-1 text-[#6d6c67]">&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-[#2d2e2b] 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,
pickerSlot: null,
};
const weekGrid = document.getElementById('calendar-week-grid');
const monthGrid = document.getElementById('calendar-month-grid');
const pickerBackdrop = document.getElementById('planner-picker-backdrop');
const pickerSheet = document.getElementById('planner-picker-sheet');
const ingBackdrop = document.getElementById('planner-ing-backdrop');
const ingSheet = document.getElementById('planner-ing-sheet');
const rerender = () => {
syncModeToggle(state.mode);
updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor, state.selected);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => ({
dimmed: meta.mode === 'month' && !meta.inCurrentMonth,
showIndicator: meta.mode === 'month'
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
: dayHasAnyMeal(state.plans, day),
}),
});
renderDayContent(state);
};
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(0,0,0,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-prev')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, -1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, -1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-next')?.addEventListener('click', () => {
if (state.mode === 'week') {
state.weekStart = addWeeks(state.weekStart, 1);
if (!weekContains(state.weekStart, state.selected)) {
state.selected = new Date(state.weekStart);
}
} else {
state.monthAnchor = addMonths(state.monthAnchor, 1);
if (!sameMonth(state.monthAnchor, state.selected)) {
state.selected = startOfMonth(state.monthAnchor);
}
}
rerender();
});
document.getElementById('cal-go-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
state.selected = today;
state.weekStart = startOfWeekMonday(today);
state.monthAnchor = startOfMonth(today);
rerender();
});
document.getElementById('planner-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 openRecipe = e.target.closest('.planner-open-recipe');
if (openRecipe) {
const recipeId = openRecipe.getAttribute('data-recipe-id');
if (recipeId && window.openRecipeDetail) {
window.openRecipeDetail(recipeId);
}
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 = '';
renderPickerList(slotId, state.plans);
openSheet(pickerBackdrop, pickerSheet);
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 clearBtn = e.target.closest('.planner-clear-meal');
if (clearBtn) {
const slotId = clearBtn.getAttribute('data-slot-id');
const entryId = clearBtn.getAttribute('data-entry-id');
const key = dateKey(state.selected);
const arr = state.plans[key]?.[slotId];
if (!Array.isArray(arr) || !entryId) return;
const next = arr.filter((x) => x && x.id !== entryId);
if (!state.plans[key]) state.plans[key] = {};
if (next.length === 0) delete state.plans[key][slotId];
else state.plans[key][slotId] = next;
if (Object.keys(state.plans[key]).length === 0) delete state.plans[key];
persist();
return;
}
});
const closePicker = () => {
state.pickerSlot = null;
closeSheet(pickerBackdrop, pickerSheet);
};
document.getElementById('planner-picker-search')?.addEventListener('input', (e) => {
if (state.pickerSlot) {
renderPickerList(state.pickerSlot, state.plans, e.target.value);
}
});
bindPlannerSheetDragClose(pickerSheet, closePicker);
bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet));
pickerBackdrop?.addEventListener('click', closePicker);
document.getElementById('planner-picker-list')?.addEventListener('click', (e) => {
const pick = e.target.closest('.planner-pick-recipe');
if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id');
if (!recipeId || !RECIPES[recipeId]) return;
const slotId = state.pickerSlot;
closePicker();
setTimeout(() => {
window.openMealPlanEditor?.({
mode: 'add',
recipeId,
date: state.selected,
slotId,
});
}, 320);
});
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);
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;
});
}