diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a490c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Use the lightweight Nginx image +FROM nginx:alpine + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/ +COPY manifest.webmanifest /usr/share/nginx/html/ +COPY sw.js /usr/share/nginx/html/ +COPY icons /usr/share/nginx/html/icons +COPY js /usr/share/nginx/html/js +COPY css /usr/share/nginx/html/css + +EXPOSE 80 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ec496dd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + recipe-mockup: + build: . + container_name: recipe-mockup + restart: unless-stopped + networks: + - homelab_apps + labels: + - "traefik.enable=true" + - "traefik.docker.network=homelab_apps" + - "traefik.http.routers.recipe.rule=Host(`${RECIPE_DOMAIN}`)" + - "traefik.http.routers.recipe.entrypoints=websecure" + - "traefik.http.routers.recipe.tls=true" + - "traefik.http.routers.recipe.tls.certresolver=le" + - "traefik.http.services.recipe.loadbalancer.server.port=80" + + # Authentik Protection +# - "traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" +# - "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true" +# - "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version" +# - "traefik.http.routers.recipe.middlewares=authentik" + +networks: + homelab_apps: + external: true \ No newline at end of file diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..31e470b Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000..f31fa2b Binary files /dev/null and b/icons/icon-192.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..ca5c9ef Binary files /dev/null and b/icons/icon-512.png differ diff --git a/index.html b/index.html index 544cab0..52c5a01 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,14 @@ + + + + Recipe App - Modular + + + - + -
+
+ \ No newline at end of file diff --git a/js/app.js b/js/app.js index d7a3d52..824b38a 100644 --- a/js/app.js +++ b/js/app.js @@ -2,25 +2,35 @@ import { getRecipeListHTML } from './views/RecipeList.js'; import { getFilterHTML, setupFilter } from './views/Filter.js'; import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js'; import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.js'; +import { getPantryHTML, refreshPantry, setupPantry } from './views/Pantry.js'; +import { getShoppingHTML, refreshShopping, setupShopping } from './views/Shopping.js'; + +function getAppToastHTML() { + return ` +
+
+
+ `; +} function getBottomNavHTML() { return ` - `; @@ -29,21 +39,27 @@ function getBottomNavHTML() { function setupTabs() { const main = document.getElementById('main-view'); const planner = document.getElementById('planner-view'); + const pantry = document.getElementById('pantry-view'); + const shopping = document.getElementById('shopping-view'); const nav = document.getElementById('app-bottom-nav'); - if (!main || !planner || !nav) return; + if (!main || !planner || !pantry || !shopping || !nav) return; - const activeTab = 'nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]'; - const idleTab = 'nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]'; + const activeTab = 'nav-tab flex flex-col items-center gap-0.5 text-black flex-1 min-w-0 max-w-[5.5rem]'; + const idleTab = 'nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]'; const apply = (tab) => { - const showRecipes = tab === 'recipes'; - main.classList.toggle('hidden', !showRecipes); - planner.classList.toggle('hidden', showRecipes); + main.classList.toggle('hidden', tab !== 'recipes'); + planner.classList.toggle('hidden', tab !== 'planner'); + pantry.classList.toggle('hidden', tab !== 'pantry'); + shopping.classList.toggle('hidden', tab !== 'shopping'); + + if (tab === 'pantry') refreshPantry(); + if (tab === 'shopping') refreshShopping(); nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => { const id = btn.getAttribute('data-tab'); if (btn.hasAttribute('disabled')) return; - if (id === 'recipes' || id === 'planner') { + if (id === 'recipes' || id === 'planner' || id === 'pantry' || id === 'shopping') { btn.className = id === tab ? activeTab : idleTab; } }); @@ -53,10 +69,15 @@ function setupTabs() { const btn = e.target.closest('.nav-tab[data-tab]'); if (!btn || btn.hasAttribute('disabled')) return; const tab = btn.getAttribute('data-tab'); - if (tab === 'recipes' || tab === 'planner') apply(tab); + if (tab === 'recipes' || tab === 'planner' || tab === 'pantry' || tab === 'shopping') apply(tab); }); apply('recipes'); + + window.refreshStockViews = () => { + refreshPantry(); + refreshShopping(); + }; } document.addEventListener('DOMContentLoaded', () => { @@ -65,13 +86,18 @@ document.addEventListener('DOMContentLoaded', () => { appContainer.innerHTML = ` ${getRecipeListHTML()} ${getMealPlannerHTML()} + ${getPantryHTML()} + ${getShoppingHTML()} ${getBottomNavHTML()} ${getRecipeDetailHTML()} ${getFilterHTML()} + ${getAppToastHTML()} `; setupTabs(); setupMealPlanner(); + setupPantry(); + setupShopping(); setupFilter(); setupRecipeDetail(); }); @@ -101,4 +127,4 @@ window.closeFilters = () => { const fv = document.getElementById('filter-view'); fv.classList.add('hidden'); fv.classList.remove('flex'); -}; \ No newline at end of file +}; diff --git a/js/data/catalog.js b/js/data/catalog.js new file mode 100644 index 0000000..8162b04 --- /dev/null +++ b/js/data/catalog.js @@ -0,0 +1,371 @@ +/** + * Katalog składników i przepisów — odpowiednik tabel w DB (edycja poza aplikacją). + * pantryUnit: jednostka magazynowa / sumowania na liście zakupów (g, ml, szt.). + * purchasePack: minimalna „sztuka” ze sklepu w tej samej jednostce co pantryUnit (np. 200 g). + * nutritionPer100g — wartości szacunkowe na 100 g (dla płynów: traktuj ml≈g przy wodzie). + */ + +export const CATEGORY_LABELS = { + pieczywo: 'Pieczywo', + nabial: 'Nabiał', + mieso_ryby: 'Mięso i ryby', + warzywa: 'Warzywa', + owoce: 'Owoce', + suche: 'Suche i kasze', + przyprawy: 'Przyprawy i zioła', + inne: 'Inne', +}; + +/** + * @typedef {{ kcal: number, protein: number, fat: number, carbs: number }} NutritionPer100 + * @typedef {{ amount: number, label?: string }} PurchasePack + * @typedef {{ id: string, name: string, category: keyof typeof CATEGORY_LABELS, pantryUnit: 'g'|'ml'|'szt', purchasePack?: PurchasePack, nutritionPer100g?: NutritionPer100 }} IngredientDef + */ + +/** @type {Record} */ +export const INGREDIENTS = { + maka_pszenna: { + id: 'maka_pszenna', + name: 'Mąka pszenna', + category: 'suche', + pantryUnit: 'g', + nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 }, + }, + mleko: { + id: 'mleko', + name: 'Mleko', + category: 'nabial', + pantryUnit: 'ml', + purchasePack: { amount: 1000, label: 'butelka 1 l' }, + nutritionPer100g: { kcal: 42, protein: 3.4, fat: 1, carbs: 5 }, + }, + jajko: { + id: 'jajko', + name: 'Jajka', + category: 'nabial', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 143, protein: 13, fat: 9.5, carbs: 1.1 }, + }, + piers_kurczaka: { + id: 'piers_kurczaka', + name: 'Pierś z kurczaka', + category: 'mieso_ryby', + pantryUnit: 'g', + nutritionPer100g: { kcal: 165, protein: 31, fat: 3.6, carbs: 0 }, + }, + mix_salat: { + id: 'mix_salat', + name: 'Mix sałat', + category: 'warzywa', + pantryUnit: 'g', + nutritionPer100g: { kcal: 20, protein: 1.5, fat: 0.3, carbs: 3 }, + }, + pomidor: { + id: 'pomidor', + name: 'Pomidor', + category: 'warzywa', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 }, + }, + makaron_suchy: { + id: 'makaron_suchy', + name: 'Makaron', + category: 'suche', + pantryUnit: 'g', + nutritionPer100g: { kcal: 371, protein: 13, fat: 1.5, carbs: 74 }, + }, + pomidory_krojone: { + id: 'pomidory_krojone', + name: 'Pomidory krojone (puszka)', + category: 'warzywa', + pantryUnit: 'g', + nutritionPer100g: { kcal: 20, protein: 1, fat: 0.2, carbs: 4 }, + }, + bazylia_swieza: { + id: 'bazylia_swieza', + name: 'Bazylia świeża', + category: 'przyprawy', + pantryUnit: 'g', + nutritionPer100g: { kcal: 23, protein: 3.2, fat: 0.6, carbs: 2.7 }, + }, + jogurt_naturalny: { + id: 'jogurt_naturalny', + name: 'Jogurt naturalny', + category: 'nabial', + pantryUnit: 'g', + nutritionPer100g: { kcal: 61, protein: 3.5, fat: 3.3, carbs: 4.7 }, + }, + mieszanka_jagod: { + id: 'mieszanka_jagod', + name: 'Mieszanka jagód', + category: 'owoce', + pantryUnit: 'g', + nutritionPer100g: { kcal: 50, protein: 0.7, fat: 0.3, carbs: 12 }, + }, + miod: { + id: 'miod', + name: 'Miód', + category: 'inne', + pantryUnit: 'g', + nutritionPer100g: { kcal: 304, protein: 0.3, fat: 0, carbs: 82 }, + }, + chleb_zakwas: { + id: 'chleb_zakwas', + name: 'Chleb na zakwasie', + category: 'pieczywo', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 250, protein: 9, fat: 1.5, carbs: 49 }, + }, + awokado: { + id: 'awokado', + name: 'Awokado', + category: 'warzywa', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 160, protein: 2, fat: 15, carbs: 9 }, + }, + cytryna: { + id: 'cytryna', + name: 'Cytryna', + category: 'owoce', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 29, protein: 1.1, fat: 0.3, carbs: 9 }, + }, + losos_filet: { + id: 'losos_filet', + name: 'Filet z łososia', + category: 'mieso_ryby', + pantryUnit: 'g', + nutritionPer100g: { kcal: 208, protein: 20, fat: 13, carbs: 0 }, + }, + koper_swiezy: { + id: 'koper_swiezy', + name: 'Koper', + category: 'przyprawy', + pantryUnit: 'g', + nutritionPer100g: { kcal: 43, protein: 3.5, fat: 1.1, carbs: 7 }, + }, + mieso_wol_mielone: { + id: 'mieso_wol_mielone', + name: 'Mięso mielone wołowe', + category: 'mieso_ryby', + pantryUnit: 'g', + nutritionPer100g: { kcal: 250, protein: 26, fat: 15, carbs: 0 }, + }, + tortilla_kukurydziana: { + id: 'tortilla_kukurydziana', + name: 'Tortille kukurydziane', + category: 'pieczywo', + pantryUnit: 'szt', + nutritionPer100g: { kcal: 218, protein: 5.7, fat: 2.9, carbs: 44 }, + }, + salsa_pomidorowa: { + id: 'salsa_pomidorowa', + name: 'Salsa pomidorowa', + category: 'warzywa', + pantryUnit: 'g', + nutritionPer100g: { kcal: 36, protein: 1.5, fat: 0.2, carbs: 8 }, + }, + platki_owsiane: { + id: 'platki_owsiane', + name: 'Płatki owsiane', + category: 'suche', + pantryUnit: 'g', + nutritionPer100g: { kcal: 389, protein: 17, fat: 7, carbs: 66 }, + }, + serek_wiejski: { + id: 'serek_wiejski', + name: 'Serek wiejski', + category: 'nabial', + pantryUnit: 'g', + purchasePack: { amount: 200, label: 'opakowanie 200 g' }, + nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 }, + }, + orzechy_wloskie: { + id: 'orzechy_wloskie', + name: 'Orzechy włoskie', + category: 'suche', + pantryUnit: 'g', + nutritionPer100g: { kcal: 654, protein: 15, fat: 65, carbs: 14 }, + }, + truskawki: { + id: 'truskawki', + name: 'Truskawki', + category: 'owoce', + pantryUnit: 'g', + nutritionPer100g: { kcal: 32, protein: 0.7, fat: 0.3, carbs: 8 }, + }, + borowki_amerykanskie: { + id: 'borowki_amerykanskie', + name: 'Borówki amerykańskie', + category: 'owoce', + pantryUnit: 'g', + nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 }, + }, +}; + +/** Porcja bazowa = 1; składniki przez ingredientId */ +export const 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: [ + { ingredientId: 'maka_pszenna', amount: 200, unit: 'g' }, + { ingredientId: 'mleko', amount: 250, unit: 'ml' }, + { ingredientId: 'jajko', 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: [ + { ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' }, + { ingredientId: 'mix_salat', amount: 100, unit: 'g' }, + { ingredientId: '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: [ + { ingredientId: 'makaron_suchy', amount: 120, unit: 'g' }, + { ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' }, + { ingredientId: 'bazylia_swieza', 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: [ + { ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' }, + { ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' }, + { ingredientId: 'miod', 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: [ + { ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' }, + { ingredientId: 'awokado', amount: 1, unit: 'szt.' }, + { ingredientId: '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: [ + { ingredientId: 'losos_filet', amount: 180, unit: 'g' }, + { ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' }, + { ingredientId: 'koper_swiezy', 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: [ + { ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' }, + { ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' }, + { ingredientId: '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: [ + { ingredientId: 'platki_owsiane', amount: 60, unit: 'g' }, + { ingredientId: 'mleko', amount: 200, unit: 'ml' }, + { ingredientId: 'miod', 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: [ + { ingredientId: 'serek_wiejski', amount: 200, unit: 'g' }, + { ingredientId: 'miod', amount: 10, unit: 'g' }, + { ingredientId: 'orzechy_wloskie', amount: 50, unit: 'g' }, + { ingredientId: 'truskawki', amount: 100, unit: 'g' }, + { ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' }, + ], + }, +}; + +/** + * Krok +/- w spiżarni: całe opakowanie albo domyślny krok (10 g/ml lub 1 szt.). + * @param {string} ingredientId + * @returns {number} + */ +export function pantryQtyStep(ingredientId) { + const d = INGREDIENTS[ingredientId]; + if (!d) return 10; + if (d.purchasePack && Number.isFinite(d.purchasePack.amount) && d.purchasePack.amount > 0) { + return d.purchasePack.amount; + } + return d.pantryUnit === 'szt' ? 1 : 10; +} + +/** + * @param {IngredientDef} def + * @param {number} stockQty — w pantryUnit + */ +export function nutritionForStock(def, stockQty) { + const n = def.nutritionPer100g; + if (!n || !Number.isFinite(stockQty) || stockQty <= 0) return null; + const f = stockQty / 100; + return { + kcal: Math.round(n.kcal * f), + protein: Math.round(n.protein * f * 10) / 10, + fat: Math.round(n.fat * f * 10) / 10, + carbs: Math.round(n.carbs * f * 10) / 10, + }; +} + +/** + * Pełne opakowania + reszta (np. 450 g / 200 → 2 + 50 g). + * @param {IngredientDef} def + * @param {number} stockQty + * @returns {{ fullPacks: number, remainder: number } | null} + */ +export function splitStockIntoPacks(def, stockQty) { + const size = def.purchasePack?.amount; + if (!size || !Number.isFinite(size) || size <= 0 || !Number.isFinite(stockQty)) return null; + const fullPacks = Math.floor(stockQty / size); + const remainder = Math.round((stockQty - fullPacks * size) * 10) / 10; + return { fullPacks, remainder }; +} diff --git a/js/planner/mealSlots.js b/js/planner/mealSlots.js new file mode 100644 index 0000000..f8a2ba1 --- /dev/null +++ b/js/planner/mealSlots.js @@ -0,0 +1,7 @@ +export 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' }, +]; diff --git a/js/services/dateUtils.js b/js/services/dateUtils.js new file mode 100644 index 0000000..f55c8b3 --- /dev/null +++ b/js/services/dateUtils.js @@ -0,0 +1,51 @@ +export function startOfDay(d) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +export function sameDay(a, b) { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); +} + +export function addDays(d, n) { + const x = new Date(d); + x.setDate(x.getDate() + n); + return startOfDay(x); +} + +/** Poniedziałek jako pierwszy dzień tygodnia (PL) */ +export function startOfWeekMonday(d) { + const date = startOfDay(d); + const day = date.getDay(); + const diff = day === 0 ? -6 : 1 - day; + return addDays(date, diff); +} + +export function startOfMonth(d) { + const x = new Date(d.getFullYear(), d.getMonth(), 1); + return startOfDay(x); +} + +export function addMonths(d, n) { + const x = new Date(d); + x.setMonth(x.getMonth() + n); + return startOfDay(x); +} + +export function addWeeks(d, n) { + return addDays(d, n * 7); +} + +export function weekContains(weekStart, d) { + const t = startOfDay(d).getTime(); + const ws = weekStart.getTime(); + const we = addDays(weekStart, 6).getTime(); + return t >= ws && t <= we; +} + +export function sameMonth(a, b) { + return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear(); +} diff --git a/js/services/pantryShopping.js b/js/services/pantryShopping.js new file mode 100644 index 0000000..52eab32 --- /dev/null +++ b/js/services/pantryShopping.js @@ -0,0 +1,414 @@ +import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js'; +import { PANTRY_STORAGE_KEY, SHOPPING_STORAGE_KEY } from '../storageKeys.js'; + +export const KITCHEN_LIST_ID = 'kitchen'; +export const MISC_LIST_ID = 'misc'; + +function newId(prefix) { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** @typedef {{ id: string, ingredientId: string, name: string, amount: number, unit: string, category: string, checked: boolean, sourceNote?: string }} KitchenShoppingItem */ +/** @typedef {{ id: string, text: string, note?: string, checked: boolean }} FreeformShoppingItem */ +/** @typedef {{ id: string, name: string, type: 'kitchen'|'freeform', items: (KitchenShoppingItem|FreeformShoppingItem)[] }} ShoppingListDef */ + +/** + * @typedef {{ + * lists: ShoppingListDef[], + * activeListId: string + * }} ShoppingState + */ + +function defaultShoppingState() { + return { + lists: [ + { id: KITCHEN_LIST_ID, name: 'Kuchnia', type: 'kitchen', items: [] }, + { id: MISC_LIST_ID, name: 'Inne zakupy', type: 'freeform', items: [] }, + ], + activeListId: KITCHEN_LIST_ID, + }; +} + +/** @param {unknown} x */ +function normalizeKitchenItem(x) { + if (!x || typeof x !== 'object') return null; + const o = /** @type {Record} */ (x); + if (!o.ingredientId || !Number.isFinite(Number(o.amount))) return null; + const id = String(o.id && String(o.id).length ? o.id : newId('s')); + const ingId = String(o.ingredientId); + return { + id, + ingredientId: ingId, + name: String(o.name || INGREDIENTS[ingId]?.name || ingId), + amount: Math.round(Number(o.amount) * 100) / 100, + unit: String(o.unit || ''), + category: String(o.category || INGREDIENTS[ingId]?.category || 'inne'), + checked: Boolean(o.checked), + sourceNote: o.sourceNote ? String(o.sourceNote) : undefined, + }; +} + +/** @param {unknown} x */ +function normalizeFreeformItem(x) { + if (!x || typeof x !== 'object') return null; + const o = /** @type {Record} */ (x); + if (!o.text || !String(o.text).trim()) return null; + return { + id: String(o.id && String(o.id).length ? o.id : newId('f')), + text: String(o.text).trim(), + note: o.note ? String(o.note) : undefined, + checked: Boolean(o.checked), + }; +} + +/** @returns {ShoppingState} */ +function normalizeShoppingState(raw) { + const base = defaultShoppingState(); + if (!raw || typeof raw !== 'object') return base; + const p = /** @type {Record} */ (raw); + + if (Array.isArray(p)) { + const items = p.map(normalizeKitchenItem).filter(Boolean); + const kitchen = base.lists.find((l) => l.id === KITCHEN_LIST_ID); + if (kitchen && kitchen.type === 'kitchen') { + kitchen.items = /** @type {KitchenShoppingItem[]} */ (items); + } + return base; + } + + if (!Array.isArray(p.lists)) return base; + + /** @type {ShoppingListDef[]} */ + const lists = []; + for (const L of p.lists) { + if (!L || typeof L !== 'object') continue; + const row = /** @type {Record} */ (L); + const id = String(row.id || ''); + const name = String(row.name || 'Lista'); + let type = row.type === 'freeform' ? 'freeform' : 'kitchen'; + if (id === KITCHEN_LIST_ID) type = 'kitchen'; + if (id === MISC_LIST_ID) type = 'freeform'; + if (!id) continue; + if (type === 'kitchen') { + const items = Array.isArray(row.items) + ? row.items.map(normalizeKitchenItem).filter(Boolean) + : []; + lists.push({ id, name, type: 'kitchen', items: /** @type {KitchenShoppingItem[]} */ (items) }); + } else { + const items = Array.isArray(row.items) + ? row.items.map(normalizeFreeformItem).filter(Boolean) + : []; + lists.push({ id, name, type: 'freeform', items: /** @type {FreeformShoppingItem[]} */ (items) }); + } + } + + if (lists.length === 0) return base; + + const hasKitchen = lists.some((l) => l.id === KITCHEN_LIST_ID); + if (!hasKitchen) { + lists.unshift(/** @type {ShoppingListDef} */ (base.lists[0])); + } + const hasMisc = lists.some((l) => l.id === MISC_LIST_ID); + if (!hasMisc) { + lists.push(/** @type {ShoppingListDef} */ (base.lists[1])); + } + + let activeListId = String(p.activeListId || KITCHEN_LIST_ID); + if (!lists.some((l) => l.id === activeListId)) activeListId = lists[0].id; + + return { lists, activeListId }; +} + +export function loadShoppingState() { + try { + const raw = localStorage.getItem(SHOPPING_STORAGE_KEY); + if (!raw) return defaultShoppingState(); + const parsed = JSON.parse(raw); + return normalizeShoppingState(parsed); + } catch { + return defaultShoppingState(); + } +} + +export function saveShoppingState(state) { + try { + localStorage.setItem(SHOPPING_STORAGE_KEY, JSON.stringify(state)); + } catch { /* ignore */ } +} + +export function getListSummaries() { + const { lists, activeListId } = loadShoppingState(); + return { + lists: lists.map((l) => ({ + id: l.id, + name: l.name, + type: l.type, + openCount: l.items.filter((i) => !i.checked).length, + })), + activeListId, + }; +} + +export function setActiveListId(listId) { + const s = loadShoppingState(); + if (!s.lists.some((l) => l.id === listId)) return s; + s.activeListId = listId; + saveShoppingState(s); + return s; +} + +export function getListById(listId) { + return loadShoppingState().lists.find((l) => l.id === listId) ?? null; +} + +export function getActiveList() { + const s = loadShoppingState(); + return s.lists.find((l) => l.id === s.activeListId) ?? s.lists[0]; +} + +/** + * @param {string} name + * @returns {string} new list id + */ +export function addFreeformList(name) { + const s = loadShoppingState(); + const id = newId('list'); + s.lists.push({ + id, + name: name.trim() || 'Nowa lista', + type: 'freeform', + items: [], + }); + s.activeListId = id; + saveShoppingState(s); + return id; +} + +/** + * @param {string} listId + * @returns {boolean} + */ +export function deleteList(listId) { + if (listId === KITCHEN_LIST_ID) return false; + const s = loadShoppingState(); + const idx = s.lists.findIndex((l) => l.id === listId); + if (idx < 0) return false; + s.lists.splice(idx, 1); + if (s.activeListId === listId) { + s.activeListId = s.lists[0]?.id || KITCHEN_LIST_ID; + } + saveShoppingState(s); + return true; +} + +/** + * Dodaje linie tylko do listy kuchennej (składniki z katalogu). + * @param {{ ingredientId: string, amount: number, unit: string, name?: string, category?: string, sourceNote?: string }[]} lines + * @param {string} [listId] + */ +export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) { + const s = loadShoppingState(); + const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen'); + if (!list || list.type !== 'kitchen') return s; + + const items = /** @type {KitchenShoppingItem[]} */ (list.items); + const open = items.filter((x) => !x.checked); + const closed = items.filter((x) => x.checked); + + for (const L of lines) { + const def = INGREDIENTS[L.ingredientId]; + const name = L.name || def?.name || L.ingredientId; + const category = L.category || def?.category || 'inne'; + const idx = open.findIndex( + (x) => x.ingredientId === L.ingredientId && x.unit === L.unit, + ); + if (idx >= 0) { + open[idx].amount = Math.round((open[idx].amount + L.amount) * 100) / 100; + if (L.sourceNote && !open[idx].sourceNote) open[idx].sourceNote = L.sourceNote; + } else { + open.push({ + id: newId('s'), + ingredientId: L.ingredientId, + name, + amount: Math.round(L.amount * 100) / 100, + unit: L.unit, + category, + checked: false, + sourceNote: L.sourceNote, + }); + } + } + + list.items = [...open, ...closed]; + saveShoppingState(s); + return s; +} + +/** + * Na listę kuchenną ze spiżarni (amount w pantryUnit: g, ml lub szt.). + * @param {string} [sourceNote] — nadpisuje domyślne „Ze spiżarni” + */ +export function addIngredientToKitchenList(ingredientId, amount = 1, sourceNote) { + const def = INGREDIENTS[ingredientId]; + if (!def) return; + const unit = displayUnit(def.pantryUnit); + addOrMergeShoppingLines([{ + ingredientId, + amount, + unit, + name: def.name, + category: def.category, + sourceNote: sourceNote ?? 'Ze spiżarni', + }], KITCHEN_LIST_ID); +} + +/** + * @param {string} listId + * @param {string} text + * @param {string} [note] + */ +export function addFreeformLine(listId, text, note) { + const t = text.trim(); + if (!t) return; + const s = loadShoppingState(); + const list = s.lists.find((l) => l.id === listId && l.type === 'freeform'); + if (!list) return; + const items = /** @type {FreeformShoppingItem[]} */ (list.items); + items.push({ + id: newId('f'), + text: t, + note: note?.trim() || undefined, + checked: false, + }); + saveShoppingState(s); +} + +export function toggleItemInList(listId, itemId) { + const s = loadShoppingState(); + const list = s.lists.find((l) => l.id === listId); + if (!list) return s; + const it = list.items.find((i) => i.id === itemId); + if (it) it.checked = !it.checked; + saveShoppingState(s); + return s; +} + +export function removeItemFromList(listId, itemId) { + const s = loadShoppingState(); + const list = s.lists.find((l) => l.id === listId); + if (!list) return s; + list.items = list.items.filter((i) => i.id !== itemId); + saveShoppingState(s); + return s; +} + +export function clearCheckedInList(listId) { + const s = loadShoppingState(); + const list = s.lists.find((l) => l.id === listId); + if (!list) return s; + list.items = list.items.filter((i) => !i.checked); + saveShoppingState(s); + return s; +} + +export function computeShortfalls(needLines, pantry = loadPantry()) { + const short = []; + for (const L of needLines) { + const def = INGREDIENTS[L.ingredientId]; + if (!def) continue; + const u = normalizeUnitForPantry(L.unit, def.pantryUnit); + if (u === null) { + short.push({ ...L, pantryQty: 0, shortfall: L.amount, unitMismatch: true }); + continue; + } + const have = Number(pantry[L.ingredientId]) || 0; + const miss = Math.max(0, Math.round((L.amount - have) * 100) / 100); + if (miss > 0) { + short.push({ + ...L, + unit: displayUnit(def.pantryUnit), + pantryQty: have, + shortfall: miss, + unitMismatch: false, + }); + } + } + return short; +} + +function displayUnit(pantryUnit) { + if (pantryUnit === 'szt') return 'szt.'; + return pantryUnit; +} + +function normalizeUnitForPantry(recipeUnit, pantryUnit) { + const r = String(recipeUnit).trim().toLowerCase().replace(/\.$/, ''); + if (pantryUnit === 'g' && (r === 'g' || r === 'gram')) return 'g'; + if (pantryUnit === 'ml' && (r === 'ml' || r === 'mililitr')) return 'ml'; + if (pantryUnit === 'szt' && (r === 'szt' || r === 'sztuka' || r === 'kromki' || r === 'kromka')) return 'szt'; + return null; +} + +export function categoryLabel(cat) { + return CATEGORY_LABELS[cat] || CATEGORY_LABELS.inne; +} + +/** @returns {Record} ingredientId -> ilość w pantryUnit */ +export function loadPantry() { + try { + const raw = localStorage.getItem(PANTRY_STORAGE_KEY); + if (!raw) return {}; + const p = JSON.parse(raw); + if (typeof p !== 'object' || p === null) return {}; + const out = {}; + Object.keys(p).forEach((k) => { + const n = Number(p[k]); + if (!Number.isFinite(n) || n < 0) return; + out[k] = n; + }); + return out; + } catch { + return {}; + } +} + +export function savePantry(pantry) { + try { + localStorage.setItem(PANTRY_STORAGE_KEY, JSON.stringify(pantry)); + } catch { /* ignore */ } +} + +export function setPantryQty(ingredientId, qty) { + const pantry = loadPantry(); + if (qty <= 0 || !Number.isFinite(qty)) delete pantry[ingredientId]; + else pantry[ingredientId] = Math.round(qty * 1000) / 1000; + savePantry(pantry); + return pantry; +} + +/** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */ +export function applyCheckedKitchenListToPantry() { + const s = loadShoppingState(); + const kitchen = s.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen'); + if (!kitchen) return { pantry: loadPantry(), state: s }; + + const pantry = loadPantry(); + const items = /** @type {KitchenShoppingItem[]} */ (kitchen.items); + + for (const it of items) { + if (!it.checked) continue; + const def = INGREDIENTS[it.ingredientId]; + if (!def) continue; + if (normalizeUnitForPantry(it.unit, def.pantryUnit) === null) continue; + const cur = Number(pantry[it.ingredientId]) || 0; + pantry[it.ingredientId] = Math.round((cur + it.amount) * 1000) / 1000; + } + + kitchen.items = items.filter((i) => !i.checked); + savePantry(pantry); + saveShoppingState(s); + return { pantry, state: s }; +} diff --git a/js/services/planIngredients.js b/js/services/planIngredients.js new file mode 100644 index 0000000..c5fcc95 --- /dev/null +++ b/js/services/planIngredients.js @@ -0,0 +1,191 @@ +import { INGREDIENTS, RECIPES } from '../data/catalog.js'; +import { MEAL_SLOTS } from '../planner/mealSlots.js'; +import { addDays } from './dateUtils.js'; +import { getDayPlan } from './planStore.js'; + +export 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; + }); +} + +export 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 = 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, + }; +} + +function resolveLine(ing, scaledAmount) { + const def = INGREDIENTS[ing.ingredientId]; + return { + ingredientId: ing.ingredientId, + name: def?.name ?? ing.ingredientId, + category: def?.category ?? 'inne', + amount: scaledAmount, + unit: ing.unit, + }; +} + +/** Płaska lista składników z jednego dnia (wszystkie pory). */ +export function flattenDayIngredientLines(dayPlan) { + /** @type {ReturnType[]} */ + const out = []; + MEAL_SLOTS.forEach((slot) => { + const entries = dayPlan[slot.id]; + if (!Array.isArray(entries)) return; + entries.forEach((entry) => { + if (!entry || !entry.recipeId) return; + const r = RECIPES[entry.recipeId]; + if (!r || !Array.isArray(r.ingredients)) return; + const serv = Math.max(1, Number(entry.servings) || 1); + r.ingredients.forEach((ing) => { + const scaled = Math.round(ing.amount * serv * 10) / 10; + out.push(resolveLine(ing, scaled)); + }); + }); + }); + return out; +} + +/** Sumuje po (ingredientId + unit) — ta sama jednostka jak w przepisie. */ +export function mergeIngredientLines(lines) { + const m = new Map(); + for (const L of lines) { + const key = `${L.ingredientId}\t${L.unit}`; + const cur = m.get(key); + if (!cur) { + m.set(key, { ...L }); + } else { + cur.amount = Math.round((cur.amount + L.amount) * 10) / 10; + } + } + return [...m.values()].sort((a, b) => { + const c = a.category.localeCompare(b.category); + return c !== 0 ? c : a.name.localeCompare(b.name); + }); +} + +/** + * Zapotrzebowanie składników od weekStart (włącznie) przez 7 dni. + * @param {Record} plans + * @param {Date} weekStart + */ +export function aggregateWeekIngredientNeed(plans, weekStart) { + const all = []; + for (let i = 0; i < 7; i++) { + const day = addDays(weekStart, i); + const dayPlan = getDayPlan(plans, day); + all.push(...flattenDayIngredientLines(dayPlan)); + } + return mergeIngredientLines(all); +} + +/** + * Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami. + */ +export function aggregateDayIngredientsBySlot(dayPlan) { + /** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { ingredientId: string, name: string, amount: number, unit: string, category: 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 = RECIPES[entry.recipeId]; + if (!r) return; + const s = Math.max(1, Number(entry.servings) || 1); + const items = r.ingredients.map((ing) => { + const def = INGREDIENTS[ing.ingredientId]; + return { + ingredientId: ing.ingredientId, + name: def?.name ?? ing.ingredientId, + category: def?.category ?? 'inne', + 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; +} + +export function countDayShortfalls(dayPlan, pantry) { + const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan)); + let count = 0; + for (const line of lines) { + if ((Number(pantry[line.ingredientId]) || 0) < line.amount) count++; + } + return count; +} + +/** + * Kumulatywna prognoza zużycia spiżarni: od startDate przez lookAheadDays dni. + * Zwraca tablicę dni (tylko te z posiłkami), każdy z listą składników i informacją + * ile jest w spiżarni (po odjęciu zużycia z poprzednich dni) i ile brakuje. + */ +export function computeFullForecast(plans, pantry, startDate, lookAheadDays = 8) { + const running = { ...pantry }; + const days = []; + + for (let i = 0; i < lookAheadDays; i++) { + const day = addDays(startDate, i); + const dayPlan = getDayPlan(plans, day); + const lines = mergeIngredientLines(flattenDayIngredientLines(dayPlan)); + if (lines.length === 0) continue; + + const items = lines.map((line) => { + const def = INGREDIENTS[line.ingredientId]; + const have = Math.round((Number(running[line.ingredientId]) || 0) * 10) / 10; + const miss = Math.max(0, Math.round((line.amount - have) * 10) / 10); + return { + ...line, + pantryQty: have, + shortfall: miss, + enough: miss <= 0, + pantryUnit: def + ? def.pantryUnit === 'szt' ? 'szt.' : def.pantryUnit + : line.unit, + }; + }); + + for (const line of lines) { + const have = Number(running[line.ingredientId]) || 0; + running[line.ingredientId] = Math.max(0, Math.round((have - line.amount) * 10) / 10); + } + + days.push({ date: day, dayIndex: i, items, hasShortfall: items.some((it) => !it.enough) }); + } + + return days; +} diff --git a/js/services/planStore.js b/js/services/planStore.js new file mode 100644 index 0000000..4169323 --- /dev/null +++ b/js/services/planStore.js @@ -0,0 +1,85 @@ +import { RECIPES } from '../data/catalog.js'; +import { MEAL_SLOTS } from '../planner/mealSlots.js'; +import { PLANS_STORAGE_KEY } from '../storageKeys.js'; +import { startOfDay } from './dateUtils.js'; + +export 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}`; +} + +export 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 } */ +export function normalizeSlotValue(v) { + if (!v) return []; + if (Array.isArray(v)) { + return v + .filter((x) => x && x.recipeId && 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 && RECIPES[v.recipeId]) { + return [{ + id: newPlanEntryId(), + recipeId: v.recipeId, + servings: Math.max(1, Math.min(12, Number(v.servings) || 1)), + }]; + } + return []; +} + +export 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; +} + +export 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; +} + +export 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 {}; + } +} + +export function savePlans(plans) { + try { + localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans)); + } catch { /* ignore */ } +} + +export function getDayPlan(plans, d) { + const key = dateKey(d); + const day = plans[key]; + return day && typeof day === 'object' ? day : {}; +} diff --git a/js/storageKeys.js b/js/storageKeys.js new file mode 100644 index 0000000..ad42b3d --- /dev/null +++ b/js/storageKeys.js @@ -0,0 +1,3 @@ +export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1'; +export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1'; +export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1'; diff --git a/js/ui/toast.js b/js/ui/toast.js new file mode 100644 index 0000000..2316c8b --- /dev/null +++ b/js/ui/toast.js @@ -0,0 +1,13 @@ +export function showAppToast(message) { + const wrap = document.getElementById('app-toast'); + const text = document.getElementById('app-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(showAppToast._t); + showAppToast._t = setTimeout(() => { + wrap.classList.add('opacity-0', 'translate-y-2'); + wrap.classList.remove('opacity-100', 'translate-y-0'); + }, 2600); +} diff --git a/js/views/MealPlanner.js b/js/views/MealPlanner.js index 96ea896..de72e69 100644 --- a/js/views/MealPlanner.js +++ b/js/views/MealPlanner.js @@ -1,59 +1,46 @@ +import { INGREDIENTS, RECIPES } from '../data/catalog.js'; +import { MEAL_SLOTS } from '../planner/mealSlots.js'; +import { + addDays, + addMonths, + addWeeks, + sameDay, + sameMonth, + startOfDay, + startOfMonth, + startOfWeekMonday, + weekContains, +} from '../services/dateUtils.js'; +import { + computeFullForecast, + countDayShortfalls, + dayHasAnyMeal, + sumDayNutrition, +} from '../services/planIngredients.js'; +import { addOrMergeShoppingLines, loadPantry } from '../services/pantryShopping.js'; +import { + dateKey, + getDayPlan, + loadPlans, + newPlanEntryId, + savePlans, +} from '../services/planStore.js'; + const MONTHS_SHORT = [ 'sty', 'lut', 'mar', 'kwi', 'maj', 'cze', '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', +]; -function startOfDay(d) { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x; -} +/** 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 sameDay(a, b) { - return a.getFullYear() === b.getFullYear() - && a.getMonth() === b.getMonth() - && a.getDate() === b.getDate(); -} - -function addDays(d, n) { - const x = new Date(d); - x.setDate(x.getDate() + n); - return startOfDay(x); -} - -/** Poniedziałek jako pierwszy dzień tygodnia (PL) */ -function startOfWeekMonday(d) { - const date = startOfDay(d); - const day = date.getDay(); - const diff = day === 0 ? -6 : 1 - day; - return addDays(date, diff); -} - -function startOfMonth(d) { - const x = new Date(d.getFullYear(), d.getMonth(), 1); - return startOfDay(x); -} - -function addMonths(d, n) { - const x = new Date(d); - x.setMonth(x.getMonth() + n); - return startOfDay(x); -} - -function addWeeks(d, n) { - return addDays(d, n * 7); -} - -function weekContains(weekStart, d) { - const t = startOfDay(d).getTime(); - const ws = weekStart.getTime(); - const we = addDays(weekStart, 6).getTime(); - return t >= ws && t <= we; -} - -function sameMonth(a, b) { - return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear(); +function recipesForSlot(slotId) { + return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId)); } function isCalendarOnToday(mode, weekStart, monthAnchor, selected) { @@ -75,7 +62,7 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) { export function getMealPlannerHTML() { return ` -