From 8672c8da2846df0ab6a7719024d9cdc65601a8fc Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 24 Mar 2026 22:39:58 +0100 Subject: [PATCH] Add pantry and shopping lists --- stacks/recipe/index.html | 1 + stacks/recipe/js/app.js | 70 +++- stacks/recipe/js/data/catalog.js | 318 ++++++++++++++ stacks/recipe/js/planner/mealSlots.js | 7 + stacks/recipe/js/services/dateUtils.js | 51 +++ stacks/recipe/js/services/pantryShopping.js | 413 +++++++++++++++++++ stacks/recipe/js/services/planIngredients.js | 141 +++++++ stacks/recipe/js/services/planStore.js | 85 ++++ stacks/recipe/js/storageKeys.js | 3 + stacks/recipe/js/ui/toast.js | 13 + stacks/recipe/js/views/MealPlanner.js | 411 ++++-------------- stacks/recipe/js/views/Pantry.js | 412 ++++++++++++++++++ stacks/recipe/js/views/Shopping.js | 237 +++++++++++ 13 files changed, 1800 insertions(+), 362 deletions(-) create mode 100644 stacks/recipe/js/data/catalog.js create mode 100644 stacks/recipe/js/planner/mealSlots.js create mode 100644 stacks/recipe/js/services/dateUtils.js create mode 100644 stacks/recipe/js/services/pantryShopping.js create mode 100644 stacks/recipe/js/services/planIngredients.js create mode 100644 stacks/recipe/js/services/planStore.js create mode 100644 stacks/recipe/js/storageKeys.js create mode 100644 stacks/recipe/js/ui/toast.js create mode 100644 stacks/recipe/js/views/Pantry.js create mode 100644 stacks/recipe/js/views/Shopping.js diff --git a/stacks/recipe/index.html b/stacks/recipe/index.html index 544cab0..28899df 100644 --- a/stacks/recipe/index.html +++ b/stacks/recipe/index.html @@ -24,6 +24,7 @@ /* Utilities */ .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; } diff --git a/stacks/recipe/js/app.js b/stacks/recipe/js/app.js index d7a3d52..824b38a 100644 --- a/stacks/recipe/js/app.js +++ b/stacks/recipe/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/stacks/recipe/js/data/catalog.js b/stacks/recipe/js/data/catalog.js new file mode 100644 index 0000000..9bd076f --- /dev/null +++ b/stacks/recipe/js/data/catalog.js @@ -0,0 +1,318 @@ +/** + * 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.). + * 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', +}; + +/** @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', + 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', + 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' }, + ], + }, +}; diff --git a/stacks/recipe/js/planner/mealSlots.js b/stacks/recipe/js/planner/mealSlots.js new file mode 100644 index 0000000..f8a2ba1 --- /dev/null +++ b/stacks/recipe/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/stacks/recipe/js/services/dateUtils.js b/stacks/recipe/js/services/dateUtils.js new file mode 100644 index 0000000..f55c8b3 --- /dev/null +++ b/stacks/recipe/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/stacks/recipe/js/services/pantryShopping.js b/stacks/recipe/js/services/pantryShopping.js new file mode 100644 index 0000000..aebb3b1 --- /dev/null +++ b/stacks/recipe/js/services/pantryShopping.js @@ -0,0 +1,413 @@ +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; +} + +/** + * Jedna sztuka / domyślna jednostka magazynowa — na listę kuchenną ze spiżarni. + */ +export function addIngredientToKitchenList(ingredientId, amount = 1) { + const def = INGREDIENTS[ingredientId]; + if (!def) return; + const unit = displayUnit(def.pantryUnit); + addOrMergeShoppingLines([{ + ingredientId, + amount, + unit, + name: def.name, + category: def.category, + 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/stacks/recipe/js/services/planIngredients.js b/stacks/recipe/js/services/planIngredients.js new file mode 100644 index 0000000..0377c97 --- /dev/null +++ b/stacks/recipe/js/services/planIngredients.js @@ -0,0 +1,141 @@ +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; +} diff --git a/stacks/recipe/js/services/planStore.js b/stacks/recipe/js/services/planStore.js new file mode 100644 index 0000000..4169323 --- /dev/null +++ b/stacks/recipe/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/stacks/recipe/js/storageKeys.js b/stacks/recipe/js/storageKeys.js new file mode 100644 index 0000000..ad42b3d --- /dev/null +++ b/stacks/recipe/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/stacks/recipe/js/ui/toast.js b/stacks/recipe/js/ui/toast.js new file mode 100644 index 0000000..2316c8b --- /dev/null +++ b/stacks/recipe/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/stacks/recipe/js/views/MealPlanner.js b/stacks/recipe/js/views/MealPlanner.js index 3fb41c8..f638537 100644 --- a/stacks/recipe/js/views/MealPlanner.js +++ b/stacks/recipe/js/views/MealPlanner.js @@ -1,3 +1,30 @@ +import { 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 { + aggregateDayIngredientsBySlot, + dayHasAnyMeal, + sumDayNutrition, +} from '../services/planIngredients.js'; +import { addOrMergeShoppingLines } 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', @@ -7,346 +34,12 @@ 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); - x.setHours(0, 0, 0, 0); - return x; -} - -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 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)); + return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId)); } function isCalendarOnToday(mode, weekStart, monthAnchor, selected) { @@ -727,7 +420,7 @@ function renderDayContent(state) { : ''; const entryCards = entries.map((entry) => { - const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null; + const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null; if (!recipe) return ''; const servings = Math.max(1, Number(entry.servings) || 1); const n = recipe.nutritionPerServing; @@ -851,7 +544,11 @@ function renderIngredientsSheet(state) {

${escapeHtml(rec.recipeTitle)}

    ${rec.items.map((ing) => ` -
  • +
  • ${escapeHtml(ing.name)} ${formatAmount(ing.amount)} ${escapeHtml(ing.unit)} @@ -1042,7 +739,7 @@ export function setupMealPlanner() { 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; + if (!recipeId || !RECIPES[recipeId]) return; const key = dateKey(state.selected); if (!state.plans[key]) state.plans[key] = {}; const slotId = state.pickerSlot; @@ -1073,7 +770,24 @@ export function setupMealPlanner() { 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).`); + const lines = []; + rows.forEach((row) => { + const id = row.getAttribute('data-ingredient-id'); + const amount = parseFloat(row.getAttribute('data-amount') || ''); + const unit = row.getAttribute('data-unit') || ''; + const category = row.getAttribute('data-category') || ''; + if (!id || !Number.isFinite(amount)) return; + lines.push({ + ingredientId: id, + amount, + unit, + category, + sourceNote: 'Z planu dnia', + }); + }); + addOrMergeShoppingLines(lines); + showPlannerToast(`Dodano ${lines.length} składników na listę.`); + window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); @@ -1085,7 +799,24 @@ export function setupMealPlanner() { 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).`); + const lines = []; + selected.forEach((row) => { + const id = row.getAttribute('data-ingredient-id'); + const amount = parseFloat(row.getAttribute('data-amount') || ''); + const unit = row.getAttribute('data-unit') || ''; + const category = row.getAttribute('data-category') || ''; + if (!id || !Number.isFinite(amount)) return; + lines.push({ + ingredientId: id, + amount, + unit, + category, + sourceNote: 'Z planu dnia', + }); + }); + addOrMergeShoppingLines(lines); + showPlannerToast(`Dodano ${lines.length} pozycji na listę.`); + window.refreshShopping?.(); closeSheet(ingBackdrop, ingSheet); }); diff --git a/stacks/recipe/js/views/Pantry.js b/stacks/recipe/js/views/Pantry.js new file mode 100644 index 0000000..b9ed79f --- /dev/null +++ b/stacks/recipe/js/views/Pantry.js @@ -0,0 +1,412 @@ +import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js'; +import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js'; +import { showAppToast } from '../ui/toast.js'; + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function pantryUnitLabel(u) { + if (u === 'szt') return 'szt.'; + return u; +} + +function normalizeSearch(q) { + return String(q).trim().toLowerCase(); +} + +const PANTRY_SHOP_BOTTOM = '5.25rem'; +const PANTRY_SHOP_OFF = `translateY(calc(100% + ${PANTRY_SHOP_BOTTOM}))`; + +/** @type {string | null} */ +let shopPickerIngredientId = null; +/** @type {number} */ +let shopPickerStep = 1; + +export function getPantryHTML() { + return ` + + `; +} + +let pantryFilterCategory = ''; + +/** Zwijanie sekcji (ignorowane przy aktywnym wyszukiwaniu — widać wszystkie trafienia). */ +let pantryAccordionHaveOpen = true; +let pantryAccordionCatalogOpen = false; + +function allCategoryKeys() { + const s = new Set(); + Object.values(INGREDIENTS).forEach((d) => s.add(d.category)); + return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))); +} + +function renderCategoryChips() { + const wrap = document.getElementById('pantry-category-filters'); + if (!wrap) return; + + const keys = allCategoryKeys(); + const chips = [ + { id: '', label: 'Wszystkie' }, + ...keys.map((k) => ({ id: k, label: categoryLabel(k) })), + ]; + + wrap.innerHTML = chips.map((c) => { + const active = c.id === pantryFilterCategory; + const cls = active + ? 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-900 text-white' + : 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200'; + return ``; + }).join(''); + + wrap.querySelectorAll('.pantry-cat-btn').forEach((btn) => { + btn.addEventListener('click', () => { + pantryFilterCategory = btn.getAttribute('data-pantry-cat') || ''; + renderCategoryChips(); + renderPantryResults(); + }); + }); +} + +function filterIds(searchRaw) { + const q = normalizeSearch(searchRaw); + return Object.keys(INGREDIENTS) + .filter((id) => { + const d = INGREDIENTS[id]; + if (pantryFilterCategory && d.category !== pantryFilterCategory) return false; + if (!q) return true; + const name = d.name.toLowerCase(); + const cat = (CATEGORY_LABELS[d.category] || '').toLowerCase(); + return name.includes(q) || cat.includes(q); + }) + .sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl')); +} + +/** Krok +/-: tylko liczby całkowite (szt. ±1, g/ml ±10). */ +function qtyStepForIngredient(id) { + const u = INGREDIENTS[id]?.pantryUnit; + return u === 'szt' ? 1 : 10; +} + +function splitHaveAndCatalog(ids, pantry) { + const have = ids.filter((id) => (Number(pantry[id]) || 0) > 0); + const catalogOnly = ids.filter((id) => !pantry[id] || Number(pantry[id]) <= 0); + return { have, catalogOnly }; +} + +/** + * @param {'have' | 'catalog'} sectionKey + * @param {{ title: string, hint: string, count: number, tone: 'emerald' | 'slate', open: boolean, searching: boolean, bodyInner: string }} opts + */ +function pantryAccordionSection(sectionKey, opts) { + const { title, hint, count, tone, open, searching, bodyInner } = opts; + const showToggle = !searching && count > 0; + const isOpen = searching || open || count === 0; + const ring = tone === 'emerald' ? 'ring-emerald-100/90' : 'ring-gray-200/90'; + const dot = tone === 'emerald' ? 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.2)]' : 'bg-slate-400 shadow-[0_0_0_3px_rgba(148,163,184,0.25)]'; + const chevronRot = isOpen ? '' : '-rotate-90'; + + const rowCls = + 'w-full flex items-center gap-3 px-3.5 py-3 text-left min-h-[3.25rem]' + + (showToggle ? ' hover:bg-gray-50/80 transition-colors pantry-acc-toggle cursor-pointer' : ''); + + const chevron = + showToggle + ? `` + : ''; + + const headerInner = ` + + + + ${escapeHtml(title)} + ${count} + + ${escapeHtml(hint)} + + ${chevron}`; + + const header = + showToggle + ? `` + : `
    ${headerInner}
    `; + + return ` +
    + ${header} +
    + ${bodyInner} +
    +
    `; +} + +function pantryCardHtml(id, pantry, variant) { + const def = INGREDIENTS[id]; + const unit = pantryUnitLabel(def.pantryUnit); + const qty = Number(pantry[id]) || 0; + const val = qty > 0 ? String(Math.round(qty)) : ''; + const step = qtyStepForIngredient(id); + + const shell = variant === 'have' + ? 'rounded-xl border border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-white p-3 shadow-sm ring-1 ring-emerald-100/80' + : 'rounded-xl border border-dashed border-gray-200 bg-gray-50/90 p-3 shadow-sm'; + + return ` +
    +
    +
    +

    ${escapeHtml(def.name)}

    +

    ${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}

    +
    +
    +
    +
    + + + +
    + +
    +
    `; +} + +function renderPantryResults() { + const root = document.getElementById('pantry-results'); + if (!root) return; + + const searchEl = document.getElementById('pantry-search'); + const q = searchEl?.value || ''; + const searching = normalizeSearch(q) !== ''; + const pantry = loadPantry(); + const ids = filterIds(q); + + if (ids.length === 0) { + root.innerHTML = '

    Brak wyników — zmień wyszukiwanie lub filtr kategorii.

    '; + return; + } + + const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry); + + const haveBody = have.length + ? `
    ${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}
    ` + : '

    Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.

    '; + + const catBody = catalogOnly.length + ? `
    ${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}
    ` + : '

    Wszystkie widoczne pozycje są na stanie.

    '; + + const haveHint = + have.length === 0 + ? 'Brak zapasu w tym widoku' + : have.length === 1 + ? '1 produkt z zapasem' + : `${have.length} produktów z zapasem`; + + const catHint = + catalogOnly.length === 0 + ? 'Nic do uzupełnienia w tym widoku' + : catalogOnly.length === 1 + ? '1 pozycja bez zapasu' + : `${catalogOnly.length} pozycji bez zapasu`; + + root.innerHTML = + pantryAccordionSection('have', { + title: 'Na stanie', + hint: haveHint, + count: have.length, + tone: 'emerald', + open: pantryAccordionHaveOpen, + searching, + bodyInner: haveBody, + }) + + pantryAccordionSection('catalog', { + title: 'Katalog — bez zapasu', + hint: catHint, + count: catalogOnly.length, + tone: 'slate', + open: pantryAccordionCatalogOpen, + searching, + bodyInner: catBody, + }); + + root.querySelectorAll('.pantry-acc-toggle').forEach((btn) => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-pantry-acc'); + if (key === 'have') pantryAccordionHaveOpen = !pantryAccordionHaveOpen; + else if (key === 'catalog') pantryAccordionCatalogOpen = !pantryAccordionCatalogOpen; + renderPantryResults(); + }); + }); + + root.querySelectorAll('[data-ingredient-id]').forEach((card) => { + const id = card.getAttribute('data-ingredient-id'); + if (!id) return; + const input = card.querySelector('[data-pantry-qty]'); + const step = parseInt(String(input?.getAttribute('data-pantry-step')), 10) || qtyStepForIngredient(id); + + const applyQty = (n) => { + const v = Math.max(0, Math.round(Number(n)) || 0); + setPantryQty(id, v); + if (input) { + input.value = v > 0 ? String(v) : ''; + } + const prevVariant = card.getAttribute('data-pantry-variant'); + const nowHave = v > 0; + const expectVariant = nowHave ? 'have' : 'catalog'; + if (prevVariant !== expectVariant) { + renderPantryResults(); + } + }; + + card.querySelector('.pantry-qty-minus')?.addEventListener('click', () => { + const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0); + applyQty(Math.max(0, cur - step)); + }); + card.querySelector('.pantry-qty-plus')?.addEventListener('click', () => { + const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0); + applyQty(cur + step); + }); + input?.addEventListener('change', () => { + const raw = String(input.value).replace(',', '.').trim(); + const v = raw === '' ? 0 : Math.round(parseFloat(raw)); + applyQty(Number.isFinite(v) ? v : 0); + }); + card.querySelector('.pantry-add-shop')?.addEventListener('click', () => { + openPantryShopPicker(id); + }); + }); +} + +function readShopPickerQty() { + const el = document.getElementById('pantry-shop-qty'); + const n = Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0); + return Math.max(1, n); +} + +function setShopPickerQtyDisplay(v) { + const el = document.getElementById('pantry-shop-qty'); + if (el) el.value = String(Math.max(1, Math.round(v))); +} + +function openPantryShopPicker(ingredientId) { + const def = INGREDIENTS[ingredientId]; + if (!def) return; + + shopPickerIngredientId = ingredientId; + shopPickerStep = qtyStepForIngredient(ingredientId); + const unit = pantryUnitLabel(def.pantryUnit); + + const heading = document.getElementById('pantry-shop-heading'); + const sub = document.getElementById('pantry-shop-sub'); + if (heading) heading.textContent = `Ile dodać: ${def.name}?`; + if (sub) { + sub.textContent = `Jednostka na liście: ${unit}. Przyciski +/−: ${shopPickerStep} ${unit}.`; + } + setShopPickerQtyDisplay(shopPickerStep); + + const backdrop = document.getElementById('pantry-shop-backdrop'); + const sheet = document.getElementById('pantry-shop-sheet'); + if (!backdrop || !sheet) return; + + sheet.classList.remove('hidden'); + backdrop.classList.remove('hidden'); + requestAnimationFrame(() => { + backdrop.classList.remove('opacity-0'); + sheet.style.transform = 'translateY(0)'; + }); +} + +function closePantryShopPicker() { + shopPickerIngredientId = null; + const backdrop = document.getElementById('pantry-shop-backdrop'); + const sheet = document.getElementById('pantry-shop-sheet'); + if (sheet) { + sheet.style.transform = PANTRY_SHOP_OFF; + } + if (backdrop) { + backdrop.classList.add('opacity-0'); + } + setTimeout(() => { + backdrop?.classList.add('hidden'); + sheet?.classList.add('hidden'); + }, 300); +} + +function bindPantryShopSheet() { + document.getElementById('pantry-shop-backdrop')?.addEventListener('click', closePantryShopPicker); + document.getElementById('pantry-shop-cancel')?.addEventListener('click', closePantryShopPicker); + + document.getElementById('pantry-shop-minus')?.addEventListener('click', () => { + const cur = readShopPickerQty(); + setShopPickerQtyDisplay(Math.max(1, cur - shopPickerStep)); + }); + document.getElementById('pantry-shop-plus')?.addEventListener('click', () => { + const cur = readShopPickerQty(); + setShopPickerQtyDisplay(cur + shopPickerStep); + }); + document.getElementById('pantry-shop-qty')?.addEventListener('change', () => { + setShopPickerQtyDisplay(readShopPickerQty()); + }); + + document.getElementById('pantry-shop-add')?.addEventListener('click', () => { + if (!shopPickerIngredientId) return; + const qty = readShopPickerQty(); + addIngredientToKitchenList(shopPickerIngredientId, qty); + showAppToast(`Dodano ${qty} na listę kuchni.`); + closePantryShopPicker(); + window.refreshShopping?.(); + }); +} + +export function refreshPantry() { + renderCategoryChips(); + renderPantryResults(); +} + +export function setupPantry() { + renderCategoryChips(); + renderPantryResults(); + bindPantryShopSheet(); + + document.getElementById('pantry-search')?.addEventListener('input', () => { + renderPantryResults(); + }); + + window.refreshPantry = refreshPantry; +} diff --git a/stacks/recipe/js/views/Shopping.js b/stacks/recipe/js/views/Shopping.js new file mode 100644 index 0000000..7c82b74 --- /dev/null +++ b/stacks/recipe/js/views/Shopping.js @@ -0,0 +1,237 @@ +import { + addFreeformLine, + addFreeformList, + categoryLabel, + deleteList, + getActiveList, + getListSummaries, + KITCHEN_LIST_ID, + removeItemFromList, + setActiveListId, + toggleItemInList, +} from '../services/pantryShopping.js'; +import { showAppToast } from '../ui/toast.js'; + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function getShoppingHTML() { + return ` + + `; +} + +function syncListSelect() { + const sel = document.getElementById('shopping-list-select'); + if (!sel) return; + const { lists, activeListId } = getListSummaries(); + sel.innerHTML = lists.map((l) => { + const suffix = l.openCount ? ` (${l.openCount})` : ''; + const label = `${l.name}${suffix}`; + return ``; + }).join(''); + sel.value = activeListId; +} + +function syncChromeForList() { + const list = getActiveList(); + const isKitchen = list.type === 'kitchen'; + const delBtn = document.getElementById('shopping-delete-list'); + const ffAdd = document.getElementById('shopping-freeform-add'); + + if (ffAdd) ffAdd.classList.toggle('hidden', isKitchen); + + if (delBtn) { + delBtn.classList.toggle('hidden', isKitchen); + } +} + +function renderKitchenItems() { + const root = document.getElementById('shopping-list-root'); + if (!root) return; + + const list = getActiveList(); + if (list.type !== 'kitchen') return; + + const items = list.items; + if (items.length === 0) { + root.innerHTML = '

    Brak pozycji.

    '; + return; + } + + const groups = {}; + for (const it of items) { + const c = it.category || 'inne'; + if (!groups[c]) groups[c] = []; + groups[c].push(it); + } + + root.innerHTML = Object.keys(groups) + .sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))) + .map((cat) => ` +
    +

    ${escapeHtml(categoryLabel(cat))}

    +
      + ${groups[cat].map((it) => ` +
    • + +
      +

      ${escapeHtml(it.name)}

      +

      ${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}

      + ${it.sourceNote ? `

      ${escapeHtml(it.sourceNote)}

      ` : ''} +
      + +
    • `).join('')} +
    +
    + `) + .join(''); + + bindItemButtons(list.id); +} + +function renderFreeformItems() { + const root = document.getElementById('shopping-list-root'); + if (!root) return; + + const list = getActiveList(); + if (list.type !== 'freeform') return; + + const items = list.items; + if (items.length === 0) { + root.innerHTML = '

    Dodaj dowolny tekst powyżej — bez powiązania z katalogiem składników.

    '; + return; + } + + root.innerHTML = ` +
    +
      + ${items.map((it) => ` +
    • + +
      +

      ${escapeHtml(it.text)}

      + ${it.note ? `

      ${escapeHtml(it.note)}

      ` : ''} +
      + +
    • `).join('')} +
    +
    `; + + bindItemButtons(list.id); +} + +function bindItemButtons(listId) { + const root = document.getElementById('shopping-list-root'); + if (!root) return; + + root.querySelectorAll('[data-shop-toggle]').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-shop-toggle'); + if (id) toggleItemInList(listId, id); + refreshShopping(); + }); + }); + root.querySelectorAll('[data-shop-remove]').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-shop-remove'); + if (id) removeItemFromList(listId, id); + refreshShopping(); + }); + }); +} + +export function refreshShopping() { + syncListSelect(); + syncChromeForList(); + const list = getActiveList(); + if (list.type === 'kitchen') renderKitchenItems(); + else renderFreeformItems(); +} + +export function setupShopping() { + const sel = document.getElementById('shopping-list-select'); + sel?.addEventListener('change', () => { + const v = sel.value; + if (v) setActiveListId(v); + refreshShopping(); + }); + + document.getElementById('shopping-new-list')?.addEventListener('click', () => { + const name = window.prompt('Nazwa nowej listy (dowolne zakupy):', 'Nowa lista'); + if (name === null) return; + addFreeformList(name); + showAppToast('Utworzono listę.'); + refreshShopping(); + }); + + document.getElementById('shopping-delete-list')?.addEventListener('click', () => { + const list = getActiveList(); + if (list.id === KITCHEN_LIST_ID) return; + if (!window.confirm(`Usunąć listę „${list.name}”?`)) return; + deleteList(list.id); + showAppToast('Lista usunięta.'); + refreshShopping(); + }); + + const submitFreeform = () => { + const list = getActiveList(); + if (list.type !== 'freeform') return; + const input = document.getElementById('shopping-freeform-input'); + const note = document.getElementById('shopping-freeform-note'); + const text = input?.value || ''; + addFreeformLine(list.id, text, note?.value || ''); + if (input) input.value = ''; + if (note) note.value = ''; + refreshShopping(); + }; + + document.getElementById('shopping-freeform-submit')?.addEventListener('click', submitFreeform); + document.getElementById('shopping-freeform-input')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitFreeform(); + } + }); + + refreshShopping(); + window.refreshShopping = refreshShopping; +}