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 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;
}