Add pantry and shopping lists
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
/* Utilities */
|
/* Utilities */
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 flex justify-center p-4 font-sans text-gray-800">
|
<body class="bg-gray-100 flex justify-center p-4 font-sans text-gray-800">
|
||||||
|
|||||||
@@ -2,25 +2,35 @@ import { getRecipeListHTML } from './views/RecipeList.js';
|
|||||||
import { getFilterHTML, setupFilter } from './views/Filter.js';
|
import { getFilterHTML, setupFilter } from './views/Filter.js';
|
||||||
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
|
import { getRecipeDetailHTML, setupRecipeDetail } from './views/RecipeDetail.js';
|
||||||
import { getMealPlannerHTML, setupMealPlanner } from './views/MealPlanner.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 `
|
||||||
|
<div id="app-toast" class="pointer-events-none absolute left-4 right-4 bottom-28 z-[60] opacity-0 translate-y-2 transition-all duration-300" role="status">
|
||||||
|
<div class="rounded-xl bg-gray-900 text-white text-sm font-medium px-4 py-3 shadow-lg text-center" id="app-toast-text"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function getBottomNavHTML() {
|
function getBottomNavHTML() {
|
||||||
return `
|
return `
|
||||||
<nav id="app-bottom-nav" class="absolute bottom-0 left-0 right-0 w-full bg-white border-t border-gray-200 flex justify-between px-4 py-3 pb-6 z-20" aria-label="Główna nawigacja">
|
<nav id="app-bottom-nav" class="absolute bottom-0 left-0 right-0 w-full bg-white border-t border-gray-200 flex justify-between px-1 py-2.5 pb-6 z-20 gap-0" aria-label="Główna nawigacja">
|
||||||
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]">
|
<button type="button" data-tab="recipes" id="nav-recipes" class="nav-tab flex flex-col items-center gap-0.5 text-black flex-1 min-w-0 max-w-[5.5rem]">
|
||||||
<i class="fas fa-book text-xl" aria-hidden="true"></i>
|
<i class="fas fa-book text-base" aria-hidden="true"></i>
|
||||||
<span class="text-[11px] font-medium">Przepisy</span>
|
<span class="text-[9px] font-medium leading-tight text-center">Przepisy</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-tab="planner" id="nav-planner" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]">
|
<button type="button" data-tab="planner" id="nav-planner" class="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]">
|
||||||
<i class="far fa-calendar-alt text-xl" aria-hidden="true"></i>
|
<i class="far fa-calendar-alt text-base" aria-hidden="true"></i>
|
||||||
<span class="text-[11px] font-medium">Planer</span>
|
<span class="text-[9px] font-medium leading-tight text-center">Planer</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-tab="shopping" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]" disabled title="Wkrótce">
|
<button type="button" data-tab="pantry" id="nav-pantry" class="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]">
|
||||||
<i class="fas fa-shopping-cart text-xl" aria-hidden="true"></i>
|
<i class="fas fa-warehouse text-base" aria-hidden="true"></i>
|
||||||
<span class="text-[11px] font-medium">Zakupy</span>
|
<span class="text-[9px] font-medium leading-tight text-center">Spiżarnia</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-tab="pantry" class="nav-tab flex flex-col items-center gap-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]" disabled title="Wkrótce">
|
<button type="button" data-tab="shopping" id="nav-shopping" class="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]">
|
||||||
<i class="fas fa-box text-xl" aria-hidden="true"></i>
|
<i class="fas fa-cart-shopping text-base" aria-hidden="true"></i>
|
||||||
<span class="text-[11px] font-medium">Zapasy</span>
|
<span class="text-[9px] font-medium leading-tight text-center">Zakupy</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
@@ -29,21 +39,27 @@ function getBottomNavHTML() {
|
|||||||
function setupTabs() {
|
function setupTabs() {
|
||||||
const main = document.getElementById('main-view');
|
const main = document.getElementById('main-view');
|
||||||
const planner = document.getElementById('planner-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');
|
const nav = document.getElementById('app-bottom-nav');
|
||||||
if (!main || !planner || !nav) return;
|
if (!main || !planner || !pantry || !shopping || !nav) return;
|
||||||
|
|
||||||
const activeTab = 'nav-tab flex flex-col items-center gap-1 text-black min-w-[3.5rem]';
|
const 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-1 text-gray-500 hover:text-gray-700 min-w-[3.5rem]';
|
const idleTab = 'nav-tab flex flex-col items-center gap-0.5 text-gray-500 hover:text-gray-700 flex-1 min-w-0 max-w-[5.5rem]';
|
||||||
|
|
||||||
const apply = (tab) => {
|
const apply = (tab) => {
|
||||||
const showRecipes = tab === 'recipes';
|
main.classList.toggle('hidden', tab !== 'recipes');
|
||||||
main.classList.toggle('hidden', !showRecipes);
|
planner.classList.toggle('hidden', tab !== 'planner');
|
||||||
planner.classList.toggle('hidden', showRecipes);
|
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) => {
|
nav.querySelectorAll('.nav-tab[data-tab]').forEach((btn) => {
|
||||||
const id = btn.getAttribute('data-tab');
|
const id = btn.getAttribute('data-tab');
|
||||||
if (btn.hasAttribute('disabled')) return;
|
if (btn.hasAttribute('disabled')) return;
|
||||||
if (id === 'recipes' || id === 'planner') {
|
if (id === 'recipes' || id === 'planner' || id === 'pantry' || id === 'shopping') {
|
||||||
btn.className = id === tab ? activeTab : idleTab;
|
btn.className = id === tab ? activeTab : idleTab;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -53,10 +69,15 @@ function setupTabs() {
|
|||||||
const btn = e.target.closest('.nav-tab[data-tab]');
|
const btn = e.target.closest('.nav-tab[data-tab]');
|
||||||
if (!btn || btn.hasAttribute('disabled')) return;
|
if (!btn || btn.hasAttribute('disabled')) return;
|
||||||
const tab = btn.getAttribute('data-tab');
|
const tab = btn.getAttribute('data-tab');
|
||||||
if (tab === 'recipes' || tab === 'planner') apply(tab);
|
if (tab === 'recipes' || tab === 'planner' || tab === 'pantry' || tab === 'shopping') apply(tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
apply('recipes');
|
apply('recipes');
|
||||||
|
|
||||||
|
window.refreshStockViews = () => {
|
||||||
|
refreshPantry();
|
||||||
|
refreshShopping();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -65,13 +86,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
appContainer.innerHTML = `
|
appContainer.innerHTML = `
|
||||||
${getRecipeListHTML()}
|
${getRecipeListHTML()}
|
||||||
${getMealPlannerHTML()}
|
${getMealPlannerHTML()}
|
||||||
|
${getPantryHTML()}
|
||||||
|
${getShoppingHTML()}
|
||||||
${getBottomNavHTML()}
|
${getBottomNavHTML()}
|
||||||
${getRecipeDetailHTML()}
|
${getRecipeDetailHTML()}
|
||||||
${getFilterHTML()}
|
${getFilterHTML()}
|
||||||
|
${getAppToastHTML()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setupTabs();
|
setupTabs();
|
||||||
setupMealPlanner();
|
setupMealPlanner();
|
||||||
|
setupPantry();
|
||||||
|
setupShopping();
|
||||||
setupFilter();
|
setupFilter();
|
||||||
setupRecipeDetail();
|
setupRecipeDetail();
|
||||||
});
|
});
|
||||||
@@ -101,4 +127,4 @@ window.closeFilters = () => {
|
|||||||
const fv = document.getElementById('filter-view');
|
const fv = document.getElementById('filter-view');
|
||||||
fv.classList.add('hidden');
|
fv.classList.add('hidden');
|
||||||
fv.classList.remove('flex');
|
fv.classList.remove('flex');
|
||||||
};
|
};
|
||||||
|
|||||||
318
stacks/recipe/js/data/catalog.js
Normal file
318
stacks/recipe/js/data/catalog.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Katalog składników i przepisów — odpowiednik tabel w DB (edycja poza aplikacją).
|
||||||
|
* pantryUnit: jednostka magazynowa / sumowania na liście zakupów (g, ml, szt.).
|
||||||
|
* nutritionPer100g — wartości szacunkowe na 100 g (dla płynów: traktuj ml≈g przy wodzie).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS = {
|
||||||
|
pieczywo: 'Pieczywo',
|
||||||
|
nabial: 'Nabiał',
|
||||||
|
mieso_ryby: 'Mięso i ryby',
|
||||||
|
warzywa: 'Warzywa',
|
||||||
|
owoce: 'Owoce',
|
||||||
|
suche: 'Suche i kasze',
|
||||||
|
przyprawy: 'Przyprawy i zioła',
|
||||||
|
inne: 'Inne',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {Record<string, { id: string, name: string, category: keyof typeof CATEGORY_LABELS, pantryUnit: 'g'|'ml'|'szt', nutritionPer100g?: { kcal: number, protein: number, fat: number, carbs: number } }>} */
|
||||||
|
export const INGREDIENTS = {
|
||||||
|
maka_pszenna: {
|
||||||
|
id: 'maka_pszenna',
|
||||||
|
name: 'Mąka pszenna',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 364, protein: 10, fat: 1, carbs: 76 },
|
||||||
|
},
|
||||||
|
mleko: {
|
||||||
|
id: 'mleko',
|
||||||
|
name: 'Mleko',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'ml',
|
||||||
|
nutritionPer100g: { kcal: 42, protein: 3.4, fat: 1, carbs: 5 },
|
||||||
|
},
|
||||||
|
jajko: {
|
||||||
|
id: 'jajko',
|
||||||
|
name: 'Jajka',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 143, protein: 13, fat: 9.5, carbs: 1.1 },
|
||||||
|
},
|
||||||
|
piers_kurczaka: {
|
||||||
|
id: 'piers_kurczaka',
|
||||||
|
name: 'Pierś z kurczaka',
|
||||||
|
category: 'mieso_ryby',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 165, protein: 31, fat: 3.6, carbs: 0 },
|
||||||
|
},
|
||||||
|
mix_salat: {
|
||||||
|
id: 'mix_salat',
|
||||||
|
name: 'Mix sałat',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 20, protein: 1.5, fat: 0.3, carbs: 3 },
|
||||||
|
},
|
||||||
|
pomidor: {
|
||||||
|
id: 'pomidor',
|
||||||
|
name: 'Pomidor',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9 },
|
||||||
|
},
|
||||||
|
makaron_suchy: {
|
||||||
|
id: 'makaron_suchy',
|
||||||
|
name: 'Makaron',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 371, protein: 13, fat: 1.5, carbs: 74 },
|
||||||
|
},
|
||||||
|
pomidory_krojone: {
|
||||||
|
id: 'pomidory_krojone',
|
||||||
|
name: 'Pomidory krojone (puszka)',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 20, protein: 1, fat: 0.2, carbs: 4 },
|
||||||
|
},
|
||||||
|
bazylia_swieza: {
|
||||||
|
id: 'bazylia_swieza',
|
||||||
|
name: 'Bazylia świeża',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 23, protein: 3.2, fat: 0.6, carbs: 2.7 },
|
||||||
|
},
|
||||||
|
jogurt_naturalny: {
|
||||||
|
id: 'jogurt_naturalny',
|
||||||
|
name: 'Jogurt naturalny',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 61, protein: 3.5, fat: 3.3, carbs: 4.7 },
|
||||||
|
},
|
||||||
|
mieszanka_jagod: {
|
||||||
|
id: 'mieszanka_jagod',
|
||||||
|
name: 'Mieszanka jagód',
|
||||||
|
category: 'owoce',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 50, protein: 0.7, fat: 0.3, carbs: 12 },
|
||||||
|
},
|
||||||
|
miod: {
|
||||||
|
id: 'miod',
|
||||||
|
name: 'Miód',
|
||||||
|
category: 'inne',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 304, protein: 0.3, fat: 0, carbs: 82 },
|
||||||
|
},
|
||||||
|
chleb_zakwas: {
|
||||||
|
id: 'chleb_zakwas',
|
||||||
|
name: 'Chleb na zakwasie',
|
||||||
|
category: 'pieczywo',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 250, protein: 9, fat: 1.5, carbs: 49 },
|
||||||
|
},
|
||||||
|
awokado: {
|
||||||
|
id: 'awokado',
|
||||||
|
name: 'Awokado',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 160, protein: 2, fat: 15, carbs: 9 },
|
||||||
|
},
|
||||||
|
cytryna: {
|
||||||
|
id: 'cytryna',
|
||||||
|
name: 'Cytryna',
|
||||||
|
category: 'owoce',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 29, protein: 1.1, fat: 0.3, carbs: 9 },
|
||||||
|
},
|
||||||
|
losos_filet: {
|
||||||
|
id: 'losos_filet',
|
||||||
|
name: 'Filet z łososia',
|
||||||
|
category: 'mieso_ryby',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 208, protein: 20, fat: 13, carbs: 0 },
|
||||||
|
},
|
||||||
|
koper_swiezy: {
|
||||||
|
id: 'koper_swiezy',
|
||||||
|
name: 'Koper',
|
||||||
|
category: 'przyprawy',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 43, protein: 3.5, fat: 1.1, carbs: 7 },
|
||||||
|
},
|
||||||
|
mieso_wol_mielone: {
|
||||||
|
id: 'mieso_wol_mielone',
|
||||||
|
name: 'Mięso mielone wołowe',
|
||||||
|
category: 'mieso_ryby',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 250, protein: 26, fat: 15, carbs: 0 },
|
||||||
|
},
|
||||||
|
tortilla_kukurydziana: {
|
||||||
|
id: 'tortilla_kukurydziana',
|
||||||
|
name: 'Tortille kukurydziane',
|
||||||
|
category: 'pieczywo',
|
||||||
|
pantryUnit: 'szt',
|
||||||
|
nutritionPer100g: { kcal: 218, protein: 5.7, fat: 2.9, carbs: 44 },
|
||||||
|
},
|
||||||
|
salsa_pomidorowa: {
|
||||||
|
id: 'salsa_pomidorowa',
|
||||||
|
name: 'Salsa pomidorowa',
|
||||||
|
category: 'warzywa',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 36, protein: 1.5, fat: 0.2, carbs: 8 },
|
||||||
|
},
|
||||||
|
platki_owsiane: {
|
||||||
|
id: 'platki_owsiane',
|
||||||
|
name: 'Płatki owsiane',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 389, protein: 17, fat: 7, carbs: 66 },
|
||||||
|
},
|
||||||
|
serek_wiejski: {
|
||||||
|
id: 'serek_wiejski',
|
||||||
|
name: 'Serek wiejski',
|
||||||
|
category: 'nabial',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 97, protein: 11, fat: 5, carbs: 3 },
|
||||||
|
},
|
||||||
|
orzechy_wloskie: {
|
||||||
|
id: 'orzechy_wloskie',
|
||||||
|
name: 'Orzechy włoskie',
|
||||||
|
category: 'suche',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 654, protein: 15, fat: 65, carbs: 14 },
|
||||||
|
},
|
||||||
|
truskawki: {
|
||||||
|
id: 'truskawki',
|
||||||
|
name: 'Truskawki',
|
||||||
|
category: 'owoce',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 32, protein: 0.7, fat: 0.3, carbs: 8 },
|
||||||
|
},
|
||||||
|
borowki_amerykanskie: {
|
||||||
|
id: 'borowki_amerykanskie',
|
||||||
|
name: 'Borówki amerykańskie',
|
||||||
|
category: 'owoce',
|
||||||
|
pantryUnit: 'g',
|
||||||
|
nutritionPer100g: { kcal: 57, protein: 0.7, fat: 0.3, carbs: 14 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Porcja bazowa = 1; składniki przez ingredientId */
|
||||||
|
export const RECIPES = {
|
||||||
|
placki: {
|
||||||
|
id: 'placki',
|
||||||
|
title: 'Puszyste placki',
|
||||||
|
minutes: 15,
|
||||||
|
thumbLabel: 'Placki',
|
||||||
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'maka_pszenna', amount: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'mleko', amount: 250, unit: 'ml' },
|
||||||
|
{ ingredientId: 'jajko', amount: 2, unit: 'szt.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
salatka: {
|
||||||
|
id: 'salatka',
|
||||||
|
title: 'Sałatka z kurczakiem',
|
||||||
|
minutes: 20,
|
||||||
|
thumbLabel: 'Sałatka',
|
||||||
|
allowedSlots: ['obiad'],
|
||||||
|
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'piers_kurczaka', amount: 150, unit: 'g' },
|
||||||
|
{ ingredientId: 'mix_salat', amount: 100, unit: 'g' },
|
||||||
|
{ ingredientId: 'pomidor', amount: 1, unit: 'szt.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
makaron: {
|
||||||
|
id: 'makaron',
|
||||||
|
title: 'Makaron z pomidorami i bazylią',
|
||||||
|
minutes: 30,
|
||||||
|
thumbLabel: 'Makaron',
|
||||||
|
allowedSlots: ['obiad', 'kolacja'],
|
||||||
|
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'makaron_suchy', amount: 120, unit: 'g' },
|
||||||
|
{ ingredientId: 'pomidory_krojone', amount: 400, unit: 'g' },
|
||||||
|
{ ingredientId: 'bazylia_swieza', amount: 10, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
koktajl: {
|
||||||
|
id: 'koktajl',
|
||||||
|
title: 'Koktajl owocowy',
|
||||||
|
minutes: 5,
|
||||||
|
thumbLabel: 'Koktajl',
|
||||||
|
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
||||||
|
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'jogurt_naturalny', amount: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'mieszanka_jagod', amount: 150, unit: 'g' },
|
||||||
|
{ ingredientId: 'miod', amount: 15, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tost_awokado: {
|
||||||
|
id: 'tost_awokado',
|
||||||
|
title: 'Tost z awokado',
|
||||||
|
minutes: 10,
|
||||||
|
thumbLabel: 'Tost',
|
||||||
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'chleb_zakwas', amount: 2, unit: 'kromki' },
|
||||||
|
{ ingredientId: 'awokado', amount: 1, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
losos: {
|
||||||
|
id: 'losos',
|
||||||
|
title: 'Grillowany łosoś',
|
||||||
|
minutes: 25,
|
||||||
|
thumbLabel: 'Łosoś',
|
||||||
|
allowedSlots: ['kolacja', 'obiad'],
|
||||||
|
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'losos_filet', amount: 180, unit: 'g' },
|
||||||
|
{ ingredientId: 'cytryna', amount: 0.5, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'koper_swiezy', amount: 5, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tacos: {
|
||||||
|
id: 'tacos',
|
||||||
|
title: 'Tacos z wołowiną',
|
||||||
|
minutes: 20,
|
||||||
|
thumbLabel: 'Tacos',
|
||||||
|
allowedSlots: ['kolacja', 'obiad'],
|
||||||
|
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'mieso_wol_mielone', amount: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'tortilla_kukurydziana', amount: 4, unit: 'szt.' },
|
||||||
|
{ ingredientId: 'salsa_pomidorowa', amount: 100, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
owsianka: {
|
||||||
|
id: 'owsianka',
|
||||||
|
title: 'Miska owsianki',
|
||||||
|
minutes: 10,
|
||||||
|
thumbLabel: 'Owsianka',
|
||||||
|
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
||||||
|
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'platki_owsiane', amount: 60, unit: 'g' },
|
||||||
|
{ ingredientId: 'mleko', amount: 200, unit: 'ml' },
|
||||||
|
{ ingredientId: 'miod', amount: 20, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
serek_owoc: {
|
||||||
|
id: 'serek_owoc',
|
||||||
|
title: 'Serek wiejski z orzechami i owocami',
|
||||||
|
minutes: 5,
|
||||||
|
thumbLabel: 'Serek',
|
||||||
|
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
||||||
|
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: 'serek_wiejski', amount: 200, unit: 'g' },
|
||||||
|
{ ingredientId: 'miod', amount: 10, unit: 'g' },
|
||||||
|
{ ingredientId: 'orzechy_wloskie', amount: 50, unit: 'g' },
|
||||||
|
{ ingredientId: 'truskawki', amount: 100, unit: 'g' },
|
||||||
|
{ ingredientId: 'borowki_amerykanskie', amount: 80, unit: 'g' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
7
stacks/recipe/js/planner/mealSlots.js
Normal file
7
stacks/recipe/js/planner/mealSlots.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const MEAL_SLOTS = [
|
||||||
|
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
|
||||||
|
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
|
||||||
|
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
|
||||||
|
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
|
||||||
|
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
|
||||||
|
];
|
||||||
51
stacks/recipe/js/services/dateUtils.js
Normal file
51
stacks/recipe/js/services/dateUtils.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export function startOfDay(d) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear()
|
||||||
|
&& a.getMonth() === b.getMonth()
|
||||||
|
&& a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return startOfDay(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
|
||||||
|
export function startOfWeekMonday(d) {
|
||||||
|
const date = startOfDay(d);
|
||||||
|
const day = date.getDay();
|
||||||
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
|
return addDays(date, diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfMonth(d) {
|
||||||
|
const x = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
return startOfDay(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMonths(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setMonth(x.getMonth() + n);
|
||||||
|
return startOfDay(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWeeks(d, n) {
|
||||||
|
return addDays(d, n * 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekContains(weekStart, d) {
|
||||||
|
const t = startOfDay(d).getTime();
|
||||||
|
const ws = weekStart.getTime();
|
||||||
|
const we = addDays(weekStart, 6).getTime();
|
||||||
|
return t >= ws && t <= we;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameMonth(a, b) {
|
||||||
|
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||||
|
}
|
||||||
413
stacks/recipe/js/services/pantryShopping.js
Normal file
413
stacks/recipe/js/services/pantryShopping.js
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
|
||||||
|
import { PANTRY_STORAGE_KEY, SHOPPING_STORAGE_KEY } from '../storageKeys.js';
|
||||||
|
|
||||||
|
export const KITCHEN_LIST_ID = 'kitchen';
|
||||||
|
export const MISC_LIST_ID = 'misc';
|
||||||
|
|
||||||
|
function newId(prefix) {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @typedef {{ id: string, ingredientId: string, name: string, amount: number, unit: string, category: string, checked: boolean, sourceNote?: string }} KitchenShoppingItem */
|
||||||
|
/** @typedef {{ id: string, text: string, note?: string, checked: boolean }} FreeformShoppingItem */
|
||||||
|
/** @typedef {{ id: string, name: string, type: 'kitchen'|'freeform', items: (KitchenShoppingItem|FreeformShoppingItem)[] }} ShoppingListDef */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* lists: ShoppingListDef[],
|
||||||
|
* activeListId: string
|
||||||
|
* }} ShoppingState
|
||||||
|
*/
|
||||||
|
|
||||||
|
function defaultShoppingState() {
|
||||||
|
return {
|
||||||
|
lists: [
|
||||||
|
{ id: KITCHEN_LIST_ID, name: 'Kuchnia', type: 'kitchen', items: [] },
|
||||||
|
{ id: MISC_LIST_ID, name: 'Inne zakupy', type: 'freeform', items: [] },
|
||||||
|
],
|
||||||
|
activeListId: KITCHEN_LIST_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} x */
|
||||||
|
function normalizeKitchenItem(x) {
|
||||||
|
if (!x || typeof x !== 'object') return null;
|
||||||
|
const o = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (L);
|
||||||
|
const id = String(row.id || '');
|
||||||
|
const name = String(row.name || 'Lista');
|
||||||
|
let type = row.type === 'freeform' ? 'freeform' : 'kitchen';
|
||||||
|
if (id === KITCHEN_LIST_ID) type = 'kitchen';
|
||||||
|
if (id === MISC_LIST_ID) type = 'freeform';
|
||||||
|
if (!id) continue;
|
||||||
|
if (type === 'kitchen') {
|
||||||
|
const items = Array.isArray(row.items)
|
||||||
|
? row.items.map(normalizeKitchenItem).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
lists.push({ id, name, type: 'kitchen', items: /** @type {KitchenShoppingItem[]} */ (items) });
|
||||||
|
} else {
|
||||||
|
const items = Array.isArray(row.items)
|
||||||
|
? row.items.map(normalizeFreeformItem).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
lists.push({ id, name, type: 'freeform', items: /** @type {FreeformShoppingItem[]} */ (items) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lists.length === 0) return base;
|
||||||
|
|
||||||
|
const hasKitchen = lists.some((l) => l.id === KITCHEN_LIST_ID);
|
||||||
|
if (!hasKitchen) {
|
||||||
|
lists.unshift(/** @type {ShoppingListDef} */ (base.lists[0]));
|
||||||
|
}
|
||||||
|
const hasMisc = lists.some((l) => l.id === MISC_LIST_ID);
|
||||||
|
if (!hasMisc) {
|
||||||
|
lists.push(/** @type {ShoppingListDef} */ (base.lists[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeListId = String(p.activeListId || KITCHEN_LIST_ID);
|
||||||
|
if (!lists.some((l) => l.id === activeListId)) activeListId = lists[0].id;
|
||||||
|
|
||||||
|
return { lists, activeListId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadShoppingState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SHOPPING_STORAGE_KEY);
|
||||||
|
if (!raw) return defaultShoppingState();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return normalizeShoppingState(parsed);
|
||||||
|
} catch {
|
||||||
|
return defaultShoppingState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShoppingState(state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SHOPPING_STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListSummaries() {
|
||||||
|
const { lists, activeListId } = loadShoppingState();
|
||||||
|
return {
|
||||||
|
lists: lists.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
type: l.type,
|
||||||
|
openCount: l.items.filter((i) => !i.checked).length,
|
||||||
|
})),
|
||||||
|
activeListId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveListId(listId) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
if (!s.lists.some((l) => l.id === listId)) return s;
|
||||||
|
s.activeListId = listId;
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListById(listId) {
|
||||||
|
return loadShoppingState().lists.find((l) => l.id === listId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveList() {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
return s.lists.find((l) => l.id === s.activeListId) ?? s.lists[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {string} new list id
|
||||||
|
*/
|
||||||
|
export function addFreeformList(name) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const id = newId('list');
|
||||||
|
s.lists.push({
|
||||||
|
id,
|
||||||
|
name: name.trim() || 'Nowa lista',
|
||||||
|
type: 'freeform',
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
s.activeListId = id;
|
||||||
|
saveShoppingState(s);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} listId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function deleteList(listId) {
|
||||||
|
if (listId === KITCHEN_LIST_ID) return false;
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const idx = s.lists.findIndex((l) => l.id === listId);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
s.lists.splice(idx, 1);
|
||||||
|
if (s.activeListId === listId) {
|
||||||
|
s.activeListId = s.lists[0]?.id || KITCHEN_LIST_ID;
|
||||||
|
}
|
||||||
|
saveShoppingState(s);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dodaje linie tylko do listy kuchennej (składniki z katalogu).
|
||||||
|
* @param {{ ingredientId: string, amount: number, unit: string, name?: string, category?: string, sourceNote?: string }[]} lines
|
||||||
|
* @param {string} [listId]
|
||||||
|
*/
|
||||||
|
export function addOrMergeShoppingLines(lines, listId = KITCHEN_LIST_ID) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId && l.type === 'kitchen');
|
||||||
|
if (!list || list.type !== 'kitchen') return s;
|
||||||
|
|
||||||
|
const items = /** @type {KitchenShoppingItem[]} */ (list.items);
|
||||||
|
const open = items.filter((x) => !x.checked);
|
||||||
|
const closed = items.filter((x) => x.checked);
|
||||||
|
|
||||||
|
for (const L of lines) {
|
||||||
|
const def = INGREDIENTS[L.ingredientId];
|
||||||
|
const name = L.name || def?.name || L.ingredientId;
|
||||||
|
const category = L.category || def?.category || 'inne';
|
||||||
|
const idx = open.findIndex(
|
||||||
|
(x) => x.ingredientId === L.ingredientId && x.unit === L.unit,
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
open[idx].amount = Math.round((open[idx].amount + L.amount) * 100) / 100;
|
||||||
|
if (L.sourceNote && !open[idx].sourceNote) open[idx].sourceNote = L.sourceNote;
|
||||||
|
} else {
|
||||||
|
open.push({
|
||||||
|
id: newId('s'),
|
||||||
|
ingredientId: L.ingredientId,
|
||||||
|
name,
|
||||||
|
amount: Math.round(L.amount * 100) / 100,
|
||||||
|
unit: L.unit,
|
||||||
|
category,
|
||||||
|
checked: false,
|
||||||
|
sourceNote: L.sourceNote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.items = [...open, ...closed];
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedna sztuka / domyślna jednostka magazynowa — na listę kuchenną ze spiżarni.
|
||||||
|
*/
|
||||||
|
export function addIngredientToKitchenList(ingredientId, amount = 1) {
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
const unit = displayUnit(def.pantryUnit);
|
||||||
|
addOrMergeShoppingLines([{
|
||||||
|
ingredientId,
|
||||||
|
amount,
|
||||||
|
unit,
|
||||||
|
name: def.name,
|
||||||
|
category: def.category,
|
||||||
|
sourceNote: 'Ze spiżarni',
|
||||||
|
}], KITCHEN_LIST_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} listId
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} [note]
|
||||||
|
*/
|
||||||
|
export function addFreeformLine(listId, text, note) {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId && l.type === 'freeform');
|
||||||
|
if (!list) return;
|
||||||
|
const items = /** @type {FreeformShoppingItem[]} */ (list.items);
|
||||||
|
items.push({
|
||||||
|
id: newId('f'),
|
||||||
|
text: t,
|
||||||
|
note: note?.trim() || undefined,
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
saveShoppingState(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleItemInList(listId, itemId) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId);
|
||||||
|
if (!list) return s;
|
||||||
|
const it = list.items.find((i) => i.id === itemId);
|
||||||
|
if (it) it.checked = !it.checked;
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeItemFromList(listId, itemId) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId);
|
||||||
|
if (!list) return s;
|
||||||
|
list.items = list.items.filter((i) => i.id !== itemId);
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCheckedInList(listId) {
|
||||||
|
const s = loadShoppingState();
|
||||||
|
const list = s.lists.find((l) => l.id === listId);
|
||||||
|
if (!list) return s;
|
||||||
|
list.items = list.items.filter((i) => !i.checked);
|
||||||
|
saveShoppingState(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeShortfalls(needLines, pantry = loadPantry()) {
|
||||||
|
const short = [];
|
||||||
|
for (const L of needLines) {
|
||||||
|
const def = INGREDIENTS[L.ingredientId];
|
||||||
|
if (!def) continue;
|
||||||
|
const u = normalizeUnitForPantry(L.unit, def.pantryUnit);
|
||||||
|
if (u === null) {
|
||||||
|
short.push({ ...L, pantryQty: 0, shortfall: L.amount, unitMismatch: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const have = Number(pantry[L.ingredientId]) || 0;
|
||||||
|
const miss = Math.max(0, Math.round((L.amount - have) * 100) / 100);
|
||||||
|
if (miss > 0) {
|
||||||
|
short.push({
|
||||||
|
...L,
|
||||||
|
unit: displayUnit(def.pantryUnit),
|
||||||
|
pantryQty: have,
|
||||||
|
shortfall: miss,
|
||||||
|
unitMismatch: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUnit(pantryUnit) {
|
||||||
|
if (pantryUnit === 'szt') return 'szt.';
|
||||||
|
return pantryUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUnitForPantry(recipeUnit, pantryUnit) {
|
||||||
|
const r = String(recipeUnit).trim().toLowerCase().replace(/\.$/, '');
|
||||||
|
if (pantryUnit === 'g' && (r === 'g' || r === 'gram')) return 'g';
|
||||||
|
if (pantryUnit === 'ml' && (r === 'ml' || r === 'mililitr')) return 'ml';
|
||||||
|
if (pantryUnit === 'szt' && (r === 'szt' || r === 'sztuka' || r === 'kromki' || r === 'kromka')) return 'szt';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryLabel(cat) {
|
||||||
|
return CATEGORY_LABELS[cat] || CATEGORY_LABELS.inne;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Record<string, number>} 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 };
|
||||||
|
}
|
||||||
141
stacks/recipe/js/services/planIngredients.js
Normal file
141
stacks/recipe/js/services/planIngredients.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { INGREDIENTS, RECIPES } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
import { addDays } from './dateUtils.js';
|
||||||
|
import { getDayPlan } from './planStore.js';
|
||||||
|
|
||||||
|
export function dayHasAnyMeal(plans, d) {
|
||||||
|
const p = getDayPlan(plans, d);
|
||||||
|
return MEAL_SLOTS.some((s) => {
|
||||||
|
const arr = p[s.id];
|
||||||
|
return Array.isArray(arr) && arr.length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumDayNutrition(dayPlan) {
|
||||||
|
let kcal = 0;
|
||||||
|
let protein = 0;
|
||||||
|
let fat = 0;
|
||||||
|
let carbs = 0;
|
||||||
|
let mealCount = 0;
|
||||||
|
MEAL_SLOTS.forEach((slot) => {
|
||||||
|
const entries = dayPlan[slot.id];
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry || !entry.recipeId) return;
|
||||||
|
const r = RECIPES[entry.recipeId];
|
||||||
|
if (!r) return;
|
||||||
|
const s = Math.max(1, Number(entry.servings) || 1);
|
||||||
|
mealCount += 1;
|
||||||
|
kcal += r.nutritionPerServing.kcal * s;
|
||||||
|
protein += r.nutritionPerServing.protein * s;
|
||||||
|
fat += r.nutritionPerServing.fat * s;
|
||||||
|
carbs += r.nutritionPerServing.carbs * s;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
kcal: Math.round(kcal),
|
||||||
|
protein: Math.round(protein * 10) / 10,
|
||||||
|
fat: Math.round(fat * 10) / 10,
|
||||||
|
carbs: Math.round(carbs * 10) / 10,
|
||||||
|
mealCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLine(ing, scaledAmount) {
|
||||||
|
const def = INGREDIENTS[ing.ingredientId];
|
||||||
|
return {
|
||||||
|
ingredientId: ing.ingredientId,
|
||||||
|
name: def?.name ?? ing.ingredientId,
|
||||||
|
category: def?.category ?? 'inne',
|
||||||
|
amount: scaledAmount,
|
||||||
|
unit: ing.unit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Płaska lista składników z jednego dnia (wszystkie pory). */
|
||||||
|
export function flattenDayIngredientLines(dayPlan) {
|
||||||
|
/** @type {ReturnType<typeof resolveLine>[]} */
|
||||||
|
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<string, unknown>} 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;
|
||||||
|
}
|
||||||
85
stacks/recipe/js/services/planStore.js
Normal file
85
stacks/recipe/js/services/planStore.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { RECIPES } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
import { PLANS_STORAGE_KEY } from '../storageKeys.js';
|
||||||
|
import { startOfDay } from './dateUtils.js';
|
||||||
|
|
||||||
|
export function dateKey(d) {
|
||||||
|
const x = startOfDay(d);
|
||||||
|
const y = x.getFullYear();
|
||||||
|
const m = String(x.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(x.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newPlanEntryId() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } */
|
||||||
|
export function normalizeSlotValue(v) {
|
||||||
|
if (!v) return [];
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return v
|
||||||
|
.filter((x) => x && x.recipeId && RECIPES[x.recipeId])
|
||||||
|
.map((x) => ({
|
||||||
|
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
|
||||||
|
recipeId: x.recipeId,
|
||||||
|
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof v === 'object' && v.recipeId && RECIPES[v.recipeId]) {
|
||||||
|
return [{
|
||||||
|
id: newPlanEntryId(),
|
||||||
|
recipeId: v.recipeId,
|
||||||
|
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDayPlan(day) {
|
||||||
|
if (!day || typeof day !== 'object') return {};
|
||||||
|
const out = {};
|
||||||
|
MEAL_SLOTS.forEach((s) => {
|
||||||
|
const arr = normalizeSlotValue(day[s.id]);
|
||||||
|
if (arr.length > 0) out[s.id] = arr;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAllPlans(plans) {
|
||||||
|
if (!plans || typeof plans !== 'object') return {};
|
||||||
|
const out = {};
|
||||||
|
Object.keys(plans).forEach((key) => {
|
||||||
|
const d = normalizeDayPlan(plans[key]);
|
||||||
|
if (Object.keys(d).length > 0) out[key] = d;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPlans() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||||
|
return normalizeAllPlans(parsed);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePlans(plans) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayPlan(plans, d) {
|
||||||
|
const key = dateKey(d);
|
||||||
|
const day = plans[key];
|
||||||
|
return day && typeof day === 'object' ? day : {};
|
||||||
|
}
|
||||||
3
stacks/recipe/js/storageKeys.js
Normal file
3
stacks/recipe/js/storageKeys.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
|
||||||
|
export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1';
|
||||||
|
export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1';
|
||||||
13
stacks/recipe/js/ui/toast.js
Normal file
13
stacks/recipe/js/ui/toast.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function showAppToast(message) {
|
||||||
|
const wrap = document.getElementById('app-toast');
|
||||||
|
const text = document.getElementById('app-toast-text');
|
||||||
|
if (!wrap || !text) return;
|
||||||
|
text.textContent = message;
|
||||||
|
wrap.classList.remove('opacity-0', 'translate-y-2');
|
||||||
|
wrap.classList.add('opacity-100', 'translate-y-0');
|
||||||
|
clearTimeout(showAppToast._t);
|
||||||
|
showAppToast._t = setTimeout(() => {
|
||||||
|
wrap.classList.add('opacity-0', 'translate-y-2');
|
||||||
|
wrap.classList.remove('opacity-100', 'translate-y-0');
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
@@ -1,3 +1,30 @@
|
|||||||
|
import { RECIPES } from '../data/catalog.js';
|
||||||
|
import { MEAL_SLOTS } from '../planner/mealSlots.js';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
addWeeks,
|
||||||
|
sameDay,
|
||||||
|
sameMonth,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeekMonday,
|
||||||
|
weekContains,
|
||||||
|
} from '../services/dateUtils.js';
|
||||||
|
import {
|
||||||
|
aggregateDayIngredientsBySlot,
|
||||||
|
dayHasAnyMeal,
|
||||||
|
sumDayNutrition,
|
||||||
|
} from '../services/planIngredients.js';
|
||||||
|
import { addOrMergeShoppingLines } from '../services/pantryShopping.js';
|
||||||
|
import {
|
||||||
|
dateKey,
|
||||||
|
getDayPlan,
|
||||||
|
loadPlans,
|
||||||
|
newPlanEntryId,
|
||||||
|
savePlans,
|
||||||
|
} from '../services/planStore.js';
|
||||||
|
|
||||||
const MONTHS_SHORT = [
|
const MONTHS_SHORT = [
|
||||||
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
|
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
|
||||||
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
|
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
|
||||||
@@ -7,346 +34,12 @@ const WEEKDAYS_LONG = [
|
|||||||
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MEAL_SLOTS = [
|
|
||||||
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
|
|
||||||
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
|
|
||||||
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
|
|
||||||
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
|
|
||||||
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Katalog przepisów (spójny z listą w aplikacji) — porcja bazowa = 1 */
|
|
||||||
const PLANNER_RECIPES = {
|
|
||||||
placki: {
|
|
||||||
id: 'placki',
|
|
||||||
title: 'Puszyste placki',
|
|
||||||
minutes: 15,
|
|
||||||
thumbLabel: 'Placki',
|
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
|
||||||
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Mąka pszenna', amount: 200, unit: 'g' },
|
|
||||||
{ name: 'Mleko', amount: 250, unit: 'ml' },
|
|
||||||
{ name: 'Jajka', amount: 2, unit: 'szt.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
salatka: {
|
|
||||||
id: 'salatka',
|
|
||||||
title: 'Sałatka z kurczakiem',
|
|
||||||
minutes: 20,
|
|
||||||
thumbLabel: 'Sałatka',
|
|
||||||
allowedSlots: ['obiad'],
|
|
||||||
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Pierś z kurczaka', amount: 150, unit: 'g' },
|
|
||||||
{ name: 'Mix sałat', amount: 100, unit: 'g' },
|
|
||||||
{ name: 'Pomidor', amount: 1, unit: 'szt.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
makaron: {
|
|
||||||
id: 'makaron',
|
|
||||||
title: 'Makaron z pomidorami i bazylią',
|
|
||||||
minutes: 30,
|
|
||||||
thumbLabel: 'Makaron',
|
|
||||||
allowedSlots: ['obiad', 'kolacja'],
|
|
||||||
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Makaron', amount: 120, unit: 'g' },
|
|
||||||
{ name: 'Pomidory krojone', amount: 400, unit: 'g' },
|
|
||||||
{ name: 'Bazylia świeża', amount: 10, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
koktajl: {
|
|
||||||
id: 'koktajl',
|
|
||||||
title: 'Koktajl owocowy',
|
|
||||||
minutes: 5,
|
|
||||||
thumbLabel: 'Koktajl',
|
|
||||||
allowedSlots: ['przekaska', 'drugie_sniadanie'],
|
|
||||||
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Jogurt naturalny', amount: 200, unit: 'g' },
|
|
||||||
{ name: 'Mieszanka jagód', amount: 150, unit: 'g' },
|
|
||||||
{ name: 'Miód', amount: 15, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tost_awokado: {
|
|
||||||
id: 'tost_awokado',
|
|
||||||
title: 'Tost z awokado',
|
|
||||||
minutes: 10,
|
|
||||||
thumbLabel: 'Tost',
|
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
|
||||||
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Chleb na zakwasie', amount: 2, unit: 'kromki' },
|
|
||||||
{ name: 'Awokado', amount: 1, unit: 'szt.' },
|
|
||||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
losos: {
|
|
||||||
id: 'losos',
|
|
||||||
title: 'Grillowany łosoś',
|
|
||||||
minutes: 25,
|
|
||||||
thumbLabel: 'Łosoś',
|
|
||||||
allowedSlots: ['kolacja', 'obiad'],
|
|
||||||
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Filet z łososia', amount: 180, unit: 'g' },
|
|
||||||
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
|
|
||||||
{ name: 'Koper', amount: 5, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tacos: {
|
|
||||||
id: 'tacos',
|
|
||||||
title: 'Tacos z wołowiną',
|
|
||||||
minutes: 20,
|
|
||||||
thumbLabel: 'Tacos',
|
|
||||||
allowedSlots: ['kolacja', 'obiad'],
|
|
||||||
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Mięso mielone wołowe', amount: 200, unit: 'g' },
|
|
||||||
{ name: 'Tortille kukurydziane', amount: 4, unit: 'szt.' },
|
|
||||||
{ name: 'Salsa pomidorowa', amount: 100, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
owsianka: {
|
|
||||||
id: 'owsianka',
|
|
||||||
title: 'Miska owsianki',
|
|
||||||
minutes: 10,
|
|
||||||
thumbLabel: 'Owsianka',
|
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
|
|
||||||
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Płatki owsiane', amount: 60, unit: 'g' },
|
|
||||||
{ name: 'Mleko', amount: 200, unit: 'ml' },
|
|
||||||
{ name: 'Miód', amount: 20, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
serek_owoc: {
|
|
||||||
id: 'serek_owoc',
|
|
||||||
title: 'Serek wiejski z orzechami i owocami',
|
|
||||||
minutes: 5,
|
|
||||||
thumbLabel: 'Serek',
|
|
||||||
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
|
|
||||||
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
|
|
||||||
ingredients: [
|
|
||||||
{ name: 'Serek wiejski', amount: 200, unit: 'g' },
|
|
||||||
{ name: 'Miód', amount: 10, unit: 'g' },
|
|
||||||
{ name: 'Orzechy włoskie', amount: 50, unit: 'g' },
|
|
||||||
{ name: 'Truskawki', amount: 100, unit: 'g' },
|
|
||||||
{ name: 'Borówki ameryk.', amount: 80, unit: 'g' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
|
|
||||||
|
|
||||||
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
|
/** 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_BOTTOM_INSET = '5.25rem';
|
||||||
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
|
||||||
|
|
||||||
function startOfDay(d) {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(0, 0, 0, 0);
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameDay(a, b) {
|
|
||||||
return a.getFullYear() === b.getFullYear()
|
|
||||||
&& a.getMonth() === b.getMonth()
|
|
||||||
&& a.getDate() === b.getDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(d, n) {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setDate(x.getDate() + n);
|
|
||||||
return startOfDay(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
|
|
||||||
function startOfWeekMonday(d) {
|
|
||||||
const date = startOfDay(d);
|
|
||||||
const day = date.getDay();
|
|
||||||
const diff = day === 0 ? -6 : 1 - day;
|
|
||||||
return addDays(date, diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfMonth(d) {
|
|
||||||
const x = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
||||||
return startOfDay(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMonths(d, n) {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setMonth(x.getMonth() + n);
|
|
||||||
return startOfDay(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWeeks(d, n) {
|
|
||||||
return addDays(d, n * 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekContains(weekStart, d) {
|
|
||||||
const t = startOfDay(d).getTime();
|
|
||||||
const ws = weekStart.getTime();
|
|
||||||
const we = addDays(weekStart, 6).getTime();
|
|
||||||
return t >= ws && t <= we;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameMonth(a, b) {
|
|
||||||
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateKey(d) {
|
|
||||||
const x = startOfDay(d);
|
|
||||||
const y = x.getFullYear();
|
|
||||||
const m = String(x.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(x.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newPlanEntryId() {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } (stary format: jeden obiekt — migrujemy przy wczytaniu). */
|
|
||||||
function normalizeSlotValue(v) {
|
|
||||||
if (!v) return [];
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
return v
|
|
||||||
.filter((x) => x && x.recipeId && PLANNER_RECIPES[x.recipeId])
|
|
||||||
.map((x) => ({
|
|
||||||
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
|
|
||||||
recipeId: x.recipeId,
|
|
||||||
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (typeof v === 'object' && v.recipeId && PLANNER_RECIPES[v.recipeId]) {
|
|
||||||
return [{
|
|
||||||
id: newPlanEntryId(),
|
|
||||||
recipeId: v.recipeId,
|
|
||||||
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDayPlan(day) {
|
|
||||||
if (!day || typeof day !== 'object') return {};
|
|
||||||
const out = {};
|
|
||||||
MEAL_SLOTS.forEach((s) => {
|
|
||||||
const arr = normalizeSlotValue(day[s.id]);
|
|
||||||
if (arr.length > 0) out[s.id] = arr;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAllPlans(plans) {
|
|
||||||
if (!plans || typeof plans !== 'object') return {};
|
|
||||||
const out = {};
|
|
||||||
Object.keys(plans).forEach((key) => {
|
|
||||||
const d = normalizeDayPlan(plans[key]);
|
|
||||||
if (Object.keys(d).length > 0) out[key] = d;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPlans() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
|
|
||||||
if (!raw) return {};
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
|
||||||
return normalizeAllPlans(parsed);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePlans(plans) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDayPlan(plans, d) {
|
|
||||||
const key = dateKey(d);
|
|
||||||
const day = plans[key];
|
|
||||||
return day && typeof day === 'object' ? day : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayHasAnyMeal(plans, d) {
|
|
||||||
const p = getDayPlan(plans, d);
|
|
||||||
return MEAL_SLOTS.some((s) => {
|
|
||||||
const arr = p[s.id];
|
|
||||||
return Array.isArray(arr) && arr.length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumDayNutrition(dayPlan) {
|
|
||||||
let kcal = 0;
|
|
||||||
let protein = 0;
|
|
||||||
let fat = 0;
|
|
||||||
let carbs = 0;
|
|
||||||
let mealCount = 0;
|
|
||||||
MEAL_SLOTS.forEach((slot) => {
|
|
||||||
const entries = dayPlan[slot.id];
|
|
||||||
if (!Array.isArray(entries)) return;
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry || !entry.recipeId) return;
|
|
||||||
const r = PLANNER_RECIPES[entry.recipeId];
|
|
||||||
if (!r) return;
|
|
||||||
const s = Math.max(1, Number(entry.servings) || 1);
|
|
||||||
mealCount += 1;
|
|
||||||
kcal += r.nutritionPerServing.kcal * s;
|
|
||||||
protein += r.nutritionPerServing.protein * s;
|
|
||||||
fat += r.nutritionPerServing.fat * s;
|
|
||||||
carbs += r.nutritionPerServing.carbs * s;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
kcal: Math.round(kcal),
|
|
||||||
protein: Math.round(protein * 10) / 10,
|
|
||||||
fat: Math.round(fat * 10) / 10,
|
|
||||||
carbs: Math.round(carbs * 10) / 10,
|
|
||||||
mealCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
|
|
||||||
* @returns {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]}
|
|
||||||
*/
|
|
||||||
function aggregateDayIngredientsBySlot(dayPlan) {
|
|
||||||
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]} */
|
|
||||||
const blocks = [];
|
|
||||||
MEAL_SLOTS.forEach((slot) => {
|
|
||||||
const entries = dayPlan[slot.id];
|
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
||||||
const recipes = [];
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry || !entry.recipeId) return;
|
|
||||||
const r = PLANNER_RECIPES[entry.recipeId];
|
|
||||||
if (!r) return;
|
|
||||||
const s = Math.max(1, Number(entry.servings) || 1);
|
|
||||||
const items = r.ingredients.map((ing) => ({
|
|
||||||
name: ing.name,
|
|
||||||
amount: Math.round(ing.amount * s * 10) / 10,
|
|
||||||
unit: ing.unit,
|
|
||||||
}));
|
|
||||||
recipes.push({ recipeTitle: r.title, items });
|
|
||||||
});
|
|
||||||
if (recipes.length > 0) {
|
|
||||||
blocks.push({ mealLabel: slot.label, recipes });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recipesForSlot(slotId) {
|
function recipesForSlot(slotId) {
|
||||||
return Object.values(PLANNER_RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
|
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
|
||||||
@@ -727,7 +420,7 @@ function renderDayContent(state) {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const entryCards = entries.map((entry) => {
|
const entryCards = entries.map((entry) => {
|
||||||
const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null;
|
const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null;
|
||||||
if (!recipe) return '';
|
if (!recipe) return '';
|
||||||
const servings = Math.max(1, Number(entry.servings) || 1);
|
const servings = Math.max(1, Number(entry.servings) || 1);
|
||||||
const n = recipe.nutritionPerServing;
|
const n = recipe.nutritionPerServing;
|
||||||
@@ -851,7 +544,11 @@ function renderIngredientsSheet(state) {
|
|||||||
<p class="text-sm font-semibold text-gray-800 mb-1.5">${escapeHtml(rec.recipeTitle)}</p>
|
<p class="text-sm font-semibold text-gray-800 mb-1.5">${escapeHtml(rec.recipeTitle)}</p>
|
||||||
<ul class="space-y-0 border border-gray-100 rounded-xl overflow-hidden bg-white">
|
<ul class="space-y-0 border border-gray-100 rounded-xl overflow-hidden bg-white">
|
||||||
${rec.items.map((ing) => `
|
${rec.items.map((ing) => `
|
||||||
<li class="flex items-center gap-3 py-3 px-3 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors planner-ing-row">
|
<li class="flex items-center gap-3 py-3 px-3 border-b border-gray-100 last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors planner-ing-row"
|
||||||
|
data-ingredient-id="${escapeHtml(ing.ingredientId)}"
|
||||||
|
data-amount="${escapeHtml(String(ing.amount))}"
|
||||||
|
data-unit="${escapeHtml(ing.unit)}"
|
||||||
|
data-category="${escapeHtml(ing.category)}">
|
||||||
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors shrink-0"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
<div class="w-5 h-5 rounded border border-gray-300 flex items-center justify-center text-white check-box transition-colors shrink-0"><i class="fas fa-check text-[10px] hidden check-icon"></i></div>
|
||||||
<span class="text-gray-700 text-sm flex-1 ingredient-text transition-colors">${escapeHtml(ing.name)}</span>
|
<span class="text-gray-700 text-sm flex-1 ingredient-text transition-colors">${escapeHtml(ing.name)}</span>
|
||||||
<span class="font-medium text-gray-900 text-sm tabular-nums">${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}</span>
|
<span class="font-medium text-gray-900 text-sm tabular-nums">${formatAmount(ing.amount)} ${escapeHtml(ing.unit)}</span>
|
||||||
@@ -1042,7 +739,7 @@ export function setupMealPlanner() {
|
|||||||
const pick = e.target.closest('.planner-pick-recipe');
|
const pick = e.target.closest('.planner-pick-recipe');
|
||||||
if (!pick || !state.pickerSlot) return;
|
if (!pick || !state.pickerSlot) return;
|
||||||
const recipeId = pick.getAttribute('data-recipe-id');
|
const recipeId = pick.getAttribute('data-recipe-id');
|
||||||
if (!recipeId || !PLANNER_RECIPES[recipeId]) return;
|
if (!recipeId || !RECIPES[recipeId]) return;
|
||||||
const key = dateKey(state.selected);
|
const key = dateKey(state.selected);
|
||||||
if (!state.plans[key]) state.plans[key] = {};
|
if (!state.plans[key]) state.plans[key] = {};
|
||||||
const slotId = state.pickerSlot;
|
const slotId = state.pickerSlot;
|
||||||
@@ -1073,7 +770,24 @@ export function setupMealPlanner() {
|
|||||||
const rows = body?.querySelectorAll('.planner-ing-row');
|
const rows = body?.querySelectorAll('.planner-ing-row');
|
||||||
const n = rows?.length ?? 0;
|
const n = rows?.length ?? 0;
|
||||||
if (n === 0) return;
|
if (n === 0) return;
|
||||||
showPlannerToast(`Dodano ${n} składników do listy (zakładka Zakupy w przygotowaniu).`);
|
const lines = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const id = row.getAttribute('data-ingredient-id');
|
||||||
|
const amount = parseFloat(row.getAttribute('data-amount') || '');
|
||||||
|
const unit = row.getAttribute('data-unit') || '';
|
||||||
|
const category = row.getAttribute('data-category') || '';
|
||||||
|
if (!id || !Number.isFinite(amount)) return;
|
||||||
|
lines.push({
|
||||||
|
ingredientId: id,
|
||||||
|
amount,
|
||||||
|
unit,
|
||||||
|
category,
|
||||||
|
sourceNote: 'Z planu dnia',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
addOrMergeShoppingLines(lines);
|
||||||
|
showPlannerToast(`Dodano ${lines.length} składników na listę.`);
|
||||||
|
window.refreshShopping?.();
|
||||||
closeSheet(ingBackdrop, ingSheet);
|
closeSheet(ingBackdrop, ingSheet);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1085,7 +799,24 @@ export function setupMealPlanner() {
|
|||||||
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
|
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showPlannerToast(`Dodano ${n} zaznaczonych pozycji do listy (zakładka Zakupy w przygotowaniu).`);
|
const lines = [];
|
||||||
|
selected.forEach((row) => {
|
||||||
|
const id = row.getAttribute('data-ingredient-id');
|
||||||
|
const amount = parseFloat(row.getAttribute('data-amount') || '');
|
||||||
|
const unit = row.getAttribute('data-unit') || '';
|
||||||
|
const category = row.getAttribute('data-category') || '';
|
||||||
|
if (!id || !Number.isFinite(amount)) return;
|
||||||
|
lines.push({
|
||||||
|
ingredientId: id,
|
||||||
|
amount,
|
||||||
|
unit,
|
||||||
|
category,
|
||||||
|
sourceNote: 'Z planu dnia',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
addOrMergeShoppingLines(lines);
|
||||||
|
showPlannerToast(`Dodano ${lines.length} pozycji na listę.`);
|
||||||
|
window.refreshShopping?.();
|
||||||
closeSheet(ingBackdrop, ingSheet);
|
closeSheet(ingBackdrop, ingSheet);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
412
stacks/recipe/js/views/Pantry.js
Normal file
412
stacks/recipe/js/views/Pantry.js
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js';
|
||||||
|
import { addIngredientToKitchenList, categoryLabel, loadPantry, setPantryQty } from '../services/pantryShopping.js';
|
||||||
|
import { showAppToast } from '../ui/toast.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pantryUnitLabel(u) {
|
||||||
|
if (u === 'szt') return 'szt.';
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(q) {
|
||||||
|
return String(q).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANTRY_SHOP_BOTTOM = '5.25rem';
|
||||||
|
const PANTRY_SHOP_OFF = `translateY(calc(100% + ${PANTRY_SHOP_BOTTOM}))`;
|
||||||
|
|
||||||
|
/** @type {string | null} */
|
||||||
|
let shopPickerIngredientId = null;
|
||||||
|
/** @type {number} */
|
||||||
|
let shopPickerStep = 1;
|
||||||
|
|
||||||
|
export function getPantryHTML() {
|
||||||
|
return `
|
||||||
|
<div id="pantry-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||||
|
<div class="shrink-0 bg-white border-b border-gray-200 mt-3 px-4 pt-2 pb-3 space-y-3">
|
||||||
|
<div class="flex items-center w-full border border-gray-300 rounded-xl bg-white focus-within:border-gray-400 transition-colors">
|
||||||
|
<span class="pl-3 text-gray-400"><i class="fas fa-search text-sm"></i></span>
|
||||||
|
<input type="search" id="pantry-search" autocomplete="off" placeholder="Szukaj po nazwie…" class="flex-1 py-2.5 px-2 bg-transparent outline-none text-sm text-gray-800 placeholder-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div id="pantry-category-filters" class="flex gap-1.5 overflow-x-auto no-scrollbar pb-0.5 -mx-1 px-1"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pantry-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-4 no-scrollbar">
|
||||||
|
<div id="pantry-results" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pantry-shop-backdrop" class="absolute inset-0 z-[38] bg-black/40 hidden opacity-0 transition-opacity duration-200" aria-hidden="true"></div>
|
||||||
|
<div id="pantry-shop-sheet" class="absolute left-0 right-0 z-[40] bg-white rounded-t-3xl shadow-[0_-10px_40px_rgba(0,0,0,0.12)] px-4 pt-2 pb-5 flex flex-col gap-3 max-h-[55%] min-h-0" style="bottom: ${PANTRY_SHOP_BOTTOM}; transform: ${PANTRY_SHOP_OFF}; transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1)" role="dialog" aria-labelledby="pantry-shop-heading" aria-modal="true">
|
||||||
|
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto shrink-0" aria-hidden="true"></div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<h2 id="pantry-shop-heading" class="text-lg font-bold text-gray-900 leading-tight"></h2>
|
||||||
|
<p id="pantry-shop-sub" class="text-xs text-gray-500 mt-1"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center gap-4 py-2 shrink-0">
|
||||||
|
<button type="button" id="pantry-shop-minus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Mniej na liście"><i class="fas fa-minus text-sm"></i></button>
|
||||||
|
<input type="number" id="pantry-shop-qty" min="1" step="1" inputmode="numeric" class="w-24 text-center text-2xl font-bold tabular-nums border border-gray-200 rounded-xl py-2 outline-none focus:border-gray-400" value="1" />
|
||||||
|
<button type="button" id="pantry-shop-plus" class="w-11 h-11 rounded-xl bg-gray-100 text-gray-800 hover:bg-gray-200 flex items-center justify-center transition-colors" aria-label="Więcej na liście"><i class="fas fa-plus text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="pantry-shop-add" class="shrink-0 w-full py-3.5 rounded-xl bg-gray-900 text-white text-sm font-semibold hover:bg-black transition-colors">Dodaj na listę</button>
|
||||||
|
<button type="button" id="pantry-shop-cancel" class="shrink-0 w-full py-2 text-sm font-medium text-gray-500 hover:text-gray-800">Anuluj</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pantryFilterCategory = '';
|
||||||
|
|
||||||
|
/** Zwijanie sekcji (ignorowane przy aktywnym wyszukiwaniu — widać wszystkie trafienia). */
|
||||||
|
let pantryAccordionHaveOpen = true;
|
||||||
|
let pantryAccordionCatalogOpen = false;
|
||||||
|
|
||||||
|
function allCategoryKeys() {
|
||||||
|
const s = new Set();
|
||||||
|
Object.values(INGREDIENTS).forEach((d) => s.add(d.category));
|
||||||
|
return [...s].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryChips() {
|
||||||
|
const wrap = document.getElementById('pantry-category-filters');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
const keys = allCategoryKeys();
|
||||||
|
const chips = [
|
||||||
|
{ id: '', label: 'Wszystkie' },
|
||||||
|
...keys.map((k) => ({ id: k, label: categoryLabel(k) })),
|
||||||
|
];
|
||||||
|
|
||||||
|
wrap.innerHTML = chips.map((c) => {
|
||||||
|
const active = c.id === pantryFilterCategory;
|
||||||
|
const cls = active
|
||||||
|
? 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-900 text-white'
|
||||||
|
: 'shrink-0 px-3 py-1.5 rounded-full text-[11px] font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200';
|
||||||
|
return `<button type="button" data-pantry-cat="${escapeHtml(c.id)}" class="pantry-cat-btn ${cls}">${escapeHtml(c.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
wrap.querySelectorAll('.pantry-cat-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
pantryFilterCategory = btn.getAttribute('data-pantry-cat') || '';
|
||||||
|
renderCategoryChips();
|
||||||
|
renderPantryResults();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterIds(searchRaw) {
|
||||||
|
const q = normalizeSearch(searchRaw);
|
||||||
|
return Object.keys(INGREDIENTS)
|
||||||
|
.filter((id) => {
|
||||||
|
const d = INGREDIENTS[id];
|
||||||
|
if (pantryFilterCategory && d.category !== pantryFilterCategory) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
const name = d.name.toLowerCase();
|
||||||
|
const cat = (CATEGORY_LABELS[d.category] || '').toLowerCase();
|
||||||
|
return name.includes(q) || cat.includes(q);
|
||||||
|
})
|
||||||
|
.sort((a, b) => INGREDIENTS[a].name.localeCompare(INGREDIENTS[b].name, 'pl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Krok +/-: tylko liczby całkowite (szt. ±1, g/ml ±10). */
|
||||||
|
function qtyStepForIngredient(id) {
|
||||||
|
const u = INGREDIENTS[id]?.pantryUnit;
|
||||||
|
return u === 'szt' ? 1 : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitHaveAndCatalog(ids, pantry) {
|
||||||
|
const have = ids.filter((id) => (Number(pantry[id]) || 0) > 0);
|
||||||
|
const catalogOnly = ids.filter((id) => !pantry[id] || Number(pantry[id]) <= 0);
|
||||||
|
return { have, catalogOnly };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'have' | 'catalog'} sectionKey
|
||||||
|
* @param {{ title: string, hint: string, count: number, tone: 'emerald' | 'slate', open: boolean, searching: boolean, bodyInner: string }} opts
|
||||||
|
*/
|
||||||
|
function pantryAccordionSection(sectionKey, opts) {
|
||||||
|
const { title, hint, count, tone, open, searching, bodyInner } = opts;
|
||||||
|
const showToggle = !searching && count > 0;
|
||||||
|
const isOpen = searching || open || count === 0;
|
||||||
|
const ring = tone === 'emerald' ? 'ring-emerald-100/90' : 'ring-gray-200/90';
|
||||||
|
const dot = tone === 'emerald' ? 'bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.2)]' : 'bg-slate-400 shadow-[0_0_0_3px_rgba(148,163,184,0.25)]';
|
||||||
|
const chevronRot = isOpen ? '' : '-rotate-90';
|
||||||
|
|
||||||
|
const rowCls =
|
||||||
|
'w-full flex items-center gap-3 px-3.5 py-3 text-left min-h-[3.25rem]' +
|
||||||
|
(showToggle ? ' hover:bg-gray-50/80 transition-colors pantry-acc-toggle cursor-pointer' : '');
|
||||||
|
|
||||||
|
const chevron =
|
||||||
|
showToggle
|
||||||
|
? `<span class="shrink-0 w-8 h-8 rounded-xl bg-gray-100 flex items-center justify-center text-gray-600" aria-hidden="true"><i class="fas fa-chevron-down text-[10px] transition-transform duration-200 pantry-acc-chevron ${chevronRot}"></i></span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const headerInner = `
|
||||||
|
<span class="${dot} w-2 h-2 rounded-full shrink-0" aria-hidden="true"></span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
<span class="text-[13px] font-bold text-gray-900 tracking-tight">${escapeHtml(title)}</span>
|
||||||
|
<span class="text-xs font-semibold tabular-nums text-gray-500">${count}</span>
|
||||||
|
</span>
|
||||||
|
<span class="block text-[11px] text-gray-500 mt-0.5">${escapeHtml(hint)}</span>
|
||||||
|
</span>
|
||||||
|
${chevron}`;
|
||||||
|
|
||||||
|
const header =
|
||||||
|
showToggle
|
||||||
|
? `<button type="button" class="${rowCls}" data-pantry-acc="${escapeHtml(sectionKey)}" aria-expanded="${isOpen}">${headerInner}</button>`
|
||||||
|
: `<div class="${rowCls}">${headerInner}</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="rounded-2xl bg-white border border-gray-200/90 shadow-sm ring-1 ${ring} overflow-hidden mb-3 last:mb-0" data-pantry-acc-wrap="${escapeHtml(sectionKey)}">
|
||||||
|
${header}
|
||||||
|
<div class="pantry-acc-panel px-2.5 pb-2.5 pt-0 ${isOpen ? '' : 'hidden'}" data-pantry-acc-panel="${escapeHtml(sectionKey)}">
|
||||||
|
${bodyInner}
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pantryCardHtml(id, pantry, variant) {
|
||||||
|
const def = INGREDIENTS[id];
|
||||||
|
const unit = pantryUnitLabel(def.pantryUnit);
|
||||||
|
const qty = Number(pantry[id]) || 0;
|
||||||
|
const val = qty > 0 ? String(Math.round(qty)) : '';
|
||||||
|
const step = qtyStepForIngredient(id);
|
||||||
|
|
||||||
|
const shell = variant === 'have'
|
||||||
|
? 'rounded-xl border border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-white p-3 shadow-sm ring-1 ring-emerald-100/80'
|
||||||
|
: 'rounded-xl border border-dashed border-gray-200 bg-gray-50/90 p-3 shadow-sm';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${shell}" data-ingredient-id="${escapeHtml(id)}" data-pantry-variant="${variant}">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">${escapeHtml(def.name)}</p>
|
||||||
|
<p class="text-[10px] text-gray-500 mt-0.5">${escapeHtml(categoryLabel(def.category))} · magazyn: ${unit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="flex items-center gap-1 ${variant === 'have' ? 'bg-emerald-100/60' : 'bg-gray-100'} rounded-lg p-0.5">
|
||||||
|
<button type="button" class="pantry-qty-minus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Mniej"><i class="fas fa-minus text-[10px]"></i></button>
|
||||||
|
<input type="number" min="0" step="1" inputmode="numeric" pattern="[0-9]*" data-pantry-qty data-pantry-step="${step}"
|
||||||
|
class="w-16 text-center text-sm font-semibold tabular-nums bg-transparent border-0 outline-none py-1"
|
||||||
|
value="${val}" placeholder="0" title="Wpisz liczbę całkowitą; +/− zmienia o ${step} ${unit}" />
|
||||||
|
<button type="button" class="pantry-qty-plus w-8 h-8 rounded-md bg-white shadow-sm text-gray-700 hover:text-gray-900 flex items-center justify-center" aria-label="Więcej"><i class="fas fa-plus text-[10px]"></i></button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pantry-add-shop ml-auto flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-900 text-white text-xs font-semibold hover:bg-black transition-colors">
|
||||||
|
<i class="fas fa-cart-plus text-[10px]"></i>
|
||||||
|
Na listę…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPantryResults() {
|
||||||
|
const root = document.getElementById('pantry-results');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const searchEl = document.getElementById('pantry-search');
|
||||||
|
const q = searchEl?.value || '';
|
||||||
|
const searching = normalizeSearch(q) !== '';
|
||||||
|
const pantry = loadPantry();
|
||||||
|
const ids = filterIds(q);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
root.innerHTML = '<p class="text-sm text-gray-500 text-center py-10">Brak wyników — zmień wyszukiwanie lub filtr kategorii.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { have, catalogOnly } = splitHaveAndCatalog(ids, pantry);
|
||||||
|
|
||||||
|
const haveBody = have.length
|
||||||
|
? `<div class="space-y-2">${have.map((id) => pantryCardHtml(id, pantry, 'have')).join('')}</div>`
|
||||||
|
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Żaden z widocznych produktów nie ma jeszcze zapasu — ustaw ilość w katalogu poniżej.</p>';
|
||||||
|
|
||||||
|
const catBody = catalogOnly.length
|
||||||
|
? `<div class="space-y-2">${catalogOnly.map((id) => pantryCardHtml(id, pantry, 'catalog')).join('')}</div>`
|
||||||
|
: '<p class="text-xs text-gray-500 text-center py-6 px-2">Wszystkie widoczne pozycje są na stanie.</p>';
|
||||||
|
|
||||||
|
const haveHint =
|
||||||
|
have.length === 0
|
||||||
|
? 'Brak zapasu w tym widoku'
|
||||||
|
: have.length === 1
|
||||||
|
? '1 produkt z zapasem'
|
||||||
|
: `${have.length} produktów z zapasem`;
|
||||||
|
|
||||||
|
const catHint =
|
||||||
|
catalogOnly.length === 0
|
||||||
|
? 'Nic do uzupełnienia w tym widoku'
|
||||||
|
: catalogOnly.length === 1
|
||||||
|
? '1 pozycja bez zapasu'
|
||||||
|
: `${catalogOnly.length} pozycji bez zapasu`;
|
||||||
|
|
||||||
|
root.innerHTML =
|
||||||
|
pantryAccordionSection('have', {
|
||||||
|
title: 'Na stanie',
|
||||||
|
hint: haveHint,
|
||||||
|
count: have.length,
|
||||||
|
tone: 'emerald',
|
||||||
|
open: pantryAccordionHaveOpen,
|
||||||
|
searching,
|
||||||
|
bodyInner: haveBody,
|
||||||
|
}) +
|
||||||
|
pantryAccordionSection('catalog', {
|
||||||
|
title: 'Katalog — bez zapasu',
|
||||||
|
hint: catHint,
|
||||||
|
count: catalogOnly.length,
|
||||||
|
tone: 'slate',
|
||||||
|
open: pantryAccordionCatalogOpen,
|
||||||
|
searching,
|
||||||
|
bodyInner: catBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll('.pantry-acc-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const key = btn.getAttribute('data-pantry-acc');
|
||||||
|
if (key === 'have') pantryAccordionHaveOpen = !pantryAccordionHaveOpen;
|
||||||
|
else if (key === 'catalog') pantryAccordionCatalogOpen = !pantryAccordionCatalogOpen;
|
||||||
|
renderPantryResults();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll('[data-ingredient-id]').forEach((card) => {
|
||||||
|
const id = card.getAttribute('data-ingredient-id');
|
||||||
|
if (!id) return;
|
||||||
|
const input = card.querySelector('[data-pantry-qty]');
|
||||||
|
const step = parseInt(String(input?.getAttribute('data-pantry-step')), 10) || qtyStepForIngredient(id);
|
||||||
|
|
||||||
|
const applyQty = (n) => {
|
||||||
|
const v = Math.max(0, Math.round(Number(n)) || 0);
|
||||||
|
setPantryQty(id, v);
|
||||||
|
if (input) {
|
||||||
|
input.value = v > 0 ? String(v) : '';
|
||||||
|
}
|
||||||
|
const prevVariant = card.getAttribute('data-pantry-variant');
|
||||||
|
const nowHave = v > 0;
|
||||||
|
const expectVariant = nowHave ? 'have' : 'catalog';
|
||||||
|
if (prevVariant !== expectVariant) {
|
||||||
|
renderPantryResults();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
card.querySelector('.pantry-qty-minus')?.addEventListener('click', () => {
|
||||||
|
const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
|
||||||
|
applyQty(Math.max(0, cur - step));
|
||||||
|
});
|
||||||
|
card.querySelector('.pantry-qty-plus')?.addEventListener('click', () => {
|
||||||
|
const cur = Math.round(parseFloat(String(input?.value).replace(',', '.')) || 0);
|
||||||
|
applyQty(cur + step);
|
||||||
|
});
|
||||||
|
input?.addEventListener('change', () => {
|
||||||
|
const raw = String(input.value).replace(',', '.').trim();
|
||||||
|
const v = raw === '' ? 0 : Math.round(parseFloat(raw));
|
||||||
|
applyQty(Number.isFinite(v) ? v : 0);
|
||||||
|
});
|
||||||
|
card.querySelector('.pantry-add-shop')?.addEventListener('click', () => {
|
||||||
|
openPantryShopPicker(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readShopPickerQty() {
|
||||||
|
const el = document.getElementById('pantry-shop-qty');
|
||||||
|
const n = Math.round(parseFloat(String(el?.value).replace(',', '.')) || 0);
|
||||||
|
return Math.max(1, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShopPickerQtyDisplay(v) {
|
||||||
|
const el = document.getElementById('pantry-shop-qty');
|
||||||
|
if (el) el.value = String(Math.max(1, Math.round(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPantryShopPicker(ingredientId) {
|
||||||
|
const def = INGREDIENTS[ingredientId];
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
shopPickerIngredientId = ingredientId;
|
||||||
|
shopPickerStep = qtyStepForIngredient(ingredientId);
|
||||||
|
const unit = pantryUnitLabel(def.pantryUnit);
|
||||||
|
|
||||||
|
const heading = document.getElementById('pantry-shop-heading');
|
||||||
|
const sub = document.getElementById('pantry-shop-sub');
|
||||||
|
if (heading) heading.textContent = `Ile dodać: ${def.name}?`;
|
||||||
|
if (sub) {
|
||||||
|
sub.textContent = `Jednostka na liście: ${unit}. Przyciski +/−: ${shopPickerStep} ${unit}.`;
|
||||||
|
}
|
||||||
|
setShopPickerQtyDisplay(shopPickerStep);
|
||||||
|
|
||||||
|
const backdrop = document.getElementById('pantry-shop-backdrop');
|
||||||
|
const sheet = document.getElementById('pantry-shop-sheet');
|
||||||
|
if (!backdrop || !sheet) return;
|
||||||
|
|
||||||
|
sheet.classList.remove('hidden');
|
||||||
|
backdrop.classList.remove('hidden');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.remove('opacity-0');
|
||||||
|
sheet.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePantryShopPicker() {
|
||||||
|
shopPickerIngredientId = null;
|
||||||
|
const backdrop = document.getElementById('pantry-shop-backdrop');
|
||||||
|
const sheet = document.getElementById('pantry-shop-sheet');
|
||||||
|
if (sheet) {
|
||||||
|
sheet.style.transform = PANTRY_SHOP_OFF;
|
||||||
|
}
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.add('opacity-0');
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
backdrop?.classList.add('hidden');
|
||||||
|
sheet?.classList.add('hidden');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPantryShopSheet() {
|
||||||
|
document.getElementById('pantry-shop-backdrop')?.addEventListener('click', closePantryShopPicker);
|
||||||
|
document.getElementById('pantry-shop-cancel')?.addEventListener('click', closePantryShopPicker);
|
||||||
|
|
||||||
|
document.getElementById('pantry-shop-minus')?.addEventListener('click', () => {
|
||||||
|
const cur = readShopPickerQty();
|
||||||
|
setShopPickerQtyDisplay(Math.max(1, cur - shopPickerStep));
|
||||||
|
});
|
||||||
|
document.getElementById('pantry-shop-plus')?.addEventListener('click', () => {
|
||||||
|
const cur = readShopPickerQty();
|
||||||
|
setShopPickerQtyDisplay(cur + shopPickerStep);
|
||||||
|
});
|
||||||
|
document.getElementById('pantry-shop-qty')?.addEventListener('change', () => {
|
||||||
|
setShopPickerQtyDisplay(readShopPickerQty());
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pantry-shop-add')?.addEventListener('click', () => {
|
||||||
|
if (!shopPickerIngredientId) return;
|
||||||
|
const qty = readShopPickerQty();
|
||||||
|
addIngredientToKitchenList(shopPickerIngredientId, qty);
|
||||||
|
showAppToast(`Dodano ${qty} na listę kuchni.`);
|
||||||
|
closePantryShopPicker();
|
||||||
|
window.refreshShopping?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshPantry() {
|
||||||
|
renderCategoryChips();
|
||||||
|
renderPantryResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupPantry() {
|
||||||
|
renderCategoryChips();
|
||||||
|
renderPantryResults();
|
||||||
|
bindPantryShopSheet();
|
||||||
|
|
||||||
|
document.getElementById('pantry-search')?.addEventListener('input', () => {
|
||||||
|
renderPantryResults();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.refreshPantry = refreshPantry;
|
||||||
|
}
|
||||||
237
stacks/recipe/js/views/Shopping.js
Normal file
237
stacks/recipe/js/views/Shopping.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
addFreeformLine,
|
||||||
|
addFreeformList,
|
||||||
|
categoryLabel,
|
||||||
|
deleteList,
|
||||||
|
getActiveList,
|
||||||
|
getListSummaries,
|
||||||
|
KITCHEN_LIST_ID,
|
||||||
|
removeItemFromList,
|
||||||
|
setActiveListId,
|
||||||
|
toggleItemInList,
|
||||||
|
} from '../services/pantryShopping.js';
|
||||||
|
import { showAppToast } from '../ui/toast.js';
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShoppingHTML() {
|
||||||
|
return `
|
||||||
|
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden bg-gray-50 z-10 pb-24">
|
||||||
|
<div class="shrink-0 bg-white border-b border-gray-200 mt-3 px-4 pt-2 pb-3 space-y-3">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="sr-only" for="shopping-list-select">Aktywna lista</label>
|
||||||
|
<select id="shopping-list-select" class="flex-1 min-w-0 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm font-medium text-gray-900 outline-none focus:border-gray-400"></select>
|
||||||
|
<button type="button" id="shopping-new-list" class="shrink-0 w-10 h-10 rounded-xl border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 flex items-center justify-center" title="Nowa lista dowolna" aria-label="Nowa lista dowolna">
|
||||||
|
<i class="fas fa-plus text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="shopping-delete-list" class="hidden w-full py-2 rounded-lg text-xs font-medium text-red-600 hover:bg-red-50 transition-colors">
|
||||||
|
Usuń tę listę (nie dotyczy listy kuchennej)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="shopping-freeform-add" class="hidden shrink-0 px-4 pt-3 pb-2 space-y-2 border-b border-gray-100">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="shopping-freeform-input" class="flex-1 rounded-xl border border-gray-200 px-3 py-2.5 text-sm outline-none focus:border-gray-400" placeholder="Co kupić?" maxlength="200" />
|
||||||
|
<button type="button" id="shopping-freeform-submit" class="shrink-0 px-4 py-2.5 rounded-xl bg-gray-900 text-white text-xs font-semibold hover:bg-black">Dodaj</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="shopping-freeform-note" class="w-full rounded-lg border border-gray-100 px-3 py-2 text-xs text-gray-600 outline-none focus:border-gray-300" placeholder="Opcjonalna notatka (ilość, sklep…)" maxlength="120" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="shopping-scroll" class="flex-1 overflow-y-auto px-4 py-3 pb-4 no-scrollbar">
|
||||||
|
<div id="shopping-list-root" class="space-y-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<option value="${escapeHtml(l.id)}">${escapeHtml(label)}</option>`;
|
||||||
|
}).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 = '<p class="text-sm text-gray-500 text-center py-10">Brak pozycji.</p>';
|
||||||
|
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) => `
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||||
|
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wide px-3 py-2 bg-gray-50 border-b border-gray-100">${escapeHtml(categoryLabel(cat))}</p>
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
${groups[cat].map((it) => `
|
||||||
|
<li class="flex items-start gap-3 px-3 py-3 ${it.checked ? 'opacity-60' : ''}">
|
||||||
|
<button type="button" data-shop-toggle="${escapeHtml(it.id)}" class="mt-0.5 w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors ${it.checked ? 'bg-gray-900 border-gray-900 text-white' : 'border-gray-300 bg-white'}" aria-label="Kupione">
|
||||||
|
${it.checked ? '<i class="fas fa-check text-[10px]"></i>' : ''}
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.name)}</p>
|
||||||
|
<p class="text-xs text-gray-600 tabular-nums mt-0.5">${escapeHtml(String(it.amount))} ${escapeHtml(it.unit)}</p>
|
||||||
|
${it.sourceNote ? `<p class="text-[10px] text-gray-400 mt-1">${escapeHtml(it.sourceNote)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
.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 = '<p class="text-sm text-gray-500 text-center py-10">Dodaj dowolny tekst powyżej — bez powiązania z katalogiem składników.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
${items.map((it) => `
|
||||||
|
<li class="flex items-start gap-3 px-3 py-3 ${it.checked ? 'opacity-60' : ''}">
|
||||||
|
<button type="button" data-shop-toggle="${escapeHtml(it.id)}" class="mt-0.5 w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors ${it.checked ? 'bg-gray-900 border-gray-900 text-white' : 'border-gray-300 bg-white'}">
|
||||||
|
${it.checked ? '<i class="fas fa-check text-[10px]"></i>' : ''}
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 ${it.checked ? 'line-through text-gray-500' : ''}">${escapeHtml(it.text)}</p>
|
||||||
|
${it.note ? `<p class="text-xs text-gray-500 mt-1">${escapeHtml(it.note)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" data-shop-remove="${escapeHtml(it.id)}" class="shrink-0 w-8 h-8 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors" aria-label="Usuń">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user