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 31e470b..0000000
Binary files a/stacks/recipe/icons/apple-touch-icon.png and /dev/null differ
diff --git a/stacks/recipe/icons/icon-192.png b/stacks/recipe/icons/icon-192.png
deleted file mode 100644
index f31fa2b..0000000
Binary files a/stacks/recipe/icons/icon-192.png and /dev/null differ
diff --git a/stacks/recipe/icons/icon-512.png b/stacks/recipe/icons/icon-512.png
deleted file mode 100644
index ca5c9ef..0000000
Binary files a/stacks/recipe/icons/icon-512.png and /dev/null differ
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 `
-
-
-
-
Filtry
-
-
-
-
-
-
Pora posiłku
-
-
-
-
-
-
-
-
-
-
-
-
Dieta i tagi
-
-
-
-
-
-
-
-
-
-
Maks. czas przygotowania
- 30 min
-
-
-
-
- 5 min30 min1 godz.2 godz.+
-
-
-
-
-
-
-
-
-
- `;
-}
-
-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 `
-
-
-
-
-
-
-
-
-
- ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
-
-
-
-
-
- ${WEEKDAYS_SHORT.map((d) => `
${d}
`).join('')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-}
-
-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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-}
-
-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
-
-
-
-
-
-
- Sałatka
-
-
-
Sałatka z kurczakiem
-
Zielone warzywa z grillowanym kurczakiem
-
-
-
-
-
-
- Makaron
-
-
-
Makaron z pomidorami i bazylią
-
Aromatyczny sos pomidorowy z czosnkiem
-
-
-
-
-
-
- Koktajl
-
-
-
Koktajl owocowy
-
Mix jagód i jogurtu
-
-
-
-
-
-
- Tost z awokado
-
-
-
Tost z awokado
-
Chleb na zakwasie z rozgniecionym awokado
-
-
-
-
-
-
- Łosoś
-
-
-
Grillowany łosoś
-
Świeży łosoś z masłem cytrynowym
-
-
-
-
-
-
- Tacos
-
-
-
Tacos z wołowiną
-
Pikantna mielona wołowina ze świeżą salsą
-
-
-
-
-
-
- Owsianka
-
-
-
Miska owsianki
-
Ciepła owsianka z miodem i orzechami
-
-
-
-
-
-
-
- `;
-}
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));
-});