From 2008cd96eb4cb531adeccdf0af7350ff748bcdfb Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Thu, 26 Mar 2026 17:41:19 +0100 Subject: [PATCH] Enable Gitea actions and move recipe mockup to a separate repo --- stacks/gitea/docker-compose.yaml | 24 +- stacks/recipe-mockup/docker-compose.yaml | 19 + stacks/recipe/Dockerfile | 12 - stacks/recipe/css/styles.css | 53 - stacks/recipe/docker-compose.yaml | 25 - stacks/recipe/icons/apple-touch-icon.png | Bin 564 -> 0 bytes stacks/recipe/icons/icon-192.png | Bin 593 -> 0 bytes stacks/recipe/icons/icon-512.png | Bin 2201 -> 0 bytes stacks/recipe/index.html | 49 - stacks/recipe/js/app.js | 130 --- stacks/recipe/js/data/catalog.js | 371 ------- stacks/recipe/js/planner/mealSlots.js | 7 - stacks/recipe/js/services/dateUtils.js | 51 - stacks/recipe/js/services/pantryShopping.js | 414 -------- stacks/recipe/js/services/planIngredients.js | 191 ---- stacks/recipe/js/services/planStore.js | 85 -- stacks/recipe/js/storageKeys.js | 3 - stacks/recipe/js/ui/toast.js | 13 - stacks/recipe/js/views/Filter.js | 63 -- stacks/recipe/js/views/MealPlanner.js | 995 ------------------- stacks/recipe/js/views/Pantry.js | 464 --------- stacks/recipe/js/views/RecipeDetail.js | 290 ------ stacks/recipe/js/views/RecipeList.js | 174 ---- stacks/recipe/js/views/Shopping.js | 237 ----- stacks/recipe/manifest.webmanifest | 31 - stacks/recipe/nginx/default.conf | 23 - stacks/recipe/sw.js | 13 - 27 files changed, 42 insertions(+), 3695 deletions(-) create mode 100644 stacks/recipe-mockup/docker-compose.yaml delete mode 100644 stacks/recipe/Dockerfile delete mode 100644 stacks/recipe/css/styles.css delete mode 100644 stacks/recipe/docker-compose.yaml delete mode 100644 stacks/recipe/icons/apple-touch-icon.png delete mode 100644 stacks/recipe/icons/icon-192.png delete mode 100644 stacks/recipe/icons/icon-512.png delete mode 100644 stacks/recipe/index.html delete mode 100644 stacks/recipe/js/app.js delete mode 100644 stacks/recipe/js/data/catalog.js delete mode 100644 stacks/recipe/js/planner/mealSlots.js delete mode 100644 stacks/recipe/js/services/dateUtils.js delete mode 100644 stacks/recipe/js/services/pantryShopping.js delete mode 100644 stacks/recipe/js/services/planIngredients.js delete mode 100644 stacks/recipe/js/services/planStore.js delete mode 100644 stacks/recipe/js/storageKeys.js delete mode 100644 stacks/recipe/js/ui/toast.js delete mode 100644 stacks/recipe/js/views/Filter.js delete mode 100644 stacks/recipe/js/views/MealPlanner.js delete mode 100644 stacks/recipe/js/views/Pantry.js delete mode 100644 stacks/recipe/js/views/RecipeDetail.js delete mode 100644 stacks/recipe/js/views/RecipeList.js delete mode 100644 stacks/recipe/js/views/Shopping.js delete mode 100644 stacks/recipe/manifest.webmanifest delete mode 100644 stacks/recipe/nginx/default.conf delete mode 100644 stacks/recipe/sw.js diff --git a/stacks/gitea/docker-compose.yaml b/stacks/gitea/docker-compose.yaml index d86cf72..b74b026 100644 --- a/stacks/gitea/docker-compose.yaml +++ b/stacks/gitea/docker-compose.yaml @@ -24,6 +24,8 @@ services: - GITEA__service__ENABLE_PASSKEY_AUTHENTICATION=false - GITEA__openid__ENABLE_OPENID_SIGNIN=false - GITEA__openid__ENABLE_OPENID_SIGNUP=false + + - GITEA__actions__ENABLED=true networks: - homelab_apps - gitea_db_net @@ -68,6 +70,22 @@ services: - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} timeout: 5s + runner: + image: gitea/act_runner:latest + container_name: gitea_runner + restart: unless-stopped + depends_on: + - server + environment: + GITEA_INSTANCE_URL: https://${GITEA_DOMAIN} + GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/gitea_runner_token + GITEA_RUNNER_NAME: homelab-runner + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - gitea_runner_data:/data + secrets: + - gitea_runner_token + volumes: gitea_data: driver: local @@ -75,6 +93,8 @@ volumes: driver: local gitea_db_data: driver: local + gitea_runner_data: + driver: local networks: homelab_apps: @@ -84,4 +104,6 @@ networks: secrets: gitea_db_password: - environment: GITEA_DB_PASSWORD \ No newline at end of file + environment: GITEA_DB_PASSWORD + gitea_runner_token: + environment: GITEA_RUNNER_TOKEN \ No newline at end of file diff --git a/stacks/recipe-mockup/docker-compose.yaml b/stacks/recipe-mockup/docker-compose.yaml new file mode 100644 index 0000000..e6a1b78 --- /dev/null +++ b/stacks/recipe-mockup/docker-compose.yaml @@ -0,0 +1,19 @@ +services: + recipe-mockup: + image: git.ulfrx.dev/ulfr/recipe-mockup:latest + 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" + +networks: + homelab_apps: + external: true \ No newline at end of file diff --git a/stacks/recipe/Dockerfile b/stacks/recipe/Dockerfile deleted file mode 100644 index 3a490c9..0000000 --- a/stacks/recipe/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# 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/stacks/recipe/css/styles.css b/stacks/recipe/css/styles.css deleted file mode 100644 index 8272995..0000000 --- a/stacks/recipe/css/styles.css +++ /dev/null @@ -1,53 +0,0 @@ -/* Slider styling */ -input[type=range]::-webkit-slider-thumb { - -webkit-appearance: none; - height: 20px; - width: 20px; - border-radius: 50%; - background: #111827; - cursor: pointer; - margin-top: -8px; -} -input[type=range]::-webkit-slider-runnable-track { - width: 100%; - height: 4px; - cursor: pointer; - background: #e5e7eb; - border-radius: 2px; -} - -/* View Transitions */ -.view-transition { - transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; -} -.slide-in { - transform: translateX(0); - opacity: 1; -} -.slide-out { - transform: translateX(100%); - opacity: 0; - pointer-events: none; -} - -/* Ingredient Active States */ -.ingredient-active .check-box { - background-color: #111827; - border-color: #111827; -} -.ingredient-active .check-icon { - display: block; -} -.ingredient-active .ingredient-text { - text-decoration: line-through; - color: #9ca3af; -} - -/* Utilities */ -.no-scrollbar::-webkit-scrollbar { - display: none; -} -.no-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; -} \ No newline at end of file diff --git a/stacks/recipe/docker-compose.yaml b/stacks/recipe/docker-compose.yaml deleted file mode 100644 index ec496dd..0000000 --- a/stacks/recipe/docker-compose.yaml +++ /dev/null @@ -1,25 +0,0 @@ -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/stacks/recipe/icons/apple-touch-icon.png b/stacks/recipe/icons/apple-touch-icon.png deleted file mode 100644 index 31e470bbf6566fc4e994ebbf58b62c3d5342e4fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 564 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6VEpar;uumf=k2A9oD2#K2Mpp1 zJ~RK{A245_Ws+ibd*9A)ja?tD8RCpgG~3jUURWYAOT>9{QbrJuZ`VRk!%@K@93W4b YJQu8x5x=Z&089-Gp00i_>zopr0IfRDH~;_u diff --git a/stacks/recipe/icons/icon-192.png b/stacks/recipe/icons/icon-192.png deleted file mode 100644 index f31fa2b254f5efe031613b0e627dcd8fa26b002c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 593 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V3P23aSW-L^Y*eLBZGp#fdlIs z7Bl`8cH%kWH)sCMHMM#nXWujYILWX?vcZITLK?#*9*0?s9>xrrYzk*b1&48fRKHEE VX6>?P@&G0k22WQ%mvv4FO#mE_tDOJ< diff --git a/stacks/recipe/icons/icon-512.png b/stacks/recipe/icons/icon-512.png deleted file mode 100644 index ca5c9ef8103b4485f86983687bb676c5b67fb4e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2201 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e5>puljz zKDxcM#m<-=$>Ia(czR*R74!2)hJdB0XAb)igPlY_z2)z4*}Q$iB} D1#}t! diff --git a/stacks/recipe/index.html b/stacks/recipe/index.html deleted file mode 100644 index 52c5a01..0000000 --- a/stacks/recipe/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - Recipe App - Modular - - - - - - - - - -
-
- - - - - \ No newline at end of file diff --git a/stacks/recipe/js/app.js b/stacks/recipe/js/app.js deleted file mode 100644 index 824b38a..0000000 --- a/stacks/recipe/js/app.js +++ /dev/null @@ -1,130 +0,0 @@ -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 ` - - `; -} - -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 || !pantry || !shopping || !nav) return; - - 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) => { - 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' || id === 'pantry' || id === 'shopping') { - btn.className = id === tab ? activeTab : idleTab; - } - }); - }; - - nav.addEventListener('click', (e) => { - 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' || tab === 'pantry' || tab === 'shopping') apply(tab); - }); - - apply('recipes'); - - window.refreshStockViews = () => { - refreshPantry(); - refreshShopping(); - }; -} - -document.addEventListener('DOMContentLoaded', () => { - const appContainer = document.getElementById('app-container'); - - appContainer.innerHTML = ` - ${getRecipeListHTML()} - ${getMealPlannerHTML()} - ${getPantryHTML()} - ${getShoppingHTML()} - ${getBottomNavHTML()} - ${getRecipeDetailHTML()} - ${getFilterHTML()} - ${getAppToastHTML()} - `; - - setupTabs(); - setupMealPlanner(); - setupPantry(); - setupShopping(); - setupFilter(); - setupRecipeDetail(); -}); - -// --- GLOBAL NAVIGATION METHODS --- -window.openRecipeDetail = () => { - const view = document.getElementById('recipe-detail-view'); - // Swap Tailwind classes to slide IN - view.classList.remove('translate-x-full', 'opacity-0', 'pointer-events-none'); - view.classList.add('translate-x-0', 'opacity-100', 'pointer-events-auto'); -}; - -window.closeRecipeDetail = () => { - const view = document.getElementById('recipe-detail-view'); - // Swap Tailwind classes to slide OUT - view.classList.remove('translate-x-0', 'opacity-100', 'pointer-events-auto'); - view.classList.add('translate-x-full', 'opacity-0', 'pointer-events-none'); -}; - -window.openFilters = () => { - const fv = document.getElementById('filter-view'); - fv.classList.remove('hidden'); - fv.classList.add('flex'); -}; - -window.closeFilters = () => { - const fv = document.getElementById('filter-view'); - fv.classList.add('hidden'); - fv.classList.remove('flex'); -}; diff --git a/stacks/recipe/js/data/catalog.js b/stacks/recipe/js/data/catalog.js deleted file mode 100644 index 8162b04..0000000 --- a/stacks/recipe/js/data/catalog.js +++ /dev/null @@ -1,371 +0,0 @@ -/** - * 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/stacks/recipe/js/planner/mealSlots.js b/stacks/recipe/js/planner/mealSlots.js deleted file mode 100644 index f8a2ba1..0000000 --- a/stacks/recipe/js/planner/mealSlots.js +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index f55c8b3..0000000 --- a/stacks/recipe/js/services/dateUtils.js +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 52eab32..0000000 --- a/stacks/recipe/js/services/pantryShopping.js +++ /dev/null @@ -1,414 +0,0 @@ -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/stacks/recipe/js/services/planIngredients.js b/stacks/recipe/js/services/planIngredients.js deleted file mode 100644 index c5fcc95..0000000 --- a/stacks/recipe/js/services/planIngredients.js +++ /dev/null @@ -1,191 +0,0 @@ -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/stacks/recipe/js/services/planStore.js b/stacks/recipe/js/services/planStore.js deleted file mode 100644 index 4169323..0000000 --- a/stacks/recipe/js/services/planStore.js +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index ad42b3d..0000000 --- a/stacks/recipe/js/storageKeys.js +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 2316c8b..0000000 --- a/stacks/recipe/js/ui/toast.js +++ /dev/null @@ -1,13 +0,0 @@ -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/Filter.js b/stacks/recipe/js/views/Filter.js deleted file mode 100644 index 285692b..0000000 --- a/stacks/recipe/js/views/Filter.js +++ /dev/null @@ -1,63 +0,0 @@ -export function getFilterHTML() { - return ` - - `; -} - -export function setupFilter() { - const timeSlider = document.getElementById('prep-time-slider'); - const timeDisplay = document.getElementById('time-display'); - - if(timeSlider) { - timeSlider.addEventListener('input', (e) => { - const val = e.target.value; - timeDisplay.textContent = val >= 120 ? 'ponad 120 min' : `${val} min`; - }); - } -} \ No newline at end of file diff --git a/stacks/recipe/js/views/MealPlanner.js b/stacks/recipe/js/views/MealPlanner.js deleted file mode 100644 index de72e69..0000000 --- a/stacks/recipe/js/views/MealPlanner.js +++ /dev/null @@ -1,995 +0,0 @@ -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', -]; - -/** 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 recipesForSlot(slotId) { - return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId)); -} - -function isCalendarOnToday(mode, weekStart, monthAnchor, selected) { - const today = startOfDay(new Date()); - if (!sameDay(selected, today)) return false; - if (mode === 'week') return weekContains(weekStart, today); - return sameMonth(monthAnchor, today); -} - -function syncTodayButton(mode, weekStart, monthAnchor, selected) { - const btn = document.getElementById('cal-go-today'); - if (!btn) return; - const onToday = isCalendarOnToday(mode, weekStart, monthAnchor, selected); - const active = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-white px-2 text-[10px] font-semibold text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 transition-colors'; - const dim = 'h-6 shrink-0 inline-flex items-center justify-center gap-1 rounded-md border border-gray-100 bg-gray-50 px-2 text-[10px] font-semibold text-gray-400 shadow-none cursor-default transition-colors'; - btn.className = onToday ? dim : active; - btn.disabled = onToday; -} - -export function getMealPlannerHTML() { - return ` - - `; -} - -function renderWeekGrid(weekStart, selected, plans) { - const grid = document.getElementById('calendar-week-grid'); - if (!grid) return; - - const cells = []; - for (let i = 0; i < 7; i++) { - const day = addDays(weekStart, i); - const isSel = selected && sameDay(day, selected); - const isToday = sameDay(day, new Date()); - const hasMeals = dayHasAnyMeal(plans, day); - cells.push(` - - `); - } - grid.innerHTML = cells.join(''); -} - -function renderMonthGrid(monthAnchor, selected, plans) { - const grid = document.getElementById('calendar-month-grid'); - if (!grid) return; - - const first = startOfMonth(monthAnchor); - const startGrid = startOfWeekMonday(first); - const cells = []; - for (let i = 0; i < 42; i++) { - const day = addDays(startGrid, i); - const inMonth = day.getMonth() === first.getMonth(); - const isSel = selected && sameDay(day, selected); - const isToday = sameDay(day, new Date()); - const hasMeals = inMonth && dayHasAnyMeal(plans, day); - cells.push(` - - `); - } - grid.innerHTML = cells.join(''); -} - -function updatePeriodLabel(mode, weekStart, monthAnchor) { - const el = document.getElementById('cal-period-label'); - if (!el) return; - - if (mode === 'week') { - const end = addDays(weekStart, 6); - const y = weekStart.getFullYear(); - if (weekStart.getMonth() === end.getMonth()) { - el.textContent = `${weekStart.getDate()}–${end.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} ${y}`; - } else { - el.textContent = `${weekStart.getDate()} ${MONTHS_SHORT[weekStart.getMonth()]} – ${end.getDate()} ${MONTHS_SHORT[end.getMonth()]} ${y}`; - } - } else { - const m = monthAnchor.getMonth(); - const y = monthAnchor.getFullYear(); - const monthLong = [ - 'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', - 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień', - ][m]; - el.textContent = `${monthLong} ${y}`; - } -} - -function syncModeToggle(mode) { - const weekWrap = document.getElementById('calendar-week-wrap'); - const monthWrap = document.getElementById('calendar-month-wrap'); - const handleIcon = document.getElementById('calendar-handle-icon'); - - if (weekWrap) { - weekWrap.style.maxHeight = mode === 'week' ? '10rem' : '0'; - weekWrap.style.opacity = mode === 'week' ? '1' : '0'; - } - if (monthWrap) { - monthWrap.style.maxHeight = mode === 'month' ? '25rem' : '0'; - monthWrap.style.opacity = mode === 'month' ? '1' : '0'; - } - if (handleIcon) { - handleIcon.className = mode === 'week' - ? 'fas fa-chevron-down text-[8px] text-gray-300' - : 'fas fa-chevron-up text-[8px] text-gray-300'; - } -} - -function bindDayClicks(container, state, rerender) { - container?.addEventListener('click', (e) => { - const btn = e.target.closest('[data-planner-day]'); - if (!btn) return; - const ts = Number(btn.getAttribute('data-planner-day')); - state.selected = new Date(ts); - rerender(); - }); -} - -function bindCalendarSwipeGesture(state, rerender) { - const zone = document.getElementById('calendar-swipe-zone'); - if (!zone) return; - - let startY = 0; - let ptrId = null; - let moved = false; - - zone.addEventListener('pointerdown', (e) => { - if (ptrId !== null) return; - startY = e.clientY; - ptrId = e.pointerId; - moved = false; - }); - - zone.addEventListener('pointermove', (e) => { - if (e.pointerId !== ptrId) return; - if (Math.abs(e.clientY - startY) > 10) moved = true; - }); - - zone.addEventListener('pointerup', (e) => { - if (e.pointerId !== ptrId) return; - const dy = e.clientY - startY; - ptrId = null; - - if (!moved || Math.abs(dy) < 30) return; - - let switched = false; - if (state.mode === 'week' && dy > 30) { - state.mode = 'month'; - state.monthAnchor = startOfMonth(state.selected); - switched = true; - } else if (state.mode === 'month' && dy < -30) { - state.mode = 'week'; - state.weekStart = startOfWeekMonday(state.selected); - switched = true; - } - - if (switched) { - zone.addEventListener('click', (ev) => { - ev.stopPropagation(); - ev.preventDefault(); - }, { capture: true, once: true }); - rerender(); - } - }); - - zone.addEventListener('pointercancel', () => { - ptrId = null; - moved = false; - }); -} - -function showPlannerToast(message) { - const wrap = document.getElementById('planner-toast'); - const text = document.getElementById('planner-toast-text'); - if (!wrap || !text) return; - text.textContent = message; - wrap.classList.remove('opacity-0', 'translate-y-2'); - wrap.classList.add('opacity-100', 'translate-y-0'); - clearTimeout(showPlannerToast._t); - showPlannerToast._t = setTimeout(() => { - wrap.classList.add('opacity-0', 'translate-y-2'); - wrap.classList.remove('opacity-100', 'translate-y-0'); - }, 2600); -} - -function openSheet(backdrop, sheet) { - if (!backdrop || !sheet) return; - sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; - sheet.style.transform = 'translateY(0)'; - backdrop.classList.remove('hidden'); - requestAnimationFrame(() => { - backdrop.classList.remove('opacity-0'); - }); -} - -function closeSheet(backdrop, sheet) { - if (!backdrop || !sheet) return; - sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; - sheet.style.transform = PLANNER_SHEET_OFF_TRANSFORM; - backdrop.classList.add('opacity-0'); - setTimeout(() => backdrop.classList.add('hidden'), 300); -} - -/** Zamykanie panelu: przeciągnięcie nagłówka w dół (pointer). */ -function bindPlannerSheetDragClose(sheet, closeFn) { - const zone = sheet.querySelector('[data-planner-sheet-drag-zone]'); - if (!zone || !sheet) return; - - let startY = 0; - let pulling = false; - let ptrId = null; - - const resetVisual = () => { - sheet.style.transition = 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)'; - sheet.style.transform = 'translateY(0)'; - }; - - zone.addEventListener('pointerdown', (e) => { - if (e.pointerType === 'mouse' && e.button !== 0) return; - pulling = true; - ptrId = e.pointerId; - startY = e.clientY; - sheet.style.transition = 'none'; - zone.setPointerCapture(e.pointerId); - }); - - zone.addEventListener('pointermove', (e) => { - if (!pulling || e.pointerId !== ptrId) return; - const dy = Math.max(0, e.clientY - startY); - sheet.style.transform = `translateY(${dy}px)`; - }); - - zone.addEventListener('pointerup', (e) => { - if (!pulling || e.pointerId !== ptrId) return; - const dy = e.clientY - startY; - pulling = false; - ptrId = null; - try { - zone.releasePointerCapture(e.pointerId); - } catch { - /* ignore */ - } - if (dy > 56) { - closeFn(); - return; - } - resetVisual(); - }); - - zone.addEventListener('pointercancel', () => { - pulling = false; - ptrId = null; - resetVisual(); - }); -} - -function renderDayContent(state) { - const sel = state.selected; - const heading = document.getElementById('planner-day-heading'); - if (heading) { - const wd = WEEKDAYS_LONG[sel.getDay()]; - heading.textContent = `${wd}, ${sel.getDate()} ${MONTHS_SHORT[sel.getMonth()]}`; - } - - const dayPlan = getDayPlan(state.plans, sel); - const totals = sumDayNutrition(dayPlan); - - const kcalEl = document.getElementById('planner-summary-kcal'); - if (kcalEl) { - kcalEl.innerHTML = totals.mealCount === 0 - ? `— kcal` - : `${totals.kcal} kcal`; - } - const fmt = (n) => `${n} g`; - document.getElementById('planner-macro-p').textContent = totals.mealCount ? fmt(totals.protein) : '—'; - document.getElementById('planner-macro-f').textContent = totals.mealCount ? fmt(totals.fat) : '—'; - document.getElementById('planner-macro-c').textContent = totals.mealCount ? fmt(totals.carbs) : '—'; - - document.getElementById('planner-detail-kcal').textContent = `${totals.kcal} kcal`; - document.getElementById('planner-detail-p').textContent = fmt(totals.protein); - document.getElementById('planner-detail-f').textContent = fmt(totals.fat); - document.getElementById('planner-detail-c').textContent = fmt(totals.carbs); - - const ingBtn = document.getElementById('planner-open-ingredients'); - if (ingBtn) { - const noMeals = totals.mealCount === 0; - ingBtn.disabled = noMeals; - ingBtn.classList.toggle('opacity-50', noMeals); - ingBtn.classList.toggle('cursor-not-allowed', noMeals); - if (!noMeals) { - const shortCount = countDayShortfalls(dayPlan, loadPantry()); - if (shortCount > 0) { - ingBtn.innerHTML = ` - Składniki na ten dzień - ${shortCount}`; - } else { - ingBtn.innerHTML = ` - Składniki na ten dzień - OK`; - } - } else { - ingBtn.innerHTML = ` Składniki na ten dzień`; - } - } - - const slotsRoot = document.getElementById('planner-meal-slots'); - if (!slotsRoot) return; - - slotsRoot.innerHTML = MEAL_SLOTS.map((slot) => { - const entries = Array.isArray(dayPlan[slot.id]) ? dayPlan[slot.id] : []; - const countLabel = entries.length > 1 - ? `${entries.length} dania` - : ''; - - const entryCards = entries.map((entry) => { - const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null; - if (!recipe) return ''; - const servings = Math.max(1, Number(entry.servings) || 1); - const n = recipe.nutritionPerServing; - const kcal = Math.round(n.kcal * servings); - const eid = escapeHtml(entry.id); - return ` -
-
-
-
- ${escapeHtml(recipe.thumbLabel)} -
-
-

