import { INGREDIENTS, CATEGORY_LABELS, } from '../data/catalog.js?v=9'; import { loadPantry, getPantryTotal } from '../services/pantryShopping.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2'; import { addDays, sameDay, startOfDay, startOfMonth } from '../services/dateUtils.js'; import { aggregateRangeIngredientNeed, dayHasAnyMeal } from '../services/planIngredients.js?v=4'; import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar, } from '../ui/swipePopoverCalendar.js'; import { createCalendarPopoverHTML, stabilizeSwipeCalendarLayout, syncCalendarPopoverVisibility, } from '../ui/calendarPopover.js'; import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116'; import { ensureFilterPopoverStyles, filterChipStyle } from '../ui/filterPopover.js?v=1'; /* ── helpers ── */ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function unitLabel(u) { return u === 'szt' ? 'szt.' : u; } function normalizeSearch(q) { return String(q).trim().toLowerCase(); } function formatQty(n) { const rounded = Math.round((Number(n) || 0) * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, ''); } function toggleStringFilter(list, value) { return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; } function hasActivePantryFilters() { return pantryFilters.categories.length > 0 || pantryFilters.sections.length > 0; } function getActivePantryFilterCount() { return pantryFilters.categories.length + pantryFilters.sections.length; } const CATEGORY_ICONS = { pieczywo: 'fa-bread-slice', nabial: 'fa-cheese', mieso_ryby: 'fa-drumstick-bite', warzywa: 'fa-carrot', owoce: 'fa-apple-whole', suche: 'fa-wheat-awn', przyprawy: 'fa-leaf', inne: 'fa-jar', }; const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne']; const PANTRY_SECTION_FILTERS = [ { id: 'shortfalls', label: 'Potrzebne' }, { id: 'sufficient', label: 'W spiżarni' }, { id: 'notPlanned', label: 'Poza planem' }, ]; const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.']; const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru']; const DEFAULT_HORIZON_DAYS = 7; const SHORTFALL_ACCENT = 'rgb(var(--danger-rgb))'; const PANTRY_CALENDAR_THEME = { bg: 'rgba(255,255,255,0.08)', border: 'rgb(var(--card-raised-rgb))', text: 'rgb(var(--text-body-soft-rgb))', dimText: 'rgb(var(--text-faint-rgb))', dimOpacity: 0.58, dimmedBg: 'transparent', dimmedBorder: 'transparent', dot: 'rgb(var(--text-faint-rgb))', selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)', selectedText: 'rgb(var(--text-emphasis-rgb))', selectedDot: 'rgb(var(--text-emphasis-rgb))', selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)', }; /* ── state ── */ let ingredientCard = null; let horizonEndDate = addDays(startOfDay(new Date()), DEFAULT_HORIZON_DAYS - 1); let isCalendarOpen = false; let isFilterOpen = false; let calendarMonthAnchor = startOfMonth(horizonEndDate); let pantryGlobalListenersBound = false; let pantryCalendar = null; let pantrySearchQuery = ''; let pantrySearchOpen = false; let pantryFilters = { categories: [], sections: [], }; /* ── date formatting ── */ function getToday() { return startOfDay(new Date()); } function ensureValidHorizonDate() { const today = getToday(); if (!(horizonEndDate instanceof Date) || Number.isNaN(horizonEndDate.getTime()) || horizonEndDate.getTime() < today.getTime()) { horizonEndDate = today; } if (!(calendarMonthAnchor instanceof Date) || Number.isNaN(calendarMonthAnchor.getTime())) { calendarMonthAnchor = startOfMonth(horizonEndDate); } } function getHorizonDays() { ensureValidHorizonDate(); const diffMs = startOfDay(horizonEndDate).getTime() - getToday().getTime(); return Math.max(1, Math.floor(diffMs / 86400000) + 1); } function formatEndDate(date) { return `${DAY_NAMES_SHORT[date.getDay()]} ${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`; } function formatHorizonLabel(date) { return sameDay(date, getToday()) ? 'Do dziś' : `Do ${formatEndDate(date)}`; } function formatDayContext(dayStrings) { const dayNames = dayStrings.map((ds) => { const d = new Date(ds + 'T00:00:00'); return DAY_NAMES_SHORT[d.getDay()]; }); if (dayNames.length <= 3) return dayNames.join(', '); return dayNames.slice(0, 3).join(', ') + ', \u2026'; } /* ── media helpers ── */ function photoStripMedia(image, icon, accentBg) { if (image) { return `
`; } return `
`; } /* ══════════════════════ HTML SHELL ══════════════════════ */ export function getPantryHTML() { return ` ${getIngredientCardHTML({ idBase: 'pv2-card' })}`; } /* ══════════════════════ HORIZON SELECTOR ══════════════════════ */ function syncHorizonUI() { ensureValidHorizonDate(); const popover = document.getElementById('pantry-calendar-popover'); const filterPopover = document.getElementById('pantry-filter-popover'); const filterCount = document.getElementById('pantry-filter-count'); const filterPillCount = document.getElementById('pantry-filter-pill-count'); const searchWrap = document.getElementById('pantry-search-wrap'); const searchShell = document.getElementById('pantry-search-shell'); const rightWrap = document.getElementById('pantry-filter-bottom-wrap'); const rightBtn = document.getElementById('pantry-filter-bottom-btn'); const rightIcon = document.getElementById('pantry-right-btn-icon'); const searchDot = document.getElementById('pantry-search-active-dot'); const compactLabel = document.getElementById('pantry-horizon-compact-label'); const compactPill = document.getElementById('pantry-horizon-compact'); const chevron = document.getElementById('pantry-horizon-chevron'); if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate); const activeFilterCount = getActivePantryFilterCount(); syncCalendarPopoverVisibility({ popup: popover, isOpen: isCalendarOpen, chevron, chevronOpenTransform: 'rotate(180deg)', chevronClosedTransform: 'rotate(0deg)', trigger: compactPill, openTriggerStyle: {}, closedTriggerStyle: {}, triggerImportant: true, }); if (filterPopover) { filterPopover.style.opacity = isFilterOpen ? '1' : '0'; filterPopover.style.transform = isFilterOpen ? 'translateX(-50%) translateY(0) scale(1)' : 'translateX(-50%) translateY(0.5rem) scale(0.98)'; filterPopover.style.pointerEvents = isFilterOpen ? 'auto' : 'none'; } if (filterCount) { filterCount.textContent = String(activeFilterCount); filterCount.classList.toggle('hidden', true); filterCount.classList.toggle('flex', false); } if (filterPillCount) { filterPillCount.textContent = String(activeFilterCount); filterPillCount.classList.toggle('hidden', activeFilterCount === 0); filterPillCount.classList.toggle('flex', activeFilterCount > 0); } if (searchWrap) searchWrap.classList.toggle('hidden', pantrySearchOpen); if (searchShell) { searchShell.style.opacity = pantrySearchOpen ? '1' : '0'; searchShell.style.pointerEvents = pantrySearchOpen ? 'auto' : 'none'; searchShell.style.transform = pantrySearchOpen ? 'translateY(0) scale(1)' : 'translateY(0.45rem) scale(0.98)'; } if (rightIcon) rightIcon.className = 'fas fa-xmark'; if (rightBtn) rightBtn.setAttribute('aria-label', 'Zamknij wyszukiwanie'); if (rightWrap) rightWrap.classList.toggle('hidden', !pantrySearchOpen); if (searchDot) searchDot.classList.toggle('hidden', !pantrySearchQuery); renderCalendarPopover(); renderFilterPopover(); } function renderCalendarPopover() { pantryCalendar?.render(); } function bindPantryCalendarInteractions() { pantryCalendar = initSwipePopoverCalendar({ idPrefix: 'pantry-cal', selectionMode: 'single', panelHandlePx: 10, panelHandleMin: 8, panelHandleMax: 12, getMonthAnchor: () => calendarMonthAnchor, setMonthAnchor: (nextMonth) => { const nextAnchor = startOfMonth(nextMonth); const minAnchor = startOfMonth(getToday()); if (nextAnchor.getTime() < minAnchor.getTime()) return; calendarMonthAnchor = nextAnchor; }, canNavigateToMonth: (nextMonth) => { const nextAnchor = startOfMonth(nextMonth); const minAnchor = startOfMonth(getToday()); return nextAnchor.getTime() >= minAnchor.getTime(); }, getSelectionKeys: () => dateKey(horizonEndDate), onSelectionCommit: (selectedKey) => { selectHorizonDate(new Date(`${selectedKey}T00:00:00`)); }, resolveDayState: (day, { inCurrentMonth }) => { const isPast = day.getTime() < getToday().getTime(); return { disabled: isPast, dimmed: isPast || !inCurrentMonth, showDot: dayHasAnyMeal(loadPlans(), day), }; }, theme: PANTRY_CALENDAR_THEME, }); pantryCalendar.render(); } function filterChipHtml(kind, value, label, active) { return ``; } function renderFilterPopover() { const body = document.getElementById('pantry-filter-panel-body'); if (!body) return; const categoryChips = CATEGORY_ORDER .map((category) => filterChipHtml( 'category', category, CATEGORY_LABELS[category] || category, pantryFilters.categories.includes(category), )) .join(''); const sectionChips = PANTRY_SECTION_FILTERS .map((item) => filterChipHtml( 'section', item.id, item.label, pantryFilters.sections.includes(item.id), )) .join(''); body.innerHTML = `

Kategorie

${categoryChips}

Sekcje

${sectionChips}
`; } function clearSearchInput() { const hadQuery = Boolean(pantrySearchQuery); pantrySearchQuery = ''; if (hadQuery) renderBoard(); } function setPantrySearchOpen(open, { clearQuery = false, focusInput = false } = {}) { const hadQuery = Boolean(pantrySearchQuery); pantrySearchOpen = open; if (open) { isCalendarOpen = false; isFilterOpen = false; } document.documentElement.classList.toggle('is-inline-search-open', pantrySearchOpen); if (clearQuery) pantrySearchQuery = ''; const input = document.getElementById('pantry-search-input'); if (input) { if (open) { input.value = pantrySearchQuery; if (focusInput) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } } else { input.blur(); } } syncHorizonUI(); if (clearQuery && hadQuery) renderBoard(); } function closeCalendar() { if (!isCalendarOpen) return; isCalendarOpen = false; pantryCalendar?.resetTrackPosition(); syncHorizonUI(); } function openCalendar() { ensureValidHorizonDate(); calendarMonthAnchor = startOfMonth(horizonEndDate); isFilterOpen = false; isCalendarOpen = true; syncHorizonUI(); stabilizeSwipeCalendarLayout({ calendar: pantryCalendar, viewport: 'pantry-cal-viewport', }); } function closeFilter() { if (!isFilterOpen) return; isFilterOpen = false; syncHorizonUI(); } function toggleFilterPanel() { if (pantrySearchOpen) return; isCalendarOpen = false; isFilterOpen = !isFilterOpen; syncHorizonUI(); } function selectHorizonDate(date) { const next = startOfDay(date); if (next.getTime() < getToday().getTime()) return; horizonEndDate = next; calendarMonthAnchor = startOfMonth(next); isCalendarOpen = false; syncHorizonUI(); renderBoard(); } /* ══════════════════════ DATA CLASSIFICATION ══════════════════════ */ /** * Classify all ingredients into 3 groups based on plan needs and pantry stock. */ function classifyIngredients(searchQuery) { const plans = loadPlans(); const pantry = loadPantry(); const today = getToday(); const needs = aggregateRangeIngredientNeed(plans, today, getHorizonDays()); const q = normalizeSearch(searchQuery); const matchesSearch = (name, category) => { if (!q) return true; return name.toLowerCase().includes(q) || (CATEGORY_LABELS[category] || '').toLowerCase().includes(q); }; const matchesCategory = (category) => pantryFilters.categories.length === 0 || pantryFilters.categories.includes(category); const matchesSection = (section) => pantryFilters.sections.length === 0 || pantryFilters.sections.includes(section); const neededIds = new Set(); const shortfalls = []; const sufficient = []; for (const need of needs) { neededIds.add(need.ingredientId); if (!matchesSearch(need.name, need.category) || !matchesCategory(need.category)) continue; const have = getPantryTotal(need.ingredientId, pantry); const def = INGREDIENTS[need.ingredientId]; const icon = CATEGORY_ICONS[def?.category] || 'fa-jar'; const item = { ingredientId: need.ingredientId, name: need.name, category: need.category, needed: need.amount, unit: need.unit, pantryQty: have, days: need.days, image: def?.image || null, icon, }; if (have < need.amount) { if (!matchesSection('shortfalls')) continue; shortfalls.push({ ...item, shortfall: Math.round((need.amount - have) * 10) / 10, fillPct: need.amount > 0 ? Math.min(100, Math.round((have / need.amount) * 100)) : 0, }); } else { if (!matchesSection('sufficient')) continue; sufficient.push({ ...item, fillPct: 100, }); } } // "Poza planem": all ingredients NOT in any plan need const notPlanned = Object.keys(INGREDIENTS) .filter((id) => !neededIds.has(id)) .filter(() => matchesSection('notPlanned')) .filter((id) => matchesSearch(INGREDIENTS[id].name, INGREDIENTS[id].category)) .filter((id) => matchesCategory(INGREDIENTS[id].category)) .map((id) => { const def = INGREDIENTS[id]; return { ingredientId: id, name: def.name, qty: getPantryTotal(id, pantry), unit: def.pantryUnit, image: def.image || null, icon: CATEGORY_ICONS[def.category] || 'fa-jar', }; }) .sort((a, b) => a.name.length - b.name.length); return { shortfalls, sufficient, notPlanned }; } /* ══════════════════════ TILE RENDERING ══════════════════════ */ function tileIconHtml(item, size = 'sm') { const wrap = size === 'lg' ? 'w-11 h-11' : 'w-7 h-7'; const iconSize = size === 'lg' ? 'text-[22px]' : 'text-[15px]'; if (item.image) { return `
`; } return `
`; } function shortfallTileHtml(item) { const clamp = item.name.length > 25 ? ' min-w-0' : ''; return ` `; } function sufficientTileHtml(item) { const clamp = item.name.length > 25 ? ' min-w-0' : ''; return ` `; } function notPlannedChipHtml(item) { const hasStock = item.qty > 0; const clamp = item.name.length > 25 ? ' min-w-0' : ''; return ` `; } /* ══════════════════════ SECTION RENDERING ══════════════════════ */ function sectionHeaderHtml(icon, iconBg, iconColor, title, titleColor, count) { return `
${esc(title)} ${count}
`; } function renderBoard() { const root = document.getElementById('pantry-board'); if (!root) return; const q = pantrySearchQuery; const hasFilters = hasActivePantryFilters(); const { shortfalls, sufficient, notPlanned } = classifyIngredients(q); const totalVisible = shortfalls.length + sufficient.length + notPlanned.length; if (totalVisible === 0 && (q || hasFilters)) { root.innerHTML = `

Brak wyników — zmień filtry lub wyszukiwanie.

`; return; } let html = ''; // Section 1: Potrzebne (shortfalls) if (shortfalls.length > 0) { html += `
`; html += sectionHeaderHtml('fa-exclamation', 'rgb(var(--card-soft-rgb))', SHORTFALL_ACCENT, 'Potrzebne', 'rgb(var(--text-faint-rgb))', shortfalls.length); html += `
`; html += shortfalls.sort((a, b) => a.name.length - b.name.length).map(shortfallTileHtml).join(''); html += `
`; } // Section 2: W spiżarni (sufficient) if (sufficient.length > 0) { html += `
`; html += sectionHeaderHtml('fa-check', 'rgba(var(--success-rgb), 0.14)', 'rgb(var(--success-rgb))', 'W spiżarni', 'rgb(var(--text-faint-rgb))', sufficient.length); html += `
`; html += sufficient.sort((a, b) => a.name.length - b.name.length).map(sufficientTileHtml).join(''); html += `
`; } // Section 3: Poza planem if (notPlanned.length > 0) { html += `
`; html += sectionHeaderHtml('fa-minus', 'rgb(var(--app-bg-rgb))', 'rgb(var(--text-subdued-rgb))', 'Poza planem', 'rgb(var(--text-faint-rgb))', notPlanned.length); html += `
`; html += notPlanned.map(notPlannedChipHtml).join(''); html += `
`; } // Empty state: no plans at all if (shortfalls.length === 0 && sufficient.length === 0 && !q && !hasFilters) { html = `

Brak zaplanowanych posiłków

Zaplanuj posiłki, a spiżarnia pokaże czego potrzebujesz i co masz na stanie.

` + html; } root.innerHTML = html; // Bind tile clicks root.querySelectorAll('.pv2-tile').forEach((btn) => { btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, null)); }); } /* ══════════════════════ INGREDIENT SHEET ══════════════════════ */ function openIngredientCard(ingredientId, productId) { ingredientCard?.open({ ingredientId, productId, sourceNote: 'Ze spiżarni', onAfterChange: () => renderBoard(), }); } /* ══════════════════════ PUBLIC API ══════════════════════ */ export function refreshPantry() { syncHorizonUI(); renderBoard(); ingredientCard?.refresh(); } export function setupPantry() { ensureFilterPopoverStyles(); if (!ingredientCard) { ingredientCard = createIngredientCardController({ idBase: 'pv2-card', defaultSourceNote: 'Ze spiżarni' }); ingredientCard.bind(); } syncHorizonUI(); renderBoard(); // Search document.getElementById('pantry-search-btn')?.addEventListener('click', (event) => { event.stopPropagation(); setPantrySearchOpen(true, { focusInput: true }); }); document.getElementById('pantry-search-input')?.addEventListener('input', (event) => { pantrySearchQuery = event.target.value.trim(); renderBoard(); }); document.getElementById('pantry-search-input')?.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; event.stopPropagation(); setPantrySearchOpen(false, { clearQuery: true }); }); document.getElementById('pantry-filter-bottom-btn')?.addEventListener('click', (event) => { event.stopPropagation(); if (pantrySearchOpen) { setPantrySearchOpen(false, { clearQuery: true }); } }); document.getElementById('pantry-filter-pill-btn')?.addEventListener('click', (event) => { event.stopPropagation(); if (pantrySearchOpen) return; closeCalendar(); toggleFilterPanel(); }); document.getElementById('pantry-filter-clear')?.addEventListener('click', (event) => { event.stopPropagation(); pantryFilters = { categories: [], sections: [] }; syncHorizonUI(); renderBoard(); }); document.getElementById('pantry-filter-panel-body')?.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof Element)) return; const chip = target.closest('[data-pantry-filter-kind]'); if (!(chip instanceof Element)) return; event.stopPropagation(); const kind = chip.getAttribute('data-pantry-filter-kind'); const value = chip.getAttribute('data-pantry-filter-value'); if (!kind || !value) return; if (kind === 'category') { pantryFilters = { ...pantryFilters, categories: toggleStringFilter(pantryFilters.categories, value), }; } if (kind === 'section') { pantryFilters = { ...pantryFilters, sections: toggleStringFilter(pantryFilters.sections, value), }; } syncHorizonUI(); renderBoard(); }); // Horizon pill + calendar document.getElementById('pantry-horizon-compact')?.addEventListener('click', (event) => { event.stopPropagation(); isCalendarOpen ? closeCalendar() : openCalendar(); }); bindPantryCalendarInteractions(); if (!pantryGlobalListenersBound) { pantryGlobalListenersBound = true; document.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof Element)) return; if (isCalendarOpen && !target.closest('#pantry-horizon-wrap, #pantry-horizon-compact')) { closeCalendar(); } if (isFilterOpen && !target.closest('#pantry-filter-popover, #pantry-filter-pill-btn')) { closeFilter(); } }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (pantrySearchOpen) { setPantrySearchOpen(false, { clearQuery: true }); return; } if (pantrySearchQuery) clearSearchInput(); if (isFilterOpen) closeFilter(); }); window.addEventListener('app-tab-change', () => { if (pantrySearchOpen) setPantrySearchOpen(false); closeFilter(); closeCalendar(); }); window.closePantrySearch = () => { if (pantrySearchOpen) setPantrySearchOpen(false); }; window.closePantryFilter = () => { closeFilter(); }; } window.refreshPantry = refreshPantry; }