1049 lines
49 KiB
JavaScript
1049 lines
49 KiB
JavaScript
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=2';
|
||
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';
|
||
import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js';
|
||
import {
|
||
dateKey,
|
||
getDayPlan,
|
||
loadPlans,
|
||
newPlanEntryId,
|
||
savePlans,
|
||
} from '../services/planStore.js';
|
||
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="w-full rounded-xl border px-3 py-2.5" style="background:#2f2f2d !important; border-color:#444442 !important; box-shadow:0 2px 8px rgba(0,0,0,0.25);">
|
||
<div class="grid grid-cols-4 gap-3 text-left">
|
||
<div class="min-w-0">
|
||
<span id="planner-nutrition-kcal" class="block text-[15px] font-semibold text-[#ddd6ca] tabular-nums leading-none">—</span>
|
||
<span class="text-[9px] text-gray-500">kcal</span>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<span id="planner-nutrition-p" class="block text-[15px] font-semibold text-[#ddd6ca] tabular-nums leading-none">—</span>
|
||
<span class="text-[9px] text-gray-500">Białko</span>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<span id="planner-nutrition-c" class="block text-[15px] font-semibold text-[#ddd6ca] tabular-nums leading-none">—</span>
|
||
<span class="text-[9px] text-gray-500">Węgle</span>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<span id="planner-nutrition-f" class="block text-[15px] font-semibold text-[#ddd6ca] tabular-nums leading-none">—</span>
|
||
<span class="text-[9px] text-gray-500">Tłuszcze</span>
|
||
</div>
|
||
</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.innerHTML = value === null
|
||
? '—'
|
||
: `${value}<span class="ml-0.5 text-[12px] font-medium text-[#9b978f]">g</span>`;
|
||
};
|
||
|
||
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 kcalBadge = slotKcal > 0
|
||
? `<span class="text-[10px] font-semibold text-[#f2efe8] tabular-nums shrink-0 ml-auto">${slotKcal} kcal</span>`
|
||
: '';
|
||
const countLabel = entries.length > 1
|
||
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0">${entries.length} dania</span>`
|
||
: '';
|
||
|
||
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('');
|
||
|
||
if (isSkipped) {
|
||
return `
|
||
<div class="rounded-xl bg-[#393937] overflow-hidden opacity-60" 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 border-b border-[#444442] bg-[#393937]" style="background:#393937 !important;">
|
||
<i class="fas ${slot.icon} w-7 text-center text-[13px] text-[#7d7a74] shrink-0" aria-hidden="true"></i>
|
||
<span class="text-[13px] font-semibold text-[#9b978f] truncate min-w-0 flex-1">${slot.label}</span>
|
||
</div>
|
||
<div class="p-2.5 flex items-center justify-between">
|
||
<span class="text-xs text-[#9b978f] italic"><i class="fas fa-forward text-[9px] mr-1.5"></i>Pominięto</span>
|
||
<button type="button" class="planner-unskip text-[11px] font-semibold text-[#9b978f] hover:text-[#ddd6ca] px-2 py-1 rounded-lg hover:bg-[#3a3a37] transition-colors" data-slot-id="${slot.id}">Cofnij</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny';
|
||
const addClasses = entries.length === 0
|
||
? 'planner-add-meal flex-1 py-2 rounded-lg border border-dashed border-[#444442] text-[13px] font-semibold text-[#d7d2c8] hover:bg-[#3a3a37] hover:border-[#6d6c67] transition-colors'
|
||
: 'planner-add-meal w-full py-1.5 rounded-lg border border-dashed border-[#444442] text-xs font-semibold text-[#d7d2c8] hover:bg-[#3a3a37] hover:border-[#6d6c67] transition-colors';
|
||
|
||
const skipBtn = entries.length === 0
|
||
? `<button type="button" class="planner-skip-meal shrink-0 py-2 px-3 rounded-lg text-[11px] font-semibold text-[#9b978f] hover:text-[#ddd6ca] hover:bg-[#3a3a37] transition-colors" data-slot-id="${slot.id}"><i class="fas fa-forward text-[9px] mr-1"></i>Pomijam</button>`
|
||
: '';
|
||
|
||
const bottomRow = entries.length === 0
|
||
? `<div class="flex gap-2">${`<button type="button" class="${addClasses}" data-slot-id="${slot.id}"><i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>${addLabel}</button>`}${skipBtn}</div>`
|
||
: `<button type="button" class="${addClasses}" data-slot-id="${slot.id}"><i class="fas fa-plus text-[10px] mr-1 opacity-70" aria-hidden="true"></i>${addLabel}</button>`;
|
||
|
||
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 border-b border-[#444442] 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 flex-1">${slot.label}</span>
|
||
${countLabel}
|
||
${kcalBadge}
|
||
</div>
|
||
<div class="p-2.5 space-y-2">
|
||
${entryCards}
|
||
${bottomRow}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function getRecentRecipeIds(plans, limit = 5) {
|
||
const seen = new Map();
|
||
const keys = Object.keys(plans).sort().reverse();
|
||
for (const key of keys) {
|
||
const day = plans[key];
|
||
if (!day) continue;
|
||
for (const slotId of Object.keys(day)) {
|
||
if (slotId === '_skipped') continue;
|
||
const entries = day[slotId];
|
||
if (!Array.isArray(entries)) continue;
|
||
for (const e of entries) {
|
||
if (e?.recipeId && RECIPES[e.recipeId] && !seen.has(e.recipeId)) {
|
||
seen.set(e.recipeId, true);
|
||
if (seen.size >= limit) return [...seen.keys()];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return [...seen.keys()];
|
||
}
|
||
|
||
function 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]">·</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">−${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]">·</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">−${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;
|
||
});
|
||
}
|