1376 lines
63 KiB
JavaScript
1376 lines
63 KiB
JavaScript
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-7 h-7 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-[10px]" 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="planner-kcal-pill text-[10px] font-semibold tabular-nums shrink-0 px-2 py-0.5 rounded-full" style="color:rgb(var(--text-body-soft-rgb));">${slotKcal} kcal</span>`
|
||
: '';
|
||
|
||
const filledCard = `
|
||
<div class="rounded-2xl 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-4 py-3 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-3 pb-3 space-y-2 border-t border-[rgb(var(--card-strong-rgb))]" style="padding-top:0.75rem;">${entryCards}</div>` : ''}
|
||
</div>`;
|
||
|
||
if (entries.length > 0) return filledCard;
|
||
|
||
return `
|
||
<div class="rounded-2xl 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-4 py-3">
|
||
<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-4 pb-3 -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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function getRecentRecipeIds(plans, limit = 5) {
|
||
const seen = new Map();
|
||
const keys = Object.keys(plans).sort().reverse();
|
||
for (const key of keys) {
|
||
const day = plans[key];
|
||
if (!day) continue;
|
||
for (const slotId of Object.keys(day)) {
|
||
if (slotId === '_skipped') continue;
|
||
const entries = day[slotId];
|
||
if (!Array.isArray(entries)) continue;
|
||
for (const e of entries) {
|
||
if (e?.recipeId && RECIPES[e.recipeId] && !seen.has(e.recipeId)) {
|
||
seen.set(e.recipeId, true);
|
||
if (seen.size >= limit) return [...seen.keys()];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return [...seen.keys()];
|
||
}
|
||
|
||
function sortPickerRecipes(recipes, plans) {
|
||
const recentIndex = new Map(
|
||
getRecentRecipeIds(plans, 12).map((recipeId, index) => [recipeId, index]),
|
||
);
|
||
|
||
return [...recipes].sort((a, b) => {
|
||
const aRecent = recentIndex.has(a.id) ? recentIndex.get(a.id) : Number.POSITIVE_INFINITY;
|
||
const bRecent = recentIndex.has(b.id) ? recentIndex.get(b.id) : Number.POSITIVE_INFINITY;
|
||
|
||
if (aRecent !== bRecent) return aRecent - bRecent;
|
||
return a.title.localeCompare(b.title, 'pl');
|
||
});
|
||
}
|
||
|
||
function matchesPickerFilters(recipe, filterState) {
|
||
const slots = Array.isArray(filterState?.slots) ? filterState.slots : [];
|
||
const tags = Array.isArray(filterState?.tags) ? filterState.tags : [];
|
||
const minMinutes = Number.isFinite(filterState?.minMinutes)
|
||
? filterState.minMinutes
|
||
: PICKER_FILTER_MIN_MINUTES;
|
||
const maxMinutes = Number.isFinite(filterState?.maxMinutes)
|
||
? filterState.maxMinutes
|
||
: PICKER_FILTER_MAX_MINUTES;
|
||
|
||
if (slots.length > 0 && !recipe.allowedSlots.some((slotId) => slots.includes(slotId))) return false;
|
||
|
||
if (tags.length > 0) {
|
||
const recipeTags = (recipe.tags || []).map((tag) => tag.toLowerCase());
|
||
if (!tags.some((tag) => recipeTags.includes(tag.toLowerCase()))) return false;
|
||
}
|
||
|
||
if (minMinutes > PICKER_FILTER_MIN_MINUTES && recipe.minutes < minMinutes) return false;
|
||
if (maxMinutes < PICKER_FILTER_MAX_MINUTES && recipe.minutes > maxMinutes) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
function renderPickerGrid(slotId, plans, query = '') {
|
||
const grid = document.getElementById('planner-picker-grid');
|
||
const emptyState = document.getElementById('planner-picker-empty-state');
|
||
if (!grid || !emptyState) return;
|
||
|
||
const pickerFilters = window.getPlannerPickerFilterState?.() || {
|
||
slots: [],
|
||
tags: [],
|
||
minMinutes: PICKER_FILTER_MIN_MINUTES,
|
||
maxMinutes: PICKER_FILTER_MAX_MINUTES,
|
||
};
|
||
const filtered = filterRecipesByQuery(recipesForSlot(slotId), query)
|
||
.filter((recipe) => matchesPickerFilters(recipe, pickerFilters));
|
||
const sorted = sortPickerRecipes(filtered, plans);
|
||
|
||
renderRecipeGrid({
|
||
gridEl: grid,
|
||
emptyStateEl: emptyState,
|
||
recipes: sorted,
|
||
showSlotLabels: false,
|
||
cardClassName: 'recipe-list-card',
|
||
});
|
||
}
|
||
|
||
function plIngredientWord(n) {
|
||
if (n === 1) return 'składnik';
|
||
const m10 = n % 10;
|
||
const m100 = n % 100;
|
||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'składniki';
|
||
return 'składników';
|
||
}
|
||
|
||
function updateIngButtons(state) {
|
||
const btn1 = document.getElementById('planner-ing-add-all');
|
||
const btn2 = document.getElementById('planner-ing-add-btn');
|
||
|
||
const todayCount = (state._todayShortfalls || []).length;
|
||
const allCount = (state._allForecastShortfalls || []).length;
|
||
|
||
if (btn1) {
|
||
if (todayCount > 0) {
|
||
btn1.classList.remove('hidden');
|
||
btn1.disabled = false;
|
||
btn1.innerHTML = `<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))]">·</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-[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))]">·</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">−${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 calBar = document.getElementById('planner-cal-bar');
|
||
if (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);pointer-events:none;';
|
||
calBar.appendChild(shadow);
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|