Improve meal planner
This commit is contained in:
@@ -3,6 +3,146 @@ const MONTHS_SHORT = [
|
||||
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
|
||||
];
|
||||
const WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd'];
|
||||
const WEEKDAYS_LONG = [
|
||||
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
||||
];
|
||||
|
||||
const MEAL_SLOTS = [
|
||||
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
|
||||
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
|
||||
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
|
||||
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
|
||||
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
|
||||
];
|
||||
|
||||
/** Katalog przepisów (spójny z listą w aplikacji) — porcja bazowa = 1 */
|
||||
const PLANNER_RECIPES = {
|
||||
placki: {
|
||||
id: 'placki',
|
||||
title: 'Puszyste placki',
|
||||
minutes: 15,
|
||||
thumbLabel: 'Placki',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
||||
ingredients: [
|
||||
{ name: 'Mąka pszenna', amount: 200, unit: 'g' },
|
||||
{ name: 'Mleko', amount: 250, unit: 'ml' },
|
||||
{ name: 'Jajka', amount: 2, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
salatka: {
|
||||
id: 'salatka',
|
||||
title: 'Sałatka z kurczakiem',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Sałatka',
|
||||
allowedSlots: ['obiad'],
|
||||
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
||||
ingredients: [
|
||||
{ name: 'Pierś z kurczaka', amount: 150, unit: 'g' },
|
||||
{ name: 'Mix sałat', amount: 100, unit: 'g' },
|
||||
{ name: 'Pomidor', amount: 1, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
makaron: {
|
||||
id: 'makaron',
|
||||
title: 'Makaron z pomidorami i bazylią',
|
||||
minutes: 30,
|
||||
thumbLabel: 'Makaron',
|
||||
allowedSlots: ['obiad', 'kolacja'],
|
||||
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
||||
ingredients: [
|
||||
{ name: 'Makaron', amount: 120, unit: 'g' },
|
||||
{ name: 'Pomidory krojone', amount: 400, unit: 'g' },
|
||||
{ name: 'Bazylia świeża', amount: 10, unit: 'g' },
|
||||
],
|
||||
},
|
||||
koktajl: {
|
||||
id: 'koktajl',
|
||||
title: 'Koktajl owocowy',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Koktajl',
|
||||
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
||||
ingredients: [
|
||||
{ name: 'Jogurt naturalny', amount: 200, unit: 'g' },
|
||||
{ name: 'Mieszanka jagód', amount: 150, unit: 'g' },
|
||||
{ name: 'Miód', amount: 15, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tost_awokado: {
|
||||
id: 'tost_awokado',
|
||||
title: 'Tost z awokado',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Tost',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
||||
ingredients: [
|
||||
{ name: 'Chleb na zakwasie', amount: 2, unit: 'kromki' },
|
||||
{ name: 'Awokado', amount: 1, unit: 'szt.' },
|
||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
||||
],
|
||||
},
|
||||
losos: {
|
||||
id: 'losos',
|
||||
title: 'Grillowany łosoś',
|
||||
minutes: 25,
|
||||
thumbLabel: 'Łosoś',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
||||
ingredients: [
|
||||
{ name: 'Filet z łososia', amount: 180, unit: 'g' },
|
||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
||||
{ name: 'Koper', amount: 5, unit: 'g' },
|
||||
],
|
||||
},
|
||||
tacos: {
|
||||
id: 'tacos',
|
||||
title: 'Tacos z wołowiną',
|
||||
minutes: 20,
|
||||
thumbLabel: 'Tacos',
|
||||
allowedSlots: ['kolacja', 'obiad'],
|
||||
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
||||
ingredients: [
|
||||
{ name: 'Mięso mielone wołowe', amount: 200, unit: 'g' },
|
||||
{ name: 'Tortille kukurydziane', amount: 4, unit: 'szt.' },
|
||||
{ name: 'Salsa pomidorowa', amount: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
owsianka: {
|
||||
id: 'owsianka',
|
||||
title: 'Miska owsianki',
|
||||
minutes: 10,
|
||||
thumbLabel: 'Owsianka',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
||||
ingredients: [
|
||||
{ name: 'Płatki owsiane', amount: 60, unit: 'g' },
|
||||
{ name: 'Mleko', amount: 200, unit: 'ml' },
|
||||
{ name: 'Miód', amount: 20, unit: 'g' },
|
||||
],
|
||||
},
|
||||
serek_owoc: {
|
||||
id: 'serek_owoc',
|
||||
title: 'Serek wiejski z orzechami i owocami',
|
||||
minutes: 5,
|
||||
thumbLabel: 'Serek',
|
||||
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
||||
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
||||
ingredients: [
|
||||
{ name: 'Serek wiejski', amount: 200, unit: 'g' },
|
||||
{ name: 'Miód', amount: 10, unit: 'g' },
|
||||
{ name: 'Orzechy włoskie', amount: 50, unit: 'g' },
|
||||
{ name: 'Truskawki', amount: 100, unit: 'g' },
|
||||
{ name: 'Borówki ameryk.', amount: 80, unit: 'g' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
|
||||
|
||||
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
|
||||
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
|
||||
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
||||
|
||||
function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
@@ -56,6 +196,159 @@ function sameMonth(a, b) {
|
||||
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
const x = startOfDay(d);
|
||||
const y = x.getFullYear();
|
||||
const m = String(x.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(x.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function newPlanEntryId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } (stary format: jeden obiekt — migrujemy przy wczytaniu). */
|
||||
function normalizeSlotValue(v) {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
.filter((x) => x && x.recipeId && PLANNER_RECIPES[x.recipeId])
|
||||
.map((x) => ({
|
||||
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
|
||||
recipeId: x.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
|
||||
}));
|
||||
}
|
||||
if (typeof v === 'object' && v.recipeId && PLANNER_RECIPES[v.recipeId]) {
|
||||
return [{
|
||||
id: newPlanEntryId(),
|
||||
recipeId: v.recipeId,
|
||||
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeDayPlan(day) {
|
||||
if (!day || typeof day !== 'object') return {};
|
||||
const out = {};
|
||||
MEAL_SLOTS.forEach((s) => {
|
||||
const arr = normalizeSlotValue(day[s.id]);
|
||||
if (arr.length > 0) out[s.id] = arr;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeAllPlans(plans) {
|
||||
if (!plans || typeof plans !== 'object') return {};
|
||||
const out = {};
|
||||
Object.keys(plans).forEach((key) => {
|
||||
const d = normalizeDayPlan(plans[key]);
|
||||
if (Object.keys(d).length > 0) out[key] = d;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadPlans() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
return normalizeAllPlans(parsed);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePlans(plans) {
|
||||
try {
|
||||
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getDayPlan(plans, d) {
|
||||
const key = dateKey(d);
|
||||
const day = plans[key];
|
||||
return day && typeof day === 'object' ? day : {};
|
||||
}
|
||||
|
||||
function dayHasAnyMeal(plans, d) {
|
||||
const p = getDayPlan(plans, d);
|
||||
return MEAL_SLOTS.some((s) => {
|
||||
const arr = p[s.id];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function sumDayNutrition(dayPlan) {
|
||||
let kcal = 0;
|
||||
let protein = 0;
|
||||
let fat = 0;
|
||||
let carbs = 0;
|
||||
let mealCount = 0;
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = PLANNER_RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
mealCount += 1;
|
||||
kcal += r.nutritionPerServing.kcal * s;
|
||||
protein += r.nutritionPerServing.protein * s;
|
||||
fat += r.nutritionPerServing.fat * s;
|
||||
carbs += r.nutritionPerServing.carbs * s;
|
||||
});
|
||||
});
|
||||
return {
|
||||
kcal: Math.round(kcal),
|
||||
protein: Math.round(protein * 10) / 10,
|
||||
fat: Math.round(fat * 10) / 10,
|
||||
carbs: Math.round(carbs * 10) / 10,
|
||||
mealCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
||||
* @returns {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]}
|
||||
*/
|
||||
function aggregateDayIngredientsBySlot(dayPlan) {
|
||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]} */
|
||||
const blocks = [];
|
||||
MEAL_SLOTS.forEach((slot) => {
|
||||
const entries = dayPlan[slot.id];
|
||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
||||
const recipes = [];
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.recipeId) return;
|
||||
const r = PLANNER_RECIPES[entry.recipeId];
|
||||
if (!r) return;
|
||||
const s = Math.max(1, Number(entry.servings) || 1);
|
||||
const items = r.ingredients.map((ing) => ({
|
||||
name: ing.name,
|
||||
amount: Math.round(ing.amount * s * 10) / 10,
|
||||
unit: ing.unit,
|
||||
}));
|
||||
recipes.push({ recipeTitle: r.title, items });
|
||||
});
|
||||
if (recipes.length > 0) {
|
||||
blocks.push({ mealLabel: slot.label, recipes });
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function recipesForSlot(slotId) {
|
||||
return Object.values(PLANNER_RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
||||
}
|
||||
|
||||
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
|
||||
const today = startOfDay(new Date());
|
||||
if (!sameDay(selected, today)) return false;
|
||||
@@ -75,7 +368,7 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
|
||||
|
||||
export function getMealPlannerHTML() {
|
||||
return `
|
||||
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 bg-gray-50 z-10 pb-24">
|
||||
<div id="planner-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||
<div class="shrink-0 bg-white border-b border-gray-200 mt-3">
|
||||
<div class="px-3 pt-2 pb-1.5 flex items-center gap-1">
|
||||
<button type="button" id="cal-prev" class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors" aria-label="Poprzedni okres">
|
||||
@@ -116,14 +409,89 @@ export function getMealPlannerHTML() {
|
||||
<div id="calendar-month-grid" class="grid grid-cols-7 gap-0.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 pt-3">
|
||||
<p class="text-sm text-gray-500 leading-relaxed">Dotknij dnia w kalendarzu, aby później przypisać posiłki. Ta sekcja na razie jest zapowiedzią rozbudowy planera.</p>
|
||||
|
||||
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4">
|
||||
<p id="planner-day-heading" class="text-sm font-semibold text-gray-900 tabular-nums mb-2"></p>
|
||||
<div id="planner-summary-card" class="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 to-white p-3 shadow-sm mb-3">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wide text-amber-900/70">Dziś — podsumowanie</p>
|
||||
<p id="planner-summary-kcal" class="text-2xl font-bold text-gray-900 tabular-nums leading-tight mt-0.5">0 <span class="text-sm font-semibold text-gray-500">kcal</span></p>
|
||||
</div>
|
||||
<button type="button" id="planner-toggle-nutrition" class="shrink-0 flex items-center gap-1 text-[11px] font-semibold text-amber-900/80 hover:text-gray-900 py-1 px-2 rounded-lg hover:bg-amber-100/50 transition-colors" aria-expanded="false">
|
||||
Szczegóły
|
||||
<i class="fas fa-chevron-down text-[9px] transition-transform" id="planner-nutrition-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="planner-macro-row" class="flex gap-2 mb-0">
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">B</p>
|
||||
<p id="planner-macro-p" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">T</p>
|
||||
<p id="planner-macro-f" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 rounded-lg bg-white/80 border border-amber-100 px-2 py-1.5 text-center">
|
||||
<p class="text-[9px] font-semibold text-gray-500 uppercase">W</p>
|
||||
<p id="planner-macro-c" class="text-xs font-bold text-gray-900 tabular-nums">0 g</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="planner-nutrition-details" class="hidden mt-3 pt-3 border-t border-amber-200/60">
|
||||
<ul class="space-y-0 divide-y divide-amber-100/80 text-sm">
|
||||
<li class="flex justify-between py-2 font-bold"><span class="text-gray-800">Kalorie</span><span id="planner-detail-kcal" class="text-gray-900 tabular-nums">0 kcal</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Białko</span><span id="planner-detail-p" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Tłuszcze</span><span id="planner-detail-f" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
<li class="flex justify-between py-2"><span class="text-gray-700 font-medium">Węglowodany</span><span id="planner-detail-c" class="font-medium text-gray-900 tabular-nums">0 g</span></li>
|
||||
</ul>
|
||||
<p id="planner-summary-hint" class="text-[11px] text-gray-500 mt-2">Suma z zaplanowanych posiłków (porcje × wartości z przepisu).</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="planner-open-ingredients" class="w-full mb-4 flex items-center justify-center gap-2 py-3 rounded-xl border border-dashed border-gray-300 bg-white text-sm font-semibold text-gray-800 hover:border-gray-400 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-shopping-basket text-gray-500" aria-hidden="true"></i>
|
||||
Składniki na ten dzień
|
||||
</button>
|
||||
<div id="planner-meal-slots" class="space-y-3 pb-2"></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] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-picker-title" aria-modal="true">
|
||||
<div class="shrink-0 p-4 pb-2 border-b border-gray-100 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-gray-200 rounded-full mx-auto mb-3" aria-hidden="true"></div>
|
||||
<h2 id="planner-picker-title" class="text-lg font-bold text-gray-900 leading-tight pr-2">Wybierz przepis</h2>
|
||||
<p id="planner-picker-sub" class="text-xs text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
<div id="planner-picker-list" class="min-h-0 flex-1 overflow-y-auto no-scrollbar px-4 py-3 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] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] flex flex-col min-h-0 will-change-transform" style="bottom: ${PLANNER_SHEET_BOTTOM_INSET}; max-height: calc(100% - ${PLANNER_SHEET_BOTTOM_INSET}); transform: ${PLANNER_SHEET_OFF_TRANSFORM}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="planner-ing-title" aria-modal="true">
|
||||
<div class="shrink-0 p-4 pb-2 border-b border-gray-100 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-gray-200 rounded-full mx-auto mb-3" aria-hidden="true"></div>
|
||||
<h2 id="planner-ing-title" class="text-lg font-bold text-gray-900 leading-tight pr-2">Składniki na dziś</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Dodaj wszystko lub zaznacz wiersze. Zamknij: przeciągnij w dół lub dotknij tła.</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 class="shrink-0 p-4 pt-2 pb-5 border-t border-gray-100 bg-white space-y-2">
|
||||
<button type="button" id="planner-ing-add-all" class="w-full bg-gray-900 hover:bg-black text-white py-3.5 rounded-xl font-semibold shadow-sm transition-colors text-sm flex items-center justify-center gap-2">
|
||||
<i class="fas fa-cart-plus" aria-hidden="true"></i>
|
||||
Dodaj wszystkie do listy zakupów
|
||||
</button>
|
||||
<button type="button" id="planner-ing-add-btn" class="w-full border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-colors">
|
||||
<i class="fas fa-check-square text-gray-500 text-xs" aria-hidden="true"></i>
|
||||
Dodaj tylko zaznaczone
|
||||
</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 renderWeekGrid(weekStart, selected) {
|
||||
function renderWeekGrid(weekStart, selected, plans) {
|
||||
const grid = document.getElementById('calendar-week-grid');
|
||||
if (!grid) return;
|
||||
|
||||
@@ -132,19 +500,21 @@ function renderWeekGrid(weekStart, selected) {
|
||||
const day = addDays(weekStart, i);
|
||||
const isSel = selected && sameDay(day, selected);
|
||||
const isToday = sameDay(day, new Date());
|
||||
const hasMeals = dayHasAnyMeal(plans, day);
|
||||
cells.push(`
|
||||
<button type="button" data-planner-day="${day.getTime()}"
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
|
||||
${isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100'}
|
||||
${isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
|
||||
<span>${day.getDate()}</span>
|
||||
${hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
grid.innerHTML = cells.join('');
|
||||
}
|
||||
|
||||
function renderMonthGrid(monthAnchor, selected) {
|
||||
function renderMonthGrid(monthAnchor, selected, plans) {
|
||||
const grid = document.getElementById('calendar-month-grid');
|
||||
if (!grid) return;
|
||||
|
||||
@@ -156,12 +526,14 @@ function renderMonthGrid(monthAnchor, selected) {
|
||||
const inMonth = day.getMonth() === first.getMonth();
|
||||
const isSel = selected && sameDay(day, selected);
|
||||
const isToday = sameDay(day, new Date());
|
||||
const hasMeals = inMonth && dayHasAnyMeal(plans, day);
|
||||
cells.push(`
|
||||
<button type="button" data-planner-day="${day.getTime()}"
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0
|
||||
class="aspect-square flex flex-col items-center justify-center rounded-md text-xs font-medium transition-colors min-h-0 gap-0.5 py-1
|
||||
${!inMonth ? 'text-gray-300' : (isSel ? 'bg-gray-900 text-white' : 'text-gray-800 hover:bg-gray-100')}
|
||||
${inMonth && isToday && !isSel ? 'ring-1 ring-inset ring-gray-900' : ''}">
|
||||
<span>${day.getDate()}</span>
|
||||
${inMonth && hasMeals ? `<span class="w-1 h-1 rounded-full ${isSel ? 'bg-white' : 'bg-gray-900'} opacity-80" aria-hidden="true"></span>` : '<span class="w-1 h-1" aria-hidden="true"></span>'}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
@@ -226,26 +598,328 @@ function bindDayClicks(container, state, rerender) {
|
||||
});
|
||||
}
|
||||
|
||||
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.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'), 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 heading = document.getElementById('planner-day-heading');
|
||||
if (heading) {
|
||||
const wd = WEEKDAYS_LONG[sel.getDay()];
|
||||
heading.textContent = `${wd}, ${sel.getDate()} ${MONTHS_SHORT[sel.getMonth()]}`;
|
||||
}
|
||||
|
||||
const dayPlan = getDayPlan(state.plans, sel);
|
||||
const totals = sumDayNutrition(dayPlan);
|
||||
|
||||
const kcalEl = document.getElementById('planner-summary-kcal');
|
||||
if (kcalEl) {
|
||||
kcalEl.innerHTML = totals.mealCount === 0
|
||||
? `— <span class="text-sm font-semibold text-gray-500">kcal</span>`
|
||||
: `${totals.kcal} <span class="text-sm font-semibold text-gray-500">kcal</span>`;
|
||||
}
|
||||
const fmt = (n) => `${n} g`;
|
||||
document.getElementById('planner-macro-p').textContent = totals.mealCount ? fmt(totals.protein) : '—';
|
||||
document.getElementById('planner-macro-f').textContent = totals.mealCount ? fmt(totals.fat) : '—';
|
||||
document.getElementById('planner-macro-c').textContent = totals.mealCount ? fmt(totals.carbs) : '—';
|
||||
|
||||
document.getElementById('planner-detail-kcal').textContent = `${totals.kcal} kcal`;
|
||||
document.getElementById('planner-detail-p').textContent = fmt(totals.protein);
|
||||
document.getElementById('planner-detail-f').textContent = fmt(totals.fat);
|
||||
document.getElementById('planner-detail-c').textContent = fmt(totals.carbs);
|
||||
|
||||
const ingBtn = document.getElementById('planner-open-ingredients');
|
||||
if (ingBtn) {
|
||||
ingBtn.disabled = totals.mealCount === 0;
|
||||
ingBtn.classList.toggle('opacity-50', totals.mealCount === 0);
|
||||
ingBtn.classList.toggle('cursor-not-allowed', totals.mealCount === 0);
|
||||
}
|
||||
|
||||
const slotsRoot = document.getElementById('planner-meal-slots');
|
||||
if (!slotsRoot) return;
|
||||
|
||||
slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => {
|
||||
const entries = Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : [];
|
||||
const countLabel = entries.length > 1
|
||||
? `<span class="text-[10px] font-semibold text-gray-400 tabular-nums shrink-0 ml-auto">${entries.length} dania</span>`
|
||||
: '';
|
||||
|
||||
const entryCards = entries.map((entry) => {
|
||||
const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null;
|
||||
if (!recipe) return '';
|
||||
const servings = Math.max(1, Number(entry.servings) || 1);
|
||||
const n = recipe.nutritionPerServing;
|
||||
const kcal = Math.round(n.kcal * servings);
|
||||
const eid = escapeHtml(entry.id);
|
||||
return `
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-2.5 shadow-sm" 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">
|
||||
<div class="w-9 h-9 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||
<span class="text-white text-[9px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold text-gray-900 truncate">${escapeHtml(recipe.title)}</p>
|
||||
<p class="text-[11px] text-gray-500 mt-0.5 tabular-nums">
|
||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
|
||||
<span class="mx-1.5 text-gray-300">·</span>
|
||||
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${kcal} kcal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="planner-clear-meal w-7 h-7 shrink-0 rounded-full border border-gray-200 text-gray-400 hover:text-red-600 hover:border-red-200 hover:bg-red-50 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-[10px]" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 mt-2 pt-2 border-t border-gray-100">
|
||||
<span class="text-[11px] font-medium text-gray-500">Porcje</span>
|
||||
<div class="flex items-center gap-0.5 bg-gray-100 p-0.5 rounded-lg">
|
||||
<button type="button" class="planner-serv-minus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Mniej porcji"><i class="fas fa-minus text-[9px]"></i></button>
|
||||
<span class="planner-serv-count font-bold text-gray-900 text-xs w-5 text-center tabular-nums">${servings}</span>
|
||||
<button type="button" class="planner-serv-plus w-6 h-6 bg-white rounded-md shadow-sm flex items-center justify-center text-gray-600 hover:text-black" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Więcej porcji"><i class="fas fa-plus text-[9px]"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny przepis';
|
||||
const addClasses = entries.length === 0
|
||||
? 'planner-add-meal w-full py-2.5 rounded-lg border border-dashed border-gray-200 text-sm font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors'
|
||||
: 'planner-add-meal w-full py-2 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors';
|
||||
|
||||
return `
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden" data-slot-id="${slot.id}">
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-gray-100 bg-gray-50/90">
|
||||
<span class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 shrink-0">
|
||||
<i class="fas ${slot.icon} text-sm" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-gray-900 truncate min-w-0 flex-1">${slot.label}</span>
|
||||
${countLabel}
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
${entryCards}
|
||||
<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>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderPickerList(slotId) {
|
||||
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}. Przeciągnij nagłówek w dół lub dotknij tła, by zamknąć.` : '';
|
||||
|
||||
const recipes = recipesForSlot(slotId);
|
||||
if (recipes.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-500 text-center py-6">Brak dopasowanych przepisów.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = recipes.map((r) => `
|
||||
<button type="button" class="planner-pick-recipe w-full flex gap-3 p-3 rounded-xl border border-gray-200 bg-gray-50/80 hover:border-gray-900 hover:bg-white text-left transition-all" data-recipe-id="${r.id}">
|
||||
<div class="w-14 h-14 rounded-lg bg-[#d4d4d4] flex items-center justify-center shrink-0">
|
||||
<span class="text-white text-[10px] font-medium">${escapeHtml(r.thumbLabel)}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 py-0.5">
|
||||
<p class="text-sm font-bold text-gray-900 line-clamp-2">${escapeHtml(r.title)}</p>
|
||||
<p class="text-[11px] text-gray-500 mt-1 tabular-nums">
|
||||
<i class="fas fa-fire text-gray-400 mr-0.5" aria-hidden="true"></i>${r.nutritionPerServing.kcal} kcal
|
||||
<span class="mx-1 text-gray-300">·</span>
|
||||
<i class="fas fa-clock text-gray-400 mr-0.5" aria-hidden="true"></i>${r.minutes} min
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderIngredientsSheet(state) {
|
||||
const body = document.getElementById('planner-ing-body');
|
||||
if (!body) return;
|
||||
|
||||
const dayPlan = getDayPlan(state.plans, state.selected);
|
||||
const blocks = aggregateDayIngredientsBySlot(dayPlan);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
body.innerHTML = '<p class="text-sm text-gray-500 text-center py-8">Najpierw zaplanuj posiłki.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = blocks.map((block) => `
|
||||
<div class="mb-6 last:mb-2">
|
||||
<p class="text-xs font-bold text-gray-400 uppercase tracking-wide mb-2">${escapeHtml(block.mealLabel)}</p>
|
||||
<div class="space-y-3">
|
||||
${block.recipes.map((rec) => `
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-800 mb-1.5">${escapeHtml(rec.recipeTitle)}</p>
|
||||
<ul class="space-y-0 border border-gray-100 rounded-xl overflow-hidden bg-white">
|
||||
${rec.items.map((ing) => `
|
||||
<li class="flex items-center gap-3 py-3 px-3 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors planner-ing-row">
|
||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors shrink-0"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||
<span class="text-gray-700 text-sm flex-1 ingredient-text transition-colors">${escapeHtml(ing.name)}</span>
|
||||
<span class="font-medium text-gray-900 text-sm tabular-nums">${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
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: 'owsianka', servings: 1 }],
|
||||
obiad: [{ id: newPlanEntryId(), recipeId: 'salatka', servings: 1 }],
|
||||
kolacja: [{ id: newPlanEntryId(), recipeId: 'makaron', 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,
|
||||
nutritionExpanded: false,
|
||||
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);
|
||||
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
|
||||
if (state.mode === 'week') {
|
||||
renderWeekGrid(state.weekStart, state.selected);
|
||||
renderWeekGrid(state.weekStart, state.selected, state.plans);
|
||||
} else {
|
||||
renderMonthGrid(state.monthAnchor, state.selected);
|
||||
renderMonthGrid(state.monthAnchor, state.selected, state.plans);
|
||||
}
|
||||
renderDayContent(state);
|
||||
};
|
||||
|
||||
const persist = () => {
|
||||
savePlans(state.plans);
|
||||
rerender();
|
||||
};
|
||||
|
||||
bindDayClicks(weekGrid?.parentElement, state, rerender);
|
||||
@@ -303,5 +977,117 @@ export function setupMealPlanner() {
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('planner-toggle-nutrition')?.addEventListener('click', () => {
|
||||
state.nutritionExpanded = !state.nutritionExpanded;
|
||||
const details = document.getElementById('planner-nutrition-details');
|
||||
const chev = document.getElementById('planner-nutrition-chevron');
|
||||
const btn = document.getElementById('planner-toggle-nutrition');
|
||||
if (details) details.classList.toggle('hidden', !state.nutritionExpanded);
|
||||
if (chev) chev.classList.toggle('rotate-180', state.nutritionExpanded);
|
||||
if (btn) btn.setAttribute('aria-expanded', state.nutritionExpanded ? 'true' : 'false');
|
||||
});
|
||||
|
||||
document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => {
|
||||
const addBtn = e.target.closest('.planner-add-meal');
|
||||
if (addBtn) {
|
||||
const slotId = addBtn.getAttribute('data-slot-id');
|
||||
state.pickerSlot = slotId;
|
||||
renderPickerList(slotId);
|
||||
openSheet(pickerBackdrop, pickerSheet);
|
||||
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 minus = e.target.closest('.planner-serv-minus');
|
||||
const plus = e.target.closest('.planner-serv-plus');
|
||||
const slotId = (minus || plus)?.getAttribute('data-slot-id');
|
||||
const entryId = (minus || plus)?.getAttribute('data-entry-id');
|
||||
if (!slotId || !entryId) return;
|
||||
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;
|
||||
let s = Math.max(1, Number(entry.servings) || 1);
|
||||
if (minus) s = Math.max(1, s - 1);
|
||||
if (plus) s = Math.min(12, s + 1);
|
||||
entry.servings = s;
|
||||
persist();
|
||||
});
|
||||
|
||||
const closePicker = () => {
|
||||
state.pickerSlot = null;
|
||||
closeSheet(pickerBackdrop, pickerSheet);
|
||||
};
|
||||
|
||||
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 || !PLANNER_RECIPES[recipeId]) return;
|
||||
const key = dateKey(state.selected);
|
||||
if (!state.plans[key]) state.plans[key] = {};
|
||||
const slotId = state.pickerSlot;
|
||||
if (!state.plans[key][slotId]) state.plans[key][slotId] = [];
|
||||
state.plans[key][slotId].push({ id: newPlanEntryId(), recipeId, servings: 1 });
|
||||
closePicker();
|
||||
persist();
|
||||
});
|
||||
|
||||
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 body = document.getElementById('planner-ing-body');
|
||||
const rows = body?.querySelectorAll('.planner-ing-row');
|
||||
const n = rows?.length ?? 0;
|
||||
if (n === 0) return;
|
||||
showPlannerToast(`Dodano ${n} składników do listy (zakładka Zakupy w przygotowaniu).`);
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => {
|
||||
const body = document.getElementById('planner-ing-body');
|
||||
const selected = body?.querySelectorAll('.planner-ing-row.ingredient-active');
|
||||
const n = selected?.length ?? 0;
|
||||
if (n === 0) {
|
||||
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
|
||||
return;
|
||||
}
|
||||
showPlannerToast(`Dodano ${n} zaznaczonych pozycji do listy (zakładka Zakupy w przygotowaniu).`);
|
||||
closeSheet(ingBackdrop, ingSheet);
|
||||
});
|
||||
|
||||
rerender();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user