Files
recipe-mockup/js/views/ShoppingList.js
ulfrxdev 59340e8afd
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m17s
Fix calendar color
2026-04-18 09:16:12 +02:00

701 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 = '#888278';
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) => `<div>${d}</div>`)
.join('');
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
<!-- ── header ── -->
<div class="shrink-0 px-4 pt-5 pb-0">
<!-- title row + pill (position:relative anchors the popup) -->
<div class="flex items-center gap-2 mb-3" style="position:relative;">
<h1 class="flex-1 text-[18px] font-bold" style="color:#f2efe8;">Lista zakupów</h1>
<button type="button" id="sl-range-pill" class="min-w-0 max-w-[10rem] h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all shrink" style="background:#393937; border:1px solid #41423f; box-shadow:${SL_SHADOW};">
<span id="sl-range-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span>
<i id="sl-range-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:#9b978f;"></i>
</button>
<!-- popup calendar (absolute, overlays content below) -->
<div id="sl-calendar-popup" style="position:absolute; top:calc(100% + 0.5rem); left:0; right:0; z-index:50; pointer-events:none; opacity:0; transform:translateY(-6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
<div class="rounded-[1.35rem] px-3 py-3" style="background:#23221e; border:1px solid #787876; box-shadow:${SL_SHADOW};">
<!-- month nav topbar -->
<div class="pb-3 flex items-center justify-end gap-3">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center gap-px rounded-full border px-0.5" style="background:#272622; border-color:#34312c;">
<button type="button" id="sl-cal-prev" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:#d7d2c8;">
<i class="fas fa-chevron-left text-[10px]"></i>
</button>
<span id="sl-cal-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-1.5 text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap" style="color:#d7d2c8;"></span>
<button type="button" id="sl-cal-next" class="shrink-0 w-7 h-full flex items-center justify-center rounded-full bg-transparent transition-colors" style="color:#d7d2c8;">
<i class="fas fa-chevron-right text-[10px]"></i>
</button>
</div>
</div>
<!-- weekday header -->
<div class="grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium uppercase tracking-wide mb-1 leading-none" style="color:#9b978f;">${weekdayHeader}</div>
<!-- day grid -->
<div id="sl-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
</div>
</div>
<!-- progress -->
<div id="sl-progress-wrap" class="mb-3">
<span class="text-[12px] font-semibold block mb-1.5" style="color:#9b978f;">Kupione <span id="sl-progress-text">0/0</span></span>
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 rounded-full overflow-hidden" style="background:#393937;">
<div id="sl-progress-bar" class="h-full rounded-full transition-all duration-300" style="background:rgb(var(--success-rgb)); width:0%;"></div>
</div>
<button type="button" id="sl-clear-session" class="flex items-center justify-center flex-shrink-0" style="color:#6b6965;" aria-label="Wyczyść kupione">
<i class="fas fa-trash-can" style="font-size:14px;" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<!-- ── scrollable list ── -->
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-3 pb-24" style="background:#2d2e2b !important;">
<div id="sl-board"></div>
<!-- bought section -->
<div id="sl-bought-section" class="mt-3 hidden">
<button type="button" id="sl-bought-toggle" class="flex items-center gap-2 w-full py-2 px-1 text-left" style="color:#9b978f;">
<i id="sl-bought-chevron" class="fas fa-chevron-right text-[10px] transition-transform duration-200"></i>
<span class="text-[11px] font-bold uppercase tracking-wider">Kupione (<span id="sl-bought-count">0</span>)</span>
</button>
<div id="sl-bought-list" class="hidden mt-1"></div>
</div>
</div>
</div>`;
}
/* ══════════════════════ 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 = '#393937'; borderColor = '#787876'; color = '#f2efe8'; opacity = '1'; borderClass = 'border';
} else if (!inMonth) {
bg = 'transparent'; borderColor = 'transparent'; color = CALENDAR_DIM_TEXT; opacity = CALENDAR_DIM_OPACITY; borderClass = 'border-0';
} else {
bg = '#272622'; borderColor = '#34312c'; color = '#d7d2c8'; opacity = '1'; borderClass = 'border';
}
const dot = isToday && !isSel
? `<span class="absolute left-1/2 -translate-x-1/2 w-1 h-1 rounded-full opacity-75" style="bottom:0.24rem; background:#7d7a74;"></span>`
: '';
return `<button type="button" class="sl-cal-day mx-auto flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass} text-xs font-medium leading-tight overflow-hidden"
style="background:${bg}; border-color:${borderColor}; color:${color}; opacity:${opacity}; touch-action:none;"
data-dk="${esc(dk)}">
<span class="relative flex h-full w-full flex-col items-center justify-center">
<span class="text-[13px] font-semibold leading-none${dot ? ' -translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${dot}
</span>
</button>`;
}).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 = '#23221e';
pill.style.borderColor = '#787876';
}
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 = '#393937';
pill.style.borderColor = '#41423f';
}
}
function toggleCalendar() {
calendarOpen ? closeCalendar() : openCalendar();
}
/* ══════════════════════ ITEM ROWS ══════════════════════ */
function activeItemHtml(item, isPartial) {
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
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></i></div>`;
const isExpanded = expandedIngredientId === item.ingredientId;
const step = stepForUnit(item.unit);
const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step);
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:#4a7c59; box-shadow:0 2px 8px rgba(0,0,0,0.28);">
<div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end">
<i class="fas fa-check text-white text-lg"></i>
</div>
<div class="sl-swipe-inner rounded-xl" style="background:#393937; position:relative;"
data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:#ddd6ca;">${esc(item.name)}</span>
${isPartial ? `<span class="text-[10px]" style="color:rgb(var(--accent-rgb));">część już w spiżarni</span>` : ''}
</div>
<span class="text-[13px] font-semibold tabular-nums shrink-0" style="color:#b7ada1;">${esc(formatQty(item.shortfall))} ${esc(unitLabel(item.unit))}</span>
<i class="fas fa-chevron-${isExpanded ? 'up' : 'down'} text-[9px] shrink-0" style="color:#6d6c67;"></i>
</div>
<div class="sl-step-row overflow-hidden" data-step-for="${esc(item.ingredientId)}"
style="max-height:${isExpanded ? '52px' : '0'}; transition: max-height 0.2s ease;">
<div class="flex items-center gap-3 px-3 pb-3">
<button type="button" class="sl-step-minus w-8 h-8 rounded-full flex items-center justify-center text-lg font-bold shrink-0 active:scale-95" style="background:#2f2f2d; color:#ddd6ca; border:1px solid #444442;"></button>
<span class="sl-exp-amount text-[15px] font-bold tabular-nums" style="color:#f2efe8; min-width:40px; text-align:center;">${esc(formatQty(stepAmt))}</span>
<span class="text-[12px]" style="color:#9b978f;">${esc(unitLabel(item.unit))}</span>
<button type="button" class="sl-step-plus w-8 h-8 rounded-full flex items-center justify-center text-lg font-bold shrink-0 active:scale-95" style="background:#2f2f2d; color:#ddd6ca; border:1px solid #444442;">+</button>
<div class="flex-1"></div>
<button type="button" class="sl-step-confirm px-3 py-1.5 rounded-lg text-[12px] font-semibold shrink-0 active:scale-95" style="background:rgb(var(--accent-rgb)); color:#1a1a1a;">Dodaj</button>
</div>
</div>
</div>
</div>`;
}
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
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0 opacity-50">`
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 opacity-50" style="background:#2f2f2d;"><i class="fas ${icon} text-xs" style="color:#8f8b84;"></i></div>`;
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:#7c4a4a; box-shadow:0 2px 8px rgba(0,0,0,0.28);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start">
<i class="fas fa-rotate-left text-white text-lg"></i>
</div>
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3 rounded-xl" style="background:#2f2f2d; position:relative;" data-entry-id="${esc(entry.id)}">
<div class="shrink-0 w-6 h-6 rounded-full flex items-center justify-center" style="background:rgb(var(--success-rgb));">
<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>
</div>
${mediaHtml}
<div class="flex-1 min-w-0 opacity-60">
<span class="block text-[13px] font-medium leading-tight truncate line-through" style="color:#ddd6ca;">${esc(entry.name)}</span>
</div>
<span class="text-[13px] tabular-nums shrink-0 opacity-60" style="color:#b7ada1;">${esc(formatQty(entry.addedAmount))} ${esc(unitLabel(entry.unit))}</span>
</div>
</div>`;
}
/* ══════════════════════ 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 activeIds = new Set(activeItems.map((i) => i.ingredientId));
const coveredIds = new Set(sessionLog.filter((e) => !activeIds.has(e.ingredientId)).map((e) => e.ingredientId));
const bought = coveredIds.size;
const total = activeItems.length + bought;
const textEl = document.getElementById('sl-progress-text');
const barEl = document.getElementById('sl-progress-bar');
if (textEl) textEl.textContent = `${bought}/${total}`;
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 = `
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:#393937;">
<i class="fas fa-calendar-days text-xl" style="color:#6d6c67;"></i>
</div>
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Wybierz dni</p>
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Otwórz kalendarz i zaznacz dni, na które chcesz zrobić zakupy.</p>
</div>`;
return;
}
const activeItems = computeActiveItems();
const sessionLog = getSessionLog();
const sessionIds = new Set(sessionLog.map((e) => e.ingredientId));
if (activeItems.length === 0) {
root.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-4" style="background:#393937;">
<i class="fas fa-check text-xl" style="color:rgb(var(--success-rgb));"></i>
</div>
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Wszystko masz</p>
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Spiżarnia pokrywa zapotrzebowanie na wybrane dni.</p>
</div>`;
return;
}
const groups = groupByCategory(activeItems);
root.innerHTML = groups.map(({ cat, items: catItems }) => {
const icon = CATEGORY_ICONS[cat] || 'fa-jar';
return `
<section class="mb-4">
<div class="flex items-center gap-1.5 mb-2 px-1">
<i class="fas ${icon} text-[10px]" style="color:#9b978f;"></i>
<p class="text-[10px] font-bold uppercase tracking-wider" style="color:#9b978f;">${esc(categoryLabel(cat))}</p>
<span class="text-[10px]" style="color:#6d6c67;">${catItems.length}</span>
</div>
${catItems.map((item) => activeItemHtml(item, sessionIds.has(item.ingredientId))).join('')}
</section>`;
}).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;
}