diff --git a/stacks/recipe/js/views/MealPlanner.js b/stacks/recipe/js/views/MealPlanner.js index 96ea896..3fb41c8 100644 --- a/stacks/recipe/js/views/MealPlanner.js +++ b/stacks/recipe/js/views/MealPlanner.js @@ -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 ` -