Add pantry and shopping lists

This commit is contained in:
2026-03-24 22:39:58 +01:00
parent 091e126744
commit 8672c8da28
13 changed files with 1800 additions and 362 deletions

View File

@@ -1,3 +1,30 @@
import { RECIPES } from '../data/catalog.js';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addDays,
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js';
import {
aggregateDayIngredientsBySlot,
dayHasAnyMeal,
sumDayNutrition,
} from '../services/planIngredients.js';
import { addOrMergeShoppingLines } from '../services/pantryShopping.js';
import {
dateKey,
getDayPlan,
loadPlans,
newPlanEntryId,
savePlans,
} from '../services/planStore.js';
const MONTHS_SHORT = [
'sty', 'lut', 'mar', 'kwi', 'maj', 'cze',
'lip', 'sie', 'wrz', 'paź', 'lis', 'gru',
@@ -7,346 +34,12 @@ const WEEKDAYS_LONG = [
'Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota',
];
const MEAL_SLOTS = [
{ id: 'sniadanie', label: 'Śniadanie', icon: 'fa-sun' },
{ id: 'drugie_sniadanie', label: 'Drugie śniadanie', icon: 'fa-coffee' },
{ id: 'obiad', label: 'Obiad', icon: 'fa-utensils' },
{ id: 'przekaska', label: 'Przekąska', icon: 'fa-apple-alt' },
{ id: 'kolacja', label: 'Kolacja', icon: 'fa-moon' },
];
/** Katalog przepisów (spójny z listą w aplikacji) — porcja bazowa = 1 */
const PLANNER_RECIPES = {
placki: {
id: 'placki',
title: 'Puszyste placki',
minutes: 15,
thumbLabel: 'Placki',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
nutritionPerServing: { kcal: 320, protein: 12, fat: 8, carbs: 48 },
ingredients: [
{ name: 'Mąka pszenna', amount: 200, unit: 'g' },
{ name: 'Mleko', amount: 250, unit: 'ml' },
{ name: 'Jajka', amount: 2, unit: 'szt.' },
],
},
salatka: {
id: 'salatka',
title: 'Sałatka z kurczakiem',
minutes: 20,
thumbLabel: 'Sałatka',
allowedSlots: ['obiad'],
nutritionPerServing: { kcal: 250, protein: 35, fat: 9, carbs: 12 },
ingredients: [
{ name: 'Pierś z kurczaka', amount: 150, unit: 'g' },
{ name: 'Mix sałat', amount: 100, unit: 'g' },
{ name: 'Pomidor', amount: 1, unit: 'szt.' },
],
},
makaron: {
id: 'makaron',
title: 'Makaron z pomidorami i bazylią',
minutes: 30,
thumbLabel: 'Makaron',
allowedSlots: ['obiad', 'kolacja'],
nutritionPerServing: { kcal: 450, protein: 14, fat: 12, carbs: 72 },
ingredients: [
{ name: 'Makaron', amount: 120, unit: 'g' },
{ name: 'Pomidory krojone', amount: 400, unit: 'g' },
{ name: 'Bazylia świeża', amount: 10, unit: 'g' },
],
},
koktajl: {
id: 'koktajl',
title: 'Koktajl owocowy',
minutes: 5,
thumbLabel: 'Koktajl',
allowedSlots: ['przekaska', 'drugie_sniadanie'],
nutritionPerServing: { kcal: 180, protein: 8, fat: 3, carbs: 32 },
ingredients: [
{ name: 'Jogurt naturalny', amount: 200, unit: 'g' },
{ name: 'Mieszanka jagód', amount: 150, unit: 'g' },
{ name: 'Miód', amount: 15, unit: 'g' },
],
},
tost_awokado: {
id: 'tost_awokado',
title: 'Tost z awokado',
minutes: 10,
thumbLabel: 'Tost',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
nutritionPerServing: { kcal: 220, protein: 6, fat: 14, carbs: 20 },
ingredients: [
{ name: 'Chleb na zakwasie', amount: 2, unit: 'kromki' },
{ name: 'Awokado', amount: 1, unit: 'szt.' },
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
],
},
losos: {
id: 'losos',
title: 'Grillowany łosoś',
minutes: 25,
thumbLabel: 'Łosoś',
allowedSlots: ['kolacja', 'obiad'],
nutritionPerServing: { kcal: 380, protein: 38, fat: 22, carbs: 4 },
ingredients: [
{ name: 'Filet z łososia', amount: 180, unit: 'g' },
{ name: 'Cytryna', amount: 0.5, unit: 'szt.' },
{ name: 'Koper', amount: 5, unit: 'g' },
],
},
tacos: {
id: 'tacos',
title: 'Tacos z wołowiną',
minutes: 20,
thumbLabel: 'Tacos',
allowedSlots: ['kolacja', 'obiad'],
nutritionPerServing: { kcal: 410, protein: 28, fat: 18, carbs: 38 },
ingredients: [
{ name: 'Mięso mielone wołowe', amount: 200, unit: 'g' },
{ name: 'Tortille kukurydziane', amount: 4, unit: 'szt.' },
{ name: 'Salsa pomidorowa', amount: 100, unit: 'g' },
],
},
owsianka: {
id: 'owsianka',
title: 'Miska owsianki',
minutes: 10,
thumbLabel: 'Owsianka',
allowedSlots: ['sniadanie', 'drugie_sniadanie'],
nutritionPerServing: { kcal: 210, protein: 8, fat: 6, carbs: 34 },
ingredients: [
{ name: 'Płatki owsiane', amount: 60, unit: 'g' },
{ name: 'Mleko', amount: 200, unit: 'ml' },
{ name: 'Miód', amount: 20, unit: 'g' },
],
},
serek_owoc: {
id: 'serek_owoc',
title: 'Serek wiejski z orzechami i owocami',
minutes: 5,
thumbLabel: 'Serek',
allowedSlots: ['sniadanie', 'drugie_sniadanie', 'przekaska'],
nutritionPerServing: { kcal: 642, protein: 32, fat: 43, carbs: 41 },
ingredients: [
{ name: 'Serek wiejski', amount: 200, unit: 'g' },
{ name: 'Miód', amount: 10, unit: 'g' },
{ name: 'Orzechy włoskie', amount: 50, unit: 'g' },
{ name: 'Truskawki', amount: 100, unit: 'g' },
{ name: 'Borówki ameryk.', amount: 80, unit: 'g' },
],
},
};
const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
/** Odstęp od dołu planera = miejsce na dolną nawigację. Ten sam w `bottom`, `max-height` i w `translateY(calc(100% + …))` przy zamknięciu — inaczej zostaje widoczny uchwyt. */
const PLANNER_SHEET_BOTTOM_INSET = '5.25rem';
const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTTOM_INSET}))`;
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
function addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return startOfDay(x);
}
/** Poniedziałek jako pierwszy dzień tygodnia (PL) */
function startOfWeekMonday(d) {
const date = startOfDay(d);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
return addDays(date, diff);
}
function startOfMonth(d) {
const x = new Date(d.getFullYear(), d.getMonth(), 1);
return startOfDay(x);
}
function addMonths(d, n) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return startOfDay(x);
}
function addWeeks(d, n) {
return addDays(d, n * 7);
}
function weekContains(weekStart, d) {
const t = startOfDay(d).getTime();
const ws = weekStart.getTime();
const we = addDays(weekStart, 6).getTime();
return t >= ws && t <= we;
}
function sameMonth(a, b) {
return a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
}
function dateKey(d) {
const x = startOfDay(d);
const y = x.getFullYear();
const m = String(x.getMonth() + 1).padStart(2, '0');
const day = String(x.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function newPlanEntryId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `e${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
/** Jedna pora dnia = tablica wpisów { id, recipeId, servings } (stary format: jeden obiekt — migrujemy przy wczytaniu). */
function normalizeSlotValue(v) {
if (!v) return [];
if (Array.isArray(v)) {
return v
.filter((x) => x && x.recipeId && PLANNER_RECIPES[x.recipeId])
.map((x) => ({
id: x.id && String(x.id).length ? String(x.id) : newPlanEntryId(),
recipeId: x.recipeId,
servings: Math.max(1, Math.min(12, Number(x.servings) || 1)),
}));
}
if (typeof v === 'object' && v.recipeId && PLANNER_RECIPES[v.recipeId]) {
return [{
id: newPlanEntryId(),
recipeId: v.recipeId,
servings: Math.max(1, Math.min(12, Number(v.servings) || 1)),
}];
}
return [];
}
function normalizeDayPlan(day) {
if (!day || typeof day !== 'object') return {};
const out = {};
MEAL_SLOTS.forEach((s) => {
const arr = normalizeSlotValue(day[s.id]);
if (arr.length > 0) out[s.id] = arr;
});
return out;
}
function normalizeAllPlans(plans) {
if (!plans || typeof plans !== 'object') return {};
const out = {};
Object.keys(plans).forEach((key) => {
const d = normalizeDayPlan(plans[key]);
if (Object.keys(d).length > 0) out[key] = d;
});
return out;
}
function loadPlans() {
try {
const raw = localStorage.getItem(PLANS_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return {};
return normalizeAllPlans(parsed);
} catch {
return {};
}
}
function savePlans(plans) {
try {
localStorage.setItem(PLANS_STORAGE_KEY, JSON.stringify(plans));
} catch { /* ignore */ }
}
function getDayPlan(plans, d) {
const key = dateKey(d);
const day = plans[key];
return day && typeof day === 'object' ? day : {};
}
function dayHasAnyMeal(plans, d) {
const p = getDayPlan(plans, d);
return MEAL_SLOTS.some((s) => {
const arr = p[s.id];
return Array.isArray(arr) && arr.length > 0;
});
}
function sumDayNutrition(dayPlan) {
let kcal = 0;
let protein = 0;
let fat = 0;
let carbs = 0;
let mealCount = 0;
MEAL_SLOTS.forEach((slot) => {
const entries = dayPlan[slot.id];
if (!Array.isArray(entries)) return;
entries.forEach((entry) => {
if (!entry || !entry.recipeId) return;
const r = PLANNER_RECIPES[entry.recipeId];
if (!r) return;
const s = Math.max(1, Number(entry.servings) || 1);
mealCount += 1;
kcal += r.nutritionPerServing.kcal * s;
protein += r.nutritionPerServing.protein * s;
fat += r.nutritionPerServing.fat * s;
carbs += r.nutritionPerServing.carbs * s;
});
});
return {
kcal: Math.round(kcal),
protein: Math.round(protein * 10) / 10,
fat: Math.round(fat * 10) / 10,
carbs: Math.round(carbs * 10) / 10,
mealCount,
};
}
/**
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
* @returns {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]}
*/
function aggregateDayIngredientsBySlot(dayPlan) {
/** @type {{ mealLabel: string, recipes: { recipeTitle: string, items: { name: string, amount: number, unit: string }[] }[] }[]} */
const blocks = [];
MEAL_SLOTS.forEach((slot) => {
const entries = dayPlan[slot.id];
if (!Array.isArray(entries) || entries.length === 0) return;
const recipes = [];
entries.forEach((entry) => {
if (!entry || !entry.recipeId) return;
const r = PLANNER_RECIPES[entry.recipeId];
if (!r) return;
const s = Math.max(1, Number(entry.servings) || 1);
const items = r.ingredients.map((ing) => ({
name: ing.name,
amount: Math.round(ing.amount * s * 10) / 10,
unit: ing.unit,
}));
recipes.push({ recipeTitle: r.title, items });
});
if (recipes.length > 0) {
blocks.push({ mealLabel: slot.label, recipes });
}
});
return blocks;
}
function recipesForSlot(slotId) {
return Object.values(PLANNER_RECIPES).filter((r) => r.allowedSlots.includes(slotId));
return Object.values(RECIPES).filter((r) => r.allowedSlots.includes(slotId));
}
function isCalendarOnToday(mode, weekStart, monthAnchor, selected) {
@@ -727,7 +420,7 @@ function renderDayContent(state) {
: '';
const entryCards = entries.map((entry) => {
const recipe = entry && entry.recipeId ? PLANNER_RECIPES[entry.recipeId] : null;
const recipe = entry && entry.recipeId ? RECIPES[entry.recipeId] : null;
if (!recipe) return '';
const servings = Math.max(1, Number(entry.servings) || 1);
const n = recipe.nutritionPerServing;
@@ -851,7 +544,11 @@ function renderIngredientsSheet(state) {
<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">
${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>
<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>
@@ -1042,7 +739,7 @@ export function setupMealPlanner() {
const pick = e.target.closest('.planner-pick-recipe');
if (!pick || !state.pickerSlot) return;
const recipeId = pick.getAttribute('data-recipe-id');
if (!recipeId || !PLANNER_RECIPES[recipeId]) return;
if (!recipeId || !RECIPES[recipeId]) return;
const key = dateKey(state.selected);
if (!state.plans[key]) state.plans[key] = {};
const slotId = state.pickerSlot;
@@ -1073,7 +770,24 @@ export function setupMealPlanner() {
const rows = body?.querySelectorAll('.planner-ing-row');
const n = rows?.length ?? 0;
if (n === 0) return;
showPlannerToast(`Dodano ${n} składników do listy (zakładka Zakupy w przygotowaniu).`);
const lines = [];
rows.forEach((row) => {
const id = row.getAttribute('data-ingredient-id');
const amount = parseFloat(row.getAttribute('data-amount') || '');
const unit = row.getAttribute('data-unit') || '';
const category = row.getAttribute('data-category') || '';
if (!id || !Number.isFinite(amount)) return;
lines.push({
ingredientId: id,
amount,
unit,
category,
sourceNote: 'Z planu dnia',
});
});
addOrMergeShoppingLines(lines);
showPlannerToast(`Dodano ${lines.length} składników na listę.`);
window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});
@@ -1085,7 +799,24 @@ export function setupMealPlanner() {
showPlannerToast('Zaznacz składniki na liście albo użyj „Dodaj wszystkie”.');
return;
}
showPlannerToast(`Dodano ${n} zaznaczonych pozycji do listy (zakładka Zakupy w przygotowaniu).`);
const lines = [];
selected.forEach((row) => {
const id = row.getAttribute('data-ingredient-id');
const amount = parseFloat(row.getAttribute('data-amount') || '');
const unit = row.getAttribute('data-unit') || '';
const category = row.getAttribute('data-category') || '';
if (!id || !Number.isFinite(amount)) return;
lines.push({
ingredientId: id,
amount,
unit,
category,
sourceNote: 'Z planu dnia',
});
});
addOrMergeShoppingLines(lines);
showPlannerToast(`Dodano ${lines.length} pozycji na listę.`);
window.refreshShopping?.();
closeSheet(ingBackdrop, ingSheet);
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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;
}