import { INGREDIENTS } from '../data/catalog.js?v=9'; import { loadPantry, computeShortfalls, categoryLabel, addAmountToPantry, subtractFromPantry, getSelectedDays, setSelectedDays, addSessionLogEntry, removeSessionLogEntry, clearSessionLog, loadShoppingSession, } from '../services/pantryShopping.js?v=2'; import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2'; import { addDays, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js'; import { showAppToast } from '../ui/toast.js'; /* ── helpers ── */ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function formatQty(n) { const rounded = Math.round((Number(n) || 0) * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, ''); } function unitLabel(u) { return u === 'szt' ? 'szt.' : String(u); } function stepForUnit(unit) { return (unit === 'szt.' || unit === 'szt') ? 1 : 10; } const SL_SHADOW = '0 5px 10px rgba(0,0,0,0.16), 0 14px 22px rgba(0,0,0,0.24), 0 22px 34px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.04)'; 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 DAY_ABBR = ['Nd', 'Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'Sb']; const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.']; const WEEKDAY_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd']; const MONTHS_LONG = ['Styczeń','Luty','Marzec','Kwiecień','Maj','Czerwiec','Lipiec','Sierpień','Wrzesień','Październik','Listopad','Grudzień']; const MONTHS_SHORT = ['sty','lut','mar','kwi','maj','cze','lip','sie','wrz','paź','lis','gru']; const CALENDAR_DIM_TEXT = 'rgb(var(--text-faint-rgb))'; const CALENDAR_DIM_OPACITY = '0.58'; /* ── module state ── */ let boughtSectionOpen = false; let expandedIngredientId = null; let expandedAmount = 0; let calendarOpen = false; let calendarMonth = startOfMonth(new Date()); /* ── day helpers ── */ function todayKey() { return dateKey(new Date()); } function getDefaultSelectedDays() { const today = startOfDay(new Date()); return Array.from({ length: 7 }, (_, i) => dateKey(addDays(today, i))); } function formatRangePill(selectedDays) { if (selectedDays.length === 0) return 'Wybierz dni'; const sorted = [...selectedDays].sort(); const fmt = (dk) => { const d = new Date(dk + 'T00:00:00'); return `${DAY_NAMES_SHORT[d.getDay()]} ${d.getDate()} ${MONTHS_SHORT[d.getMonth()]}`; }; if (sorted.length === 1) return fmt(sorted[0]); return `${fmt(sorted[0])} – ${fmt(sorted[sorted.length - 1])}`; } /* ── computed data ── */ function computeActiveItems() { const selectedDays = getSelectedDays(); if (selectedDays.length === 0) return []; const plans = loadPlans(); const needLines = aggregateSelectedDaysIngredientNeed(plans, selectedDays); return computeShortfalls(needLines, loadPantry()); } function getSessionLog() { return loadShoppingSession().sessionLog; } function groupByCategory(items) { const groups = new Map(); for (const item of items) { const cat = item.category || 'inne'; if (!groups.has(cat)) groups.set(cat, []); groups.get(cat).push(item); } return CATEGORY_ORDER .filter((cat) => groups.has(cat)) .map((cat) => ({ cat, items: groups.get(cat) })); } /* ══════════════════════ HTML SHELL ══════════════════════ */ export function getShoppingListHTML() { const weekdayHeader = WEEKDAY_SHORT .map((d) => `
${d}
`) .join(''); return ` `; } /* ══════════════════════ CALENDAR ══════════════════════ */ function getDayRange(startDk, endDk) { const a = new Date(startDk + 'T00:00:00'); const b = new Date(endDk + 'T00:00:00'); const [from, to] = a <= b ? [a, b] : [b, a]; const days = []; for (let d = new Date(from); d <= to; d = addDays(d, 1)) days.push(dateKey(d)); return days; } function renderCalendarGrid(previewDays = null) { const grid = document.getElementById('sl-cal-grid'); const monthLabel = document.getElementById('sl-cal-month-label'); if (!grid) return; if (monthLabel) { monthLabel.textContent = `${MONTHS_LONG[calendarMonth.getMonth()]} ${calendarMonth.getFullYear()}`; } const selected = previewDays ?? new Set(getSelectedDays()); const today = todayKey(); const first = startOfMonth(calendarMonth); const gridStart = startOfWeekMonday(first); const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i)); while (cells.length > 35 && cells.slice(-7).every((d) => d.getMonth() !== first.getMonth())) { cells.splice(-7); } grid.innerHTML = cells.map((day) => { const dk = dateKey(day); const inMonth = day.getMonth() === first.getMonth(); const isSel = selected.has(dk); const isToday = dk === today; let bg, borderColor, color, opacity, borderClass; if (isSel) { bg = 'rgb(var(--card-rgb))'; borderColor = 'rgb(var(--border-input-rgb))'; color = 'rgb(var(--text-emphasis-rgb))'; opacity = '1'; borderClass = 'border'; } else if (!inMonth) { bg = 'transparent'; borderColor = 'transparent'; color = CALENDAR_DIM_TEXT; opacity = CALENDAR_DIM_OPACITY; borderClass = 'border-0'; } else { bg = 'rgb(var(--app-bg-rgb))'; borderColor = 'rgb(var(--card-raised-rgb))'; color = 'rgb(var(--text-body-soft-rgb))'; opacity = '1'; borderClass = 'border'; } const dot = isToday && !isSel ? `` : ''; return ``; }).join(''); } function bindCalendarEvents() { const grid = document.getElementById('sl-cal-grid'); if (!grid) return; let dragStartDk = null; let dragCurrentDk = null; let dragging = false; grid.addEventListener('pointerdown', (e) => { e.stopPropagation(); const btn = e.target.closest('.sl-cal-day'); if (!btn) return; dragStartDk = btn.dataset.dk; dragCurrentDk = btn.dataset.dk; dragging = true; grid.setPointerCapture(e.pointerId); renderCalendarGrid(new Set([dragStartDk])); }); grid.addEventListener('pointermove', (e) => { if (!dragging || !dragStartDk) return; e.preventDefault(); const el = document.elementFromPoint(e.clientX, e.clientY); const btn = el?.closest('.sl-cal-day'); if (!btn?.dataset.dk || btn.dataset.dk === dragCurrentDk) return; dragCurrentDk = btn.dataset.dk; renderCalendarGrid(new Set(getDayRange(dragStartDk, dragCurrentDk))); }); grid.addEventListener('pointerup', (e) => { if (!dragging) return; dragging = false; const range = getDayRange(dragStartDk, dragCurrentDk); dragStartDk = null; dragCurrentDk = null; setSelectedDays(range); expandedIngredientId = null; updatePillLabel(); renderCalendarGrid(); renderAll(); }); grid.addEventListener('pointercancel', () => { dragging = false; dragStartDk = null; dragCurrentDk = null; renderCalendarGrid(); }); // Prevent bubbled click events (generated after pointerup) from closing the popup grid.addEventListener('click', (e) => e.stopPropagation()); } function updatePillLabel() { const el = document.getElementById('sl-range-label'); if (el) el.textContent = formatRangePill(getSelectedDays()); } function openCalendar() { calendarOpen = true; calendarMonth = startOfMonth(new Date()); const popup = document.getElementById('sl-calendar-popup'); const chevron = document.getElementById('sl-range-chevron'); const pill = document.getElementById('sl-range-pill'); if (popup) { popup.style.pointerEvents = 'auto'; popup.style.opacity = '1'; popup.style.transform = 'translateY(0) scale(1)'; } if (chevron) chevron.style.transform = 'rotate(180deg)'; if (pill) { pill.style.background = 'rgb(var(--sunken-rgb))'; pill.style.borderColor = 'rgb(var(--border-input-rgb))'; } renderCalendarGrid(); } function closeCalendar() { calendarOpen = false; const popup = document.getElementById('sl-calendar-popup'); const chevron = document.getElementById('sl-range-chevron'); const pill = document.getElementById('sl-range-pill'); if (popup) { popup.style.pointerEvents = 'none'; popup.style.opacity = '0'; popup.style.transform = 'translateY(-6px) scale(0.98)'; } if (chevron) chevron.style.transform = ''; if (pill) { pill.style.background = 'rgb(var(--card-rgb))'; pill.style.borderColor = 'rgb(var(--border-card-rgb))'; } } function toggleCalendar() { calendarOpen ? closeCalendar() : openCalendar(); } /* ══════════════════════ ITEM ROWS ══════════════════════ */ function activeItemHtml(item) { const def = INGREDIENTS[item.ingredientId]; const icon = CATEGORY_ICONS[def?.category] || 'fa-jar'; const image = def?.image; const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover'; const mediaHtml = image ? `` : `
`; const isExpanded = expandedIngredientId === item.ingredientId; const step = stepForUnit(item.unit); const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step); return `
${mediaHtml}
${esc(item.name)}
${esc(formatQty(item.shortfall))} ${esc(unitLabel(item.unit))}
${esc(formatQty(stepAmt))} ${esc(unitLabel(item.unit))}
`; } function boughtItemHtml(entry) { const def = INGREDIENTS[entry.ingredientId]; const icon = CATEGORY_ICONS[def?.category] || 'fa-jar'; const image = def?.image; const mediaFit = image?.endsWith('.svg') ? 'object-contain' : 'object-cover'; const mediaHtml = image ? `` : `
`; return `
${mediaHtml}
${esc(entry.name)}
${esc(formatQty(entry.addedAmount))} ${esc(unitLabel(entry.unit))}
`; } /* ══════════════════════ SWIPE ══════════════════════ */ function attachSwipe(container, opts) { const inner = container.querySelector('.sl-swipe-inner'); if (!inner) return; let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false; container.addEventListener('pointerdown', (e) => { startX = e.clientX; startY = e.clientY; dx = 0; tracking = true; decided = false; goingH = false; inner.style.transition = 'none'; container.setPointerCapture(e.pointerId); }); container.addEventListener('pointermove', (e) => { if (!tracking) return; const ddx = e.clientX - startX; const ddy = e.clientY - startY; if (!decided) { if (Math.abs(ddx) < 6 && Math.abs(ddy) < 6) return; decided = true; goingH = Math.abs(ddx) > Math.abs(ddy); } if (!goingH) { tracking = false; inner.style.transform = ''; return; } dx = ddx; if (dx > 0 && opts.onRight) inner.style.transform = `translateX(${Math.min(dx, 90)}px)`; else if (dx < 0 && opts.onLeft) inner.style.transform = `translateX(${Math.max(dx, -90)}px)`; }); const finish = () => { if (!tracking) return; tracking = false; inner.style.transition = 'transform 0.2s ease'; const thr = 65; if (dx > thr && opts.onRight) { inner.style.transform = 'translateX(120%)'; setTimeout(opts.onRight, 180); } else if (dx < -thr && opts.onLeft) { inner.style.transform = 'translateX(-120%)'; setTimeout(opts.onLeft, 180); } else { inner.style.transform = ''; } dx = 0; }; container.addEventListener('pointerup', finish); container.addEventListener('pointercancel', () => { tracking = false; inner.style.transition = 'transform 0.2s ease'; inner.style.transform = ''; dx = 0; }); } /* ══════════════════════ EXPAND / STEPPER ══════════════════════ */ function toggleExpand(ingredientId, unit, shortfall) { if (expandedIngredientId === ingredientId) { expandedIngredientId = null; } else { expandedIngredientId = ingredientId; const step = stepForUnit(unit); expandedAmount = Math.max(step, Math.round(shortfall / step) * step); } renderBoard(); } function updateExpandedAmountDisplay() { const el = document.querySelector(`[data-step-for="${expandedIngredientId}"] .sl-exp-amount`); if (el) el.textContent = formatQty(expandedAmount); } /* ══════════════════════ BUY / UNDO ══════════════════════ */ function buyItem(ingredientId, unit, amount) { const def = INGREDIENTS[ingredientId]; if (!def) return; addAmountToPantry(ingredientId, undefined, amount); addSessionLogEntry({ ingredientId, name: def.name, addedAmount: amount, unit, category: def.category || 'inne' }); expandedIngredientId = null; renderAll(); if (typeof window.refreshPantry === 'function') window.refreshPantry(); } function undoBoughtEntry(entryId) { const log = getSessionLog(); const entry = log.find((e) => e.id === entryId); if (!entry) return; subtractFromPantry(entry.ingredientId, entry.productId, entry.addedAmount); removeSessionLogEntry(entryId); renderAll(); if (typeof window.refreshPantry === 'function') window.refreshPantry(); showAppToast('Cofnięto zakup'); } /* ══════════════════════ RENDERING ══════════════════════ */ function renderProgress(activeItems, sessionLog) { const touchedIds = new Set(sessionLog.map((e) => e.ingredientId)); const bought = touchedIds.size; const untouched = activeItems.filter((i) => !touchedIds.has(i.ingredientId)).length; const total = bought + untouched; const barEl = document.getElementById('sl-progress-bar'); if (barEl) barEl.style.width = total > 0 ? `${Math.round((bought / total) * 100)}%` : '0%'; } function renderBoard() { const root = document.getElementById('sl-board'); if (!root) return; const selectedDays = getSelectedDays(); if (selectedDays.length === 0) { root.innerHTML = `

Wybierz dni

Otwórz kalendarz i zaznacz dni, na które chcesz zrobić zakupy.

`; return; } const activeItems = computeActiveItems(); if (activeItems.length === 0) { root.innerHTML = `

Wszystko masz

Spiżarnia pokrywa zapotrzebowanie na wybrane dni.

`; return; } const groups = groupByCategory(activeItems); root.innerHTML = groups.map(({ cat, items: catItems }) => { const icon = CATEGORY_ICONS[cat] || 'fa-jar'; return `

${esc(categoryLabel(cat))}

${catItems.length}
${catItems.map((item) => activeItemHtml(item)).join('')}
`; }).join(''); bindActiveRowEvents(root, activeItems); } function renderBought() { const sessionLog = getSessionLog(); const section = document.getElementById('sl-bought-section'); const countEl = document.getElementById('sl-bought-count'); const list = document.getElementById('sl-bought-list'); if (!section || !list || !countEl) return; if (sessionLog.length === 0) { section.classList.add('hidden'); return; } section.classList.remove('hidden'); countEl.textContent = String(sessionLog.length); const chevron = document.getElementById('sl-bought-chevron'); if (chevron) chevron.style.transform = boughtSectionOpen ? 'rotate(90deg)' : ''; if (!boughtSectionOpen) { list.classList.add('hidden'); return; } list.classList.remove('hidden'); list.innerHTML = [...sessionLog].reverse().map((e) => boughtItemHtml(e)).join(''); list.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => { const entryId = wrap.querySelector('.sl-swipe-inner')?.dataset.entryId; if (!entryId) return; attachSwipe(wrap, { onLeft: () => undoBoughtEntry(entryId) }); }); } function renderAll() { const activeItems = computeActiveItems(); const sessionLog = getSessionLog(); renderProgress(activeItems, sessionLog); renderBoard(); renderBought(); } /* ══════════════════════ ACTIVE ROW EVENTS ══════════════════════ */ function bindActiveRowEvents(root, activeItems) { root.querySelectorAll('.sl-swipe-wrap').forEach((wrap) => { const inner = wrap.querySelector('.sl-swipe-inner'); const ingredientId = inner?.dataset.id; const unit = inner?.dataset.unit; const shortfall = parseFloat(inner?.dataset.shortfall || '0'); if (!ingredientId) return; const item = activeItems.find((i) => i.ingredientId === ingredientId); attachSwipe(wrap, { onRight: item ? () => buyItem(item.ingredientId, item.unit, item.shortfall) : undefined }); inner.querySelector('.sl-item-main')?.addEventListener('click', () => toggleExpand(ingredientId, unit, shortfall)); inner.querySelector('.sl-step-minus')?.addEventListener('click', (e) => { e.stopPropagation(); const step = stepForUnit(unit); expandedAmount = Math.max(step, expandedAmount - step); updateExpandedAmountDisplay(); }); inner.querySelector('.sl-step-plus')?.addEventListener('click', (e) => { e.stopPropagation(); expandedAmount += stepForUnit(unit); updateExpandedAmountDisplay(); }); inner.querySelector('.sl-step-confirm')?.addEventListener('click', (e) => { e.stopPropagation(); if (expandedAmount > 0) buyItem(ingredientId, unit, expandedAmount); }); }); } /* ══════════════════════ PUBLIC API ══════════════════════ */ export function refreshShoppingList() { updatePillLabel(); renderAll(); } export function setupShoppingList() { if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays()); updatePillLabel(); renderAll(); bindCalendarEvents(); document.getElementById('sl-range-pill')?.addEventListener('click', (e) => { e.stopPropagation(); toggleCalendar(); }); document.addEventListener('click', (e) => { if (!calendarOpen) return; const popup = document.getElementById('sl-calendar-popup'); const pill = document.getElementById('sl-range-pill'); if (popup && !popup.contains(e.target) && pill && !pill.contains(e.target)) { closeCalendar(); } }); document.getElementById('sl-cal-prev')?.addEventListener('click', (e) => { e.stopPropagation(); calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1)); renderCalendarGrid(); }); document.getElementById('sl-cal-next')?.addEventListener('click', (e) => { e.stopPropagation(); calendarMonth = startOfMonth(new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1)); renderCalendarGrid(); }); document.getElementById('sl-clear-session')?.addEventListener('click', () => { clearSessionLog(); renderAll(); }); document.getElementById('sl-bought-toggle')?.addEventListener('click', () => { boughtSectionOpen = !boughtSectionOpen; renderBought(); }); window.refreshShoppingList = refreshShoppingList; }