${escapeHtml(recipe.title)}

-

- ${recipe.minutes} min - · - ${kcal} kcal -

-
-
- -
-
- Porcje -
- - ${servings} - -
-
-
`; - }).join(''); - - const addLabel = entries.length === 0 ? 'Dodaj przepis' : 'Dodaj kolejny przepis'; - const addClasses = entries.length === 0 - ? 'planner-add-meal w-full py-2 rounded-lg border border-dashed border-gray-200 text-[13px] font-semibold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors' - : 'planner-add-meal w-full py-1.5 rounded-lg border border-dashed border-gray-200 text-xs font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors'; - - return ` -
-
- - - - ${slot.label} - ${countLabel} -
-
- ${entryCards} - -
-
`; - }).join(''); -} - -function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function renderPickerList(slotId) { - const slot = MEAL_SLOTS.find((s) => s.id === slotId); - const list = document.getElementById('planner-picker-list'); - const title = document.getElementById('planner-picker-title'); - const sub = document.getElementById('planner-picker-sub'); - if (!list || !title || !sub) return; - - title.textContent = 'Wybierz przepis'; - sub.textContent = slot ? `Dla: ${slot.label}. Przeciągnij nagłówek w dół lub dotknij tła, by zamknąć.` : ''; - - const recipes = recipesForSlot(slotId); - if (recipes.length === 0) { - list.innerHTML = '

Brak dopasowanych przepisów.

'; - return; - } - - list.innerHTML = recipes.map((r) => ` - - `).join(''); -} - -function plIngredientWord(n) { - if (n === 1) return 'składnik'; - const m10 = n % 10; - const m100 = n % 100; - if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'składniki'; - return 'składników'; -} - -function updateIngButtons(state) { - const btn1 = document.getElementById('planner-ing-add-all'); - const btn2 = document.getElementById('planner-ing-add-btn'); - - const todayCount = (state._todayShortfalls || []).length; - const allCount = (state._allForecastShortfalls || []).length; - - if (btn1) { - if (todayCount > 0) { - btn1.classList.remove('hidden'); - btn1.disabled = false; - btn1.innerHTML = ` Dodaj braki na dziś do listy`; - } else { - btn1.classList.add('hidden'); - } - } - if (btn2) { - if (allCount > todayCount) { - btn2.classList.remove('hidden'); - btn2.innerHTML = ` Dodaj braki na cały tydzień`; - } else { - btn2.classList.add('hidden'); - } - } -} - -function renderIngredientsSheet(state) { - const body = document.getElementById('planner-ing-body'); - const titleEl = document.getElementById('planner-ing-title'); - const subEl = document.getElementById('planner-ing-sub'); - if (!body) return; - - const pantry = loadPantry(); - const forecast = computeFullForecast(state.plans, pantry, state.selected); - - const today = forecast.length > 0 && forecast[0].dayIndex === 0 ? forecast[0] : null; - const upcoming = forecast.filter((d) => d.dayIndex > 0 && d.hasShortfall); - - state._todayShortfalls = today ? today.items.filter((it) => !it.enough) : []; - state._allForecastShortfalls = []; - for (const d of forecast) { - for (const it of d.items) { - if (!it.enough) state._allForecastShortfalls.push(it); - } - } - - if (titleEl) { - const wd = WEEKDAYS_LONG[state.selected.getDay()]; - titleEl.textContent = `${wd}, ${state.selected.getDate()} ${MONTHS_SHORT[state.selected.getMonth()]} — składniki`; - } - if (subEl) subEl.textContent = 'Porównanie potrzeb z zapasami w spiżarni.'; - - if (!today || today.items.length === 0) { - body.innerHTML = '

Najpierw zaplanuj posiłki.

'; - updateIngButtons(state); - return; - } - - const shortItems = today.items.filter((it) => !it.enough); - const okItems = today.items.filter((it) => it.enough); - let html = ''; - - if (shortItems.length === 0) { - html += `
-
- -
-
-

Wszystko masz w spiżarni

-

${today.items.length} ${plIngredientWord(today.items.length)} — zapasy wystarczą

-
-
`; - } else { - html += `
-
- -
-
-

${shortItems.length} ${plIngredientWord(shortItems.length)} do kupienia

-

Brakuje składników na zaplanowane posiłki

-
-
`; - } - - if (shortItems.length > 0) { - html += `
-

- Do kupienia -

-
    - ${shortItems.map((ing) => ` -
  • -
    -
    -

    ${escapeHtml(ing.name)}

    -

    - potrzeba ${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)} - · - w spiżarni ${ing.pantryQty > 0 ? formatAmount(ing.pantryQty) + ' ' + escapeHtml(ing.pantryUnit) : 'brak'} -

    -
    -
    -

    −${formatAmount(ing.shortfall)}

    -

    ${escapeHtml(ing.pantryUnit)}

    -
    -
  • `).join('')} -
-
`; - } - - if (okItems.length > 0) { - html += `
-

- W spiżarni -

-
    - ${okItems.map((ing) => ` -
  • -
    -
    -

    ${escapeHtml(ing.name)}

    -

    - potrzeba ${formatAmount(ing.amount)} ${escapeHtml(ing.pantryUnit)} - · - masz ${formatAmount(ing.pantryQty)} ${escapeHtml(ing.pantryUnit)} -

    -
    -
  • `).join('')} -
-
`; - } - - if (upcoming.length > 0) { - html += `
-

- Nadchodzące braki -

-
- ${upcoming.map((day) => { - const wd = WEEKDAYS_LONG[day.date.getDay()]; - const label = `${wd}, ${day.date.getDate()} ${MONTHS_SHORT[day.date.getMonth()]}`; - const shorts = day.items.filter((it) => !it.enough); - return `
-

- ${escapeHtml(label)} -

-
    - ${shorts.map((it) => ` -
  • - ${escapeHtml(it.name)} - −${formatAmount(it.shortfall)} ${escapeHtml(it.pantryUnit)} -
  • `).join('')} -
-
`; - }).join('')} -
-
`; - } - - body.innerHTML = html; - updateIngButtons(state); -} - -function formatAmount(n) { - return Number.isInteger(n) ? String(n) : String(n); -} - -function seedDemoIfEmpty(plans) { - const todayKey = dateKey(new Date()); - if (Object.keys(plans).length > 0) return plans; - return { - ...plans, - [todayKey]: { - sniadanie: [{ id: newPlanEntryId(), recipeId: 'owsianka', servings: 1 }], - obiad: [{ id: newPlanEntryId(), recipeId: 'salatka', servings: 1 }], - kolacja: [{ id: newPlanEntryId(), recipeId: 'makaron', servings: 1 }], - }, - }; -} - -export function setupMealPlanner() { - let plans = loadPlans(); - plans = seedDemoIfEmpty(plans); - savePlans(plans); - - const state = { - mode: 'week', - weekStart: startOfWeekMonday(new Date()), - monthAnchor: startOfDay(new Date()), - selected: startOfDay(new Date()), - plans, - nutritionExpanded: false, - pickerSlot: null, - }; - - const weekGrid = document.getElementById('calendar-week-grid'); - const monthGrid = document.getElementById('calendar-month-grid'); - - const pickerBackdrop = document.getElementById('planner-picker-backdrop'); - const pickerSheet = document.getElementById('planner-picker-sheet'); - const ingBackdrop = document.getElementById('planner-ing-backdrop'); - const ingSheet = document.getElementById('planner-ing-sheet'); - - const rerender = () => { - syncModeToggle(state.mode); - updatePeriodLabel(state.mode, state.weekStart, state.monthAnchor); - syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected); - renderWeekGrid(state.weekStart, state.selected, state.plans); - renderMonthGrid(state.monthAnchor, state.selected, state.plans); - renderDayContent(state); - }; - - const persist = () => { - savePlans(state.plans); - rerender(); - }; - - bindDayClicks(weekGrid?.parentElement, state, rerender); - bindDayClicks(monthGrid?.parentElement, state, rerender); - - document.getElementById('cal-prev')?.addEventListener('click', () => { - if (state.mode === 'week') { - state.weekStart = addWeeks(state.weekStart, -1); - if (!weekContains(state.weekStart, state.selected)) { - state.selected = new Date(state.weekStart); - } - } else { - state.monthAnchor = addMonths(state.monthAnchor, -1); - if (!sameMonth(state.monthAnchor, state.selected)) { - state.selected = startOfMonth(state.monthAnchor); - } - } - rerender(); - }); - - document.getElementById('cal-next')?.addEventListener('click', () => { - if (state.mode === 'week') { - state.weekStart = addWeeks(state.weekStart, 1); - if (!weekContains(state.weekStart, state.selected)) { - state.selected = new Date(state.weekStart); - } - } else { - state.monthAnchor = addMonths(state.monthAnchor, 1); - if (!sameMonth(state.monthAnchor, state.selected)) { - state.selected = startOfMonth(state.monthAnchor); - } - } - rerender(); - }); - - document.getElementById('cal-go-today')?.addEventListener('click', () => { - const today = startOfDay(new Date()); - state.selected = today; - state.weekStart = startOfWeekMonday(today); - state.monthAnchor = startOfMonth(today); - rerender(); - }); - - document.getElementById('planner-toggle-nutrition')?.addEventListener('click', () => { - state.nutritionExpanded = !state.nutritionExpanded; - const details = document.getElementById('planner-nutrition-details'); - const chev = document.getElementById('planner-nutrition-chevron'); - const btn = document.getElementById('planner-toggle-nutrition'); - if (details) details.classList.toggle('hidden', !state.nutritionExpanded); - if (chev) chev.classList.toggle('rotate-180', state.nutritionExpanded); - if (btn) btn.setAttribute('aria-expanded', state.nutritionExpanded ? 'true' : 'false'); - }); - - document.getElementById('planner-meal-slots')?.addEventListener('click', (e) => { - const addBtn = e.target.closest('.planner-add-meal'); - if (addBtn) { - const slotId = addBtn.getAttribute('data-slot-id'); - state.pickerSlot = slotId; - renderPickerList(slotId); - openSheet(pickerBackdrop, pickerSheet); - return; - } - const clearBtn = e.target.closest('.planner-clear-meal'); - if (clearBtn) { - const slotId = clearBtn.getAttribute('data-slot-id'); - const entryId = clearBtn.getAttribute('data-entry-id'); - const key = dateKey(state.selected); - const arr = state.plans[key]?.[slotId]; - if (!Array.isArray(arr) || !entryId) return; - const next = arr.filter((x) => x && x.id !== entryId); - if (!state.plans[key]) state.plans[key] = {}; - if (next.length === 0) delete state.plans[key][slotId]; - else state.plans[key][slotId] = next; - if (Object.keys(state.plans[key]).length === 0) delete state.plans[key]; - persist(); - return; - } - const minus = e.target.closest('.planner-serv-minus'); - const plus = e.target.closest('.planner-serv-plus'); - const slotId = (minus || plus)?.getAttribute('data-slot-id'); - const entryId = (minus || plus)?.getAttribute('data-entry-id'); - if (!slotId || !entryId) return; - const key = dateKey(state.selected); - const arr = state.plans[key]?.[slotId]; - if (!Array.isArray(arr)) return; - const entry = arr.find((x) => x && x.id === entryId); - if (!entry) return; - let s = Math.max(1, Number(entry.servings) || 1); - if (minus) s = Math.max(1, s - 1); - if (plus) s = Math.min(12, s + 1); - entry.servings = s; - persist(); - }); - - const closePicker = () => { - state.pickerSlot = null; - closeSheet(pickerBackdrop, pickerSheet); - }; - - bindPlannerSheetDragClose(pickerSheet, closePicker); - bindPlannerSheetDragClose(ingSheet, () => closeSheet(ingBackdrop, ingSheet)); - - pickerBackdrop?.addEventListener('click', closePicker); - - document.getElementById('planner-picker-list')?.addEventListener('click', (e) => { - const pick = e.target.closest('.planner-pick-recipe'); - if (!pick || !state.pickerSlot) return; - const recipeId = pick.getAttribute('data-recipe-id'); - if (!recipeId || !RECIPES[recipeId]) return; - const key = dateKey(state.selected); - if (!state.plans[key]) state.plans[key] = {}; - const slotId = state.pickerSlot; - if (!state.plans[key][slotId]) state.plans[key][slotId] = []; - state.plans[key][slotId].push({ id: newPlanEntryId(), recipeId, servings: 1 }); - closePicker(); - persist(); - }); - - document.getElementById('planner-open-ingredients')?.addEventListener('click', () => { - if (sumDayNutrition(getDayPlan(state.plans, state.selected)).mealCount === 0) return; - renderIngredientsSheet(state); - openSheet(ingBackdrop, ingSheet); - }); - - ingBackdrop?.addEventListener('click', () => { - closeSheet(ingBackdrop, ingSheet); - }); - - ingSheet?.addEventListener('click', (e) => { - const row = e.target.closest('.planner-ing-row'); - if (!row || !ingSheet.contains(row)) return; - row.classList.toggle('ingredient-active'); - }); - - document.getElementById('planner-ing-add-all')?.addEventListener('click', () => { - const items = state._todayShortfalls || []; - if (items.length === 0) return; - const lines = items.map((it) => ({ - ingredientId: it.ingredientId, - amount: it.shortfall, - unit: it.pantryUnit, - category: it.category, - sourceNote: 'Braki z planu dnia', - })); - addOrMergeShoppingLines(lines); - showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`); - window.refreshShopping?.(); - closeSheet(ingBackdrop, ingSheet); - }); - - document.getElementById('planner-ing-add-btn')?.addEventListener('click', () => { - const items = state._allForecastShortfalls || []; - if (items.length === 0) return; - const map = new Map(); - for (const it of items) { - const key = it.ingredientId; - if (map.has(key)) { - const cur = map.get(key); - cur.amount = Math.round((cur.amount + it.shortfall) * 10) / 10; - } else { - map.set(key, { - ingredientId: it.ingredientId, - amount: it.shortfall, - unit: it.pantryUnit, - category: it.category, - sourceNote: 'Braki z planu tygodnia', - }); - } - } - const lines = [...map.values()]; - addOrMergeShoppingLines(lines); - showPlannerToast(`Dodano ${lines.length} braków na listę zakupów.`); - window.refreshShopping?.(); - closeSheet(ingBackdrop, ingSheet); - }); - - rerender(); - - bindCalendarSwipeGesture(state, rerender); - - requestAnimationFrame(() => { - const ww = document.getElementById('calendar-week-wrap'); - const mw = document.getElementById('calendar-month-wrap'); - const t = 'max-height 300ms ease, opacity 200ms ease'; - if (ww) ww.style.transition = t; - if (mw) mw.style.transition = t; - }); -} diff --git a/stacks/recipe/js/views/Pantry.js b/stacks/recipe/js/views/Pantry.js deleted file mode 100644 index 62f2085..0000000 --- a/stacks/recipe/js/views/Pantry.js +++ /dev/null @@ -1,464 +0,0 @@ -import { - INGREDIENTS, - CATEGORY_LABELS, - pantryQtyStep, -} from '../data/catalog.js'; -import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js'; -import { showAppToast } from '../ui/toast.js'; - -/* ── helpers ── */ - -function esc(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -function unitLabel(u) { - return u === 'szt' ? 'szt.' : u; -} - -function normalizeSearch(q) { - return String(q).trim().toLowerCase(); -} - -const CATEGORY_ICONS = { - pieczywo: 'fa-bread-slice', - nabial: 'fa-cheese', - mieso_ryby: 'fa-drumstick-bite', - warzywa: 'fa-carrot', - owoce: 'fa-apple-whole', - suche: 'fa-wheat-awn', - przyprawy: 'fa-leaf', - inne: 'fa-jar', -}; - -/* ── state ── */ - -let showOnlyStock = false; -let editingId = null; -/** @type {Set} */ -const selectedCategories = new Set(); - -let editShopStep = 1; -let editShopUsesPacks = false; - -const BOTTOM = '5.25rem'; -const HIDDEN_Y = `translateY(calc(100% + ${BOTTOM}))`; - -/* ══════════════════════ HTML SHELL ══════════════════════ */ - -export function getPantryHTML() { - return ` - `; -} - -/* ══════════════════════ CATEGORY CHIPS (multi-select) ══════════════════════ */ - -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-chips'); - if (!wrap) return; - - const keys = allCategoryKeys(); - wrap.innerHTML = keys.map(k => { - const active = selectedCategories.has(k); - const icon = CATEGORY_ICONS[k] || 'fa-jar'; - const cls = active - ? 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-900 text-white transition-colors' - : 'shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors'; - return ``; - }).join(''); - - wrap.querySelectorAll('.pv2-cat-chip').forEach(btn => { - btn.addEventListener('click', () => { - const cat = btn.dataset.cat; - if (selectedCategories.has(cat)) selectedCategories.delete(cat); - else selectedCategories.add(cat); - renderCategoryChips(); - renderBoard(); - }); - }); -} - -/* ══════════════════════ BOARD RENDERING ══════════════════════ */ - -function getFilteredIds(searchRaw) { - const q = normalizeSearch(searchRaw); - return Object.keys(INGREDIENTS).filter(id => { - const d = INGREDIENTS[id]; - if (selectedCategories.size > 0 && !selectedCategories.has(d.category)) return false; - if (!q) return true; - return d.name.toLowerCase().includes(q) || (CATEGORY_LABELS[d.category] || '').toLowerCase().includes(q); - }).sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl')); -} - -function chipHtml(id, pantry) { - const def = INGREDIENTS[id]; - const qty = Number(pantry[id]) || 0; - const u = unitLabel(def.pantryUnit); - - if (qty > 0) { - return ``; - } - - return ``; -} - -function groupByCategory(ids) { - /** @type {Map} */ - const groups = new Map(); - for (const id of ids) { - const cat = INGREDIENTS[id].category; - if (!groups.has(cat)) groups.set(cat, []); - groups.get(cat).push(id); - } - return [...groups.keys()] - .sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))) - .map(cat => ({ cat, ids: groups.get(cat) })); -} - -function renderBoard() { - const root = document.getElementById('pantry-board'); - if (!root) return; - - const q = document.getElementById('pantry-search')?.value || ''; - const pantry = loadPantry(); - const allFiltered = getFilteredIds(q); - const visible = showOnlyStock - ? allFiltered.filter(id => (Number(pantry[id]) || 0) > 0) - : allFiltered; - - if (visible.length === 0) { - root.innerHTML = showOnlyStock - ? `
-
- -
-

Nic na stanie

-

Wyłącz filtr, aby zobaczyć cały katalog produktów

-
` - : `

Brak wyników — zmień wyszukiwanie lub filtry.

`; - return; - } - - const groups = groupByCategory(visible); - let html = ''; - for (const { cat, ids } of groups) { - const icon = CATEGORY_ICONS[cat] || 'fa-jar'; - html += ` -
-

- ${esc(categoryLabel(cat))} -

-
${ids.map(id => chipHtml(id, pantry)).join('')}
-
`; - } - - root.innerHTML = html; - - root.querySelectorAll('.pv2-chip').forEach(btn => { - btn.addEventListener('click', () => openEditSheet(btn.dataset.id)); - }); -} - -/* ══════════════════════ STOCK TOGGLE ══════════════════════ */ - -function updateToggleVisuals() { - const btn = document.getElementById('pantry-stock-toggle'); - if (!btn) return; - const thumb = btn.querySelector('span'); - btn.setAttribute('aria-checked', String(showOnlyStock)); - if (showOnlyStock) { - btn.classList.remove('bg-gray-200'); - btn.classList.add('bg-emerald-500'); - thumb?.classList.add('translate-x-[18px]'); - } else { - btn.classList.add('bg-gray-200'); - btn.classList.remove('bg-emerald-500'); - thumb?.classList.remove('translate-x-[18px]'); - } -} - -/* ══════════════════════ EDIT BOTTOM SHEET ══════════════════════ */ - -function openEditSheet(ingredientId) { - const def = INGREDIENTS[ingredientId]; - if (!def) return; - editingId = ingredientId; - - const pantry = loadPantry(); - const qty = Number(pantry[ingredientId]) || 0; - const u = unitLabel(def.pantryUnit); - const step = pantryQtyStep(ingredientId); - const pack = def.purchasePack; - - const nameEl = document.getElementById('pv2-edit-name'); - if (nameEl) nameEl.textContent = def.name; - - const metaEl = document.getElementById('pv2-edit-meta'); - if (metaEl) { - let meta = categoryLabel(def.category); - if (pack) meta += ` · ${pack.label || `${pack.amount} ${u}`}`; - metaEl.textContent = meta; - } - - const qtyEl = document.getElementById('pv2-edit-qty'); - if (qtyEl) qtyEl.value = qty > 0 ? String(Math.round(qty)) : '0'; - - const unitEl = document.getElementById('pv2-edit-unit'); - if (unitEl) unitEl.textContent = u; - - editShopUsesPacks = Boolean(pack && pack.amount > 0); - editShopStep = editShopUsesPacks ? 1 : step; - - const shopQtyEl = document.getElementById('pv2-shop-qty'); - if (shopQtyEl) shopQtyEl.value = String(editShopStep); - - const shopUnitEl = document.getElementById('pv2-shop-unit'); - if (shopUnitEl) shopUnitEl.textContent = editShopUsesPacks ? 'opak.' : u; - - const shopHintEl = document.getElementById('pv2-shop-hint'); - if (shopHintEl) { - if (editShopUsesPacks) { - const lab = pack.label || `${pack.amount} ${u}`; - shopHintEl.textContent = `1 opak. = ${lab}`; - } else { - shopHintEl.textContent = ''; - } - } - - renderNutritionInSheet(def); - - const bg = document.getElementById('pv2-edit-bg'); - const sheet = document.getElementById('pv2-edit-sheet'); - if (!bg || !sheet) return; - bg.classList.remove('hidden'); - sheet.classList.remove('hidden'); - requestAnimationFrame(() => { - bg.classList.remove('opacity-0'); - sheet.style.transform = 'translateY(0)'; - }); -} - -function nutritionListRow(label, valueHtml) { - return `
  • - ${esc(label)} - ${valueHtml} -
  • `; -} - -function renderNutritionInSheet(def) { - const wrap = document.getElementById('pv2-edit-nutrition'); - if (!wrap) return; - const n = def.nutritionPer100g; - if (!n) { wrap.innerHTML = ''; return; } - - const refLabel = def.pantryUnit === 'ml' ? '100 ml produktu' : '100 g produktu'; - wrap.innerHTML = ` -
    -

    ${esc(refLabel)}

    -
      - ${nutritionListRow('Energia', `${n.kcal} kcal`)} - ${nutritionListRow('Białko', `${n.protein} g`)} - ${nutritionListRow('Tłuszcz', `${n.fat} g`)} - ${nutritionListRow('Węglowodany', `${n.carbs} g`)} -
    -
    `; -} - -function closeEditSheet() { - editingId = null; - const bg = document.getElementById('pv2-edit-bg'); - const sheet = document.getElementById('pv2-edit-sheet'); - if (sheet) sheet.style.transform = HIDDEN_Y; - if (bg) bg.classList.add('opacity-0'); - setTimeout(() => { - bg?.classList.add('hidden'); - sheet?.classList.add('hidden'); - }, 300); - renderBoard(); -} - -function getEditQty() { - const el = document.getElementById('pv2-edit-qty'); - return Math.max(0, parseFloat(String(el?.value).replace(',', '.')) || 0); -} - -function setEditQty(v) { - const el = document.getElementById('pv2-edit-qty'); - if (el) el.value = String(Math.max(0, Math.round(v))); -} - -function applyEditQty(newQty) { - if (!editingId) return; - const v = Math.max(0, Math.round(Number(newQty) * 1000) / 1000 || 0); - setPantryQty(editingId, v); - setEditQty(v); -} - -function readShopQty() { - const el = document.getElementById('pv2-shop-qty'); - return Math.max(1, Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0)); -} - -function setShopQty(v) { - const el = document.getElementById('pv2-shop-qty'); - if (el) el.value = String(Math.max(1, Math.round(Number(v)))); -} - -function bindEditSheet() { - document.getElementById('pv2-edit-bg')?.addEventListener('click', closeEditSheet); - - document.getElementById('pv2-edit-minus')?.addEventListener('click', () => { - if (!editingId) return; - applyEditQty(Math.max(0, getEditQty() - pantryQtyStep(editingId))); - }); - - document.getElementById('pv2-edit-plus')?.addEventListener('click', () => { - if (!editingId) return; - applyEditQty(getEditQty() + pantryQtyStep(editingId)); - }); - - document.getElementById('pv2-edit-qty')?.addEventListener('change', () => { - applyEditQty(getEditQty()); - }); - - document.getElementById('pv2-shop-minus')?.addEventListener('click', () => { - setShopQty(Math.max(1, readShopQty() - editShopStep)); - }); - - document.getElementById('pv2-shop-plus')?.addEventListener('click', () => { - setShopQty(readShopQty() + editShopStep); - }); - - document.getElementById('pv2-shop-qty')?.addEventListener('change', () => { - setShopQty(readShopQty()); - }); - - document.getElementById('pv2-shop-add')?.addEventListener('click', () => { - if (!editingId) return; - const def = INGREDIENTS[editingId]; - if (!def) return; - const count = readShopQty(); - const u = unitLabel(def.pantryUnit); - - if (editShopUsesPacks && def.purchasePack) { - const packAmt = def.purchasePack.amount; - const total = count * packAmt; - const note = `${count}× ${def.purchasePack.label || `${packAmt} ${u}`}`; - addIngredientToKitchenList(editingId, total, note); - showAppToast(`Dodano ${count} op. (${total} ${u}) na listę.`); - } else { - addIngredientToKitchenList(editingId, count); - showAppToast(`Dodano ${count} ${u} na listę.`); - } - window.refreshShopping?.(); - }); -} - -/* ══════════════════════ PUBLIC API ══════════════════════ */ - -export function refreshPantry() { - renderCategoryChips(); - renderBoard(); -} - -export function setupPantry() { - renderCategoryChips(); - renderBoard(); - bindEditSheet(); - - document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard()); - - document.getElementById('pantry-stock-toggle')?.addEventListener('click', () => { - showOnlyStock = !showOnlyStock; - updateToggleVisuals(); - renderBoard(); - }); - - window.refreshPantry = refreshPantry; -} diff --git a/stacks/recipe/js/views/RecipeDetail.js b/stacks/recipe/js/views/RecipeDetail.js deleted file mode 100644 index e8be2d3..0000000 --- a/stacks/recipe/js/views/RecipeDetail.js +++ /dev/null @@ -1,290 +0,0 @@ -export function getRecipeDetailHTML() { - return ` -
    - -
    - - -
    - -
    - Zdjęcie: Serek z owocami -
    - -
    - -
    -
    -

    Serek wiejski z orzechami i owocami

    -
    - -
    - Śniadanie - Wegetariańskie - Słodkie -
    - -
    -
    -
    5 min
    -
    642 kcal
    -
    - -
    - -
    - 1 - -
    - -
    -
    -
    - -
    - - - -
    - -
    - -
    -
    - Zaznacz, by dodać do listy zakupów -
    - -
      -
    • -
      - Serek wiejski - 200 g -
    • - -
    • -
      - Miód - 10 g -
    • - -
    • -
      - Orzechy włoskie -
      - - 50 g -
      -
    • - -
    • -
      - Truskawki -
      - - 100 g -
      -
    • - -
    • -
      - Borówki ameryk. -
      - - 100 g -
      -
    • -
    - - -
    - - - - - -
    -
    - - - -
    -
    -

    Zmień składnik

    - -
    - -
    -
    -
    - -
    - `; -} - -export function setupRecipeDetail() { - let currentServings = 1; // Domślnie 1 porcja dla tego przepisu - const defaultServings = 1; - let currentlySwapping = null; - - // Dane do dynamicznego modala - const swapOptions = { - 'orzechy': [ - { name: 'Orzechy włoskie', hint: 'Bazowe', color: 'gray' }, - { name: 'Migdały', hint: '+ Białko', color: 'blue' }, - { name: 'Orzechy laskowe', hint: 'Klasyk', color: 'gray' }, - { name: 'Orzechy nerkowca', hint: 'Słodsze', color: 'gray' }, - { name: 'Orzechy pekan', hint: '+ Tłuszcz', color: 'green' } - ], - 'owoce1': [ - { name: 'Truskawki', hint: 'Bazowe', color: 'gray' }, - { name: 'Gruszka konferencja', hint: '+ Węgle', color: 'blue' }, - { name: 'Banany', hint: '+ Kalorie', color: 'green' } - ], - 'owoce2': [ - { name: 'Borówki ameryk.', hint: 'Bazowe', color: 'gray' }, - { name: 'Jagody leśne', hint: 'Sezonowe', color: 'blue' }, - { name: 'Maliny', hint: '- Kalorie', color: 'green' } - ] - }; - - window.switchTab = (tabId, clickedBtn) => { - document.querySelectorAll('.tab-content').forEach(el => { - el.classList.remove('block'); - el.classList.add('hidden'); - }); - - const targetTab = document.getElementById(`tab-${tabId}`); - targetTab.classList.remove('hidden'); - targetTab.classList.add('block'); - targetTab.parentElement.scrollTop = 0; - - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.classList.remove('text-gray-900', 'border-gray-900', 'font-semibold'); - btn.classList.add('text-gray-500', 'border-transparent', 'font-medium'); - }); - - clickedBtn.classList.remove('text-gray-500', 'border-transparent', 'font-medium'); - clickedBtn.classList.add('text-gray-900', 'border-gray-900', 'font-semibold'); - }; - - window.toggleIngredient = (element) => { - element.classList.toggle('ingredient-active'); - }; - - window.changeServings = (delta) => { - const newServings = currentServings + delta; - if (newServings < 1) return; - - currentServings = newServings; - document.getElementById('servings-count').innerText = currentServings; - - const ratio = currentServings / defaultServings; - document.querySelectorAll('.ingredient-amount').forEach(el => { - const baseAmount = parseFloat(el.getAttribute('data-base-amount')); - const unit = el.getAttribute('data-unit'); - - if (!isNaN(baseAmount)) { - let newAmount = baseAmount * ratio; - newAmount = Number.isInteger(newAmount) ? newAmount : parseFloat(newAmount.toFixed(1)); - el.innerText = `${newAmount} ${unit}`; - } - }); - }; - - window.openSwapModal = (type) => { - currentlySwapping = type; - - let title = ''; - if(type === 'orzechy') title = 'Orzechy'; - if(type === 'owoce1') title = 'Owoce bazy'; - if(type === 'owoce2') title = 'Dodatki owocowe'; - - document.getElementById('swap-title-target').innerText = title; - - // Wygeneruj opcje na podstawie słownika - const container = document.getElementById('swap-options-container'); - container.innerHTML = swapOptions[type].map(opt => { - let badgeClass = 'text-gray-600 bg-gray-200'; // Domyślny gray - if (opt.color === 'blue') badgeClass = 'text-blue-600 bg-blue-100'; - if (opt.color === 'green') badgeClass = 'text-green-600 bg-green-100'; - - return ` - - `; - }).join(''); - - const backdrop = document.getElementById('swap-backdrop'); - backdrop.classList.remove('hidden'); - setTimeout(() => backdrop.classList.remove('opacity-0'), 10); - - const modal = document.getElementById('swap-modal'); - modal.classList.remove('translate-y-full'); - modal.classList.add('translate-y-0'); - }; - - window.closeSwapModal = () => { - const backdrop = document.getElementById('swap-backdrop'); - backdrop.classList.add('opacity-0'); - setTimeout(() => backdrop.classList.add('hidden'), 300); - - const modal = document.getElementById('swap-modal'); - modal.classList.remove('translate-y-0'); - modal.classList.add('translate-y-full'); - }; - - window.confirmSwap = (newItemName) => { - if (currentlySwapping === 'orzechy') { - document.getElementById('ingredient-orzechy').innerText = newItemName; - } else if (currentlySwapping === 'owoce1') { - document.getElementById('ingredient-owoce1').innerText = newItemName; - } else if (currentlySwapping === 'owoce2') { - document.getElementById('ingredient-owoce2').innerText = newItemName; - } - closeSwapModal(); - }; -} \ No newline at end of file diff --git a/stacks/recipe/js/views/RecipeList.js b/stacks/recipe/js/views/RecipeList.js deleted file mode 100644 index f1d9428..0000000 --- a/stacks/recipe/js/views/RecipeList.js +++ /dev/null @@ -1,174 +0,0 @@ -export function getRecipeListHTML() { - return ` -
    -
    -
    -
    - -
    - -
    -
    - -
    -
    - -
    -
    - Placki -
    -
    -

    Puszyste placki

    -

    Klasyczne placki na śniadanie

    -
    -
    -
    15 min
    -
    320 kcal
    -
    -
    - Śniadanie -
    -
    -
    -
    - -
    -
    - Sałatka -
    -
    -

    Sałatka z kurczakiem

    -

    Zielone warzywa z grillowanym kurczakiem

    -
    -
    -
    20 min
    -
    250 kcal
    -
    -
    - Obiad -
    -
    -
    -
    - -
    -
    - Makaron -
    -
    -

    Makaron z pomidorami i bazylią

    -

    Aromatyczny sos pomidorowy z czosnkiem

    -
    -
    -
    30 min
    -
    450 kcal
    -
    -
    - Kolacja -
    -
    -
    -
    - -
    -
    - Koktajl -
    -
    -

    Koktajl owocowy

    -

    Mix jagód i jogurtu

    -
    -
    -
    5 min
    -
    180 kcal
    -
    -
    - Przekąska -
    -
    -
    -
    - -
    -
    - Tost z awokado -
    -
    -

    Tost z awokado

    -

    Chleb na zakwasie z rozgniecionym awokado

    -
    -
    -
    10 min
    -
    220 kcal
    -
    -
    - Śniadanie -
    -
    -
    -
    - -
    -
    - Łosoś -
    -
    -

    Grillowany łosoś

    -

    Świeży łosoś z masłem cytrynowym

    -
    -
    -
    25 min
    -
    380 kcal
    -
    -
    - Kolacja -
    -
    -
    -
    - -
    -
    - Tacos -
    -
    -

    Tacos z wołowiną

    -

    Pikantna mielona wołowina ze świeżą salsą

    -
    -
    -
    20 min
    -
    410 kcal
    -
    -
    - Kolacja -
    -
    -
    -
    - -
    -
    - Owsianka -
    -
    -

    Miska owsianki

    -

    Ciepła owsianka z miodem i orzechami

    -
    -
    -
    10 min
    -
    210 kcal
    -
    -
    - Śniadanie -
    -
    -
    -
    - -
    -
    -
    - `; -} diff --git a/stacks/recipe/js/views/Shopping.js b/stacks/recipe/js/views/Shopping.js deleted file mode 100644 index 7c82b74..0000000 --- a/stacks/recipe/js/views/Shopping.js +++ /dev/null @@ -1,237 +0,0 @@ -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; -} diff --git a/stacks/recipe/manifest.webmanifest b/stacks/recipe/manifest.webmanifest deleted file mode 100644 index 92e00a2..0000000 --- a/stacks/recipe/manifest.webmanifest +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "Recipe App", - "short_name": "Recipe", - "description": "Plan posiłków, spiżarnia i zakupy", - "start_url": "./", - "scope": "./", - "display": "standalone", - "background_color": "#f3f4f6", - "theme_color": "#111827", - "lang": "pl", - "icons": [ - { - "src": "./icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "./icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "./icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/stacks/recipe/nginx/default.conf b/stacks/recipe/nginx/default.conf deleted file mode 100644 index 5ba7c60..0000000 --- a/stacks/recipe/nginx/default.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Do not store responses in browser or shared caches (Cloudflare, etc. respect unless overridden) - add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; - add_header Pragma "no-cache" always; - - location = /manifest.webmanifest { - default_type application/manifest+json; - add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; - } - - location = /sw.js { - add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; - } - - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/stacks/recipe/sw.js b/stacks/recipe/sw.js deleted file mode 100644 index da180d5..0000000 --- a/stacks/recipe/sw.js +++ /dev/null @@ -1,13 +0,0 @@ -/* Minimal service worker — wymagany m.in. przez Chrome, żeby pokazać „Zainstaluj aplikację”. - Sieć zawsze na pierwszym miejscu (bez cache’u zasobów). */ -self.addEventListener('install', (event) => { - self.skipWaiting(); -}); - -self.addEventListener('activate', (event) => { - event.waitUntil(self.clients.claim()); -}); - -self.addEventListener('fetch', (event) => { - event.respondWith(fetch(event.request)); -});