Redesign shopping list
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m19s

This commit is contained in:
2026-04-17 23:34:53 +02:00
parent a90e8ba9d2
commit 8e48ebdd95
7 changed files with 793 additions and 226 deletions

View File

@@ -1,19 +1,20 @@
import { INGREDIENTS, CATEGORY_LABELS } from '../data/catalog.js?v=9';
import { INGREDIENTS } from '../data/catalog.js?v=9';
import {
KITCHEN_LIST_ID,
loadShoppingState,
saveShoppingState,
addOrMergeShoppingLines,
removeItemFromList,
clearCheckedInList,
loadPantry,
savePantry,
computeShortfalls,
categoryLabel,
addAmountToPantry,
subtractFromPantry,
getSelectedDays,
setSelectedDays,
addSessionLogEntry,
removeSessionLogEntry,
clearSessionLog,
loadShoppingSession,
} from '../services/pantryShopping.js?v=2';
import { aggregateWeekIngredientNeed } from '../services/planIngredients.js?v=2';
import { loadPlans } from '../services/planStore.js?v=2';
import { startOfWeekMonday } from '../services/dateUtils.js';
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 ── */
@@ -22,15 +23,21 @@ function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function unitLabel(u) {
return u === 'szt' ? 'szt.' : u;
}
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',
@@ -41,46 +48,55 @@ const CATEGORY_ICONS = {
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'];
/* ══════════════════════ HTML SHELL ══════════════════════ */
/* ── module state ── */
let boughtSectionOpen = false;
let expandedIngredientId = null;
let expandedAmount = 0;
let calendarOpen = false;
let calendarMonth = startOfMonth(new Date());
export function getShoppingListHTML() {
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
/* ── day helpers ── */
<!-- ── header ── -->
<div class="shrink-0 px-4 pt-5 pb-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-[18px] font-bold" style="color:#f2efe8;">Lista zakupów</h1>
<button type="button" id="sl-clear-checked" class="text-[11px] font-semibold px-3 py-1.5 rounded-full transition-colors" style="background:#393937; color:#9b978f; border:1px solid #444442;" aria-label="Usuń kupione">
Wyczyść kupione
</button>
</div>
<button type="button" id="sl-generate" class="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[13px] font-semibold transition-colors active:scale-[0.98]" style="background:#393937; color:#ddd6ca; border:1px solid #444442;">
<i class="fas fa-wand-magic-sparkles text-[12px]" style="color:rgb(var(--accent-rgb));"></i>
Generuj braki z planera
</button>
</div>
function todayKey() { return dateKey(new Date()); }
<!-- ── scrollable list ── -->
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-2 pb-24" style="background:#2d2e2b !important;">
<div id="sl-board"></div>
</div>
</div>`;
function getDefaultSelectedDays() {
const today = startOfDay(new Date());
return Array.from({ length: 7 }, (_, i) => dateKey(addDays(today, i)));
}
/* ══════════════════════ RENDERING ══════════════════════ */
function getKitchenItems() {
const state = loadShoppingState();
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen');
return list ? /** @type {import('../services/pantryShopping.js').KitchenShoppingItem[]} */ (list.items) : [];
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])}`;
}
function groupItemsByCategory(items) {
/** @type {Map<string, typeof items>} */
/* ── 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';
@@ -92,195 +108,591 @@ function groupItemsByCategory(items) {
.map((cat) => ({ cat, items: groups.get(cat) }));
}
function itemRowHtml(item) {
const def = INGREDIENTS[item.ingredientId];
const icon = def ? (CATEGORY_ICONS[def.category] || 'fa-jar') : 'fa-jar';
const image = def?.image;
const checked = item.checked;
/* ══════════════════════ HTML SHELL ══════════════════════ */
const mediaFit = image && image.endsWith('.svg') ? 'object-contain' : 'object-cover';
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 = '#5a5752'; opacity = '0.38'; 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-row flex items-center gap-3 py-2.5 px-3 rounded-xl mb-1.5 transition-all duration-200 ${checked ? 'sl-checked' : ''}" style="background:${checked ? '#2f2f2d' : '#393937'}; border:1px solid ${checked ? '#3a3a38' : '#444442'};" data-id="${esc(item.id)}">
<button type="button" class="sl-check shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors" style="background:${checked ? 'rgb(var(--success-rgb))' : 'transparent'}; border:2px solid ${checked ? 'rgb(var(--success-rgb))' : '#6d6c67'};" aria-label="${checked ? 'Oznacz jako niekupione' : 'Oznacz jako kupione'}">
${checked ? '<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>' : ''}
</button>
${mediaHtml}
<div class="flex-1 min-w-0 ${checked ? 'opacity-40' : ''}">
<span class="block text-[13px] font-medium leading-tight truncate ${checked ? 'line-through' : ''}" style="color:#ddd6ca;">${esc(item.name)}</span>
<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>
<span class="text-[13px] font-semibold tabular-nums shrink-0 ${checked ? 'opacity-40 line-through' : ''}" style="color:#b7ada1;">${esc(formatQty(item.amount))} ${esc(unitLabel(item.unit))}</span>
<button type="button" class="sl-remove shrink-0 w-7 h-7 rounded-full flex items-center justify-center transition-colors" style="background:transparent; color:#6d6c67;" aria-label="Usuń">
<i class="fas fa-xmark text-xs"></i>
</button>
</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 items = getKitchenItems();
if (items.length === 0) {
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-cart-shopping text-xl" style="color:#6d6c67;"></i>
<i class="fas fa-calendar-days text-xl" style="color:#6d6c67;"></i>
</div>
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Lista jest pusta</p>
<p class="text-[12px] max-w-[14rem]" style="color:#9b978f;">Kliknij „Generuj braki z planera" aby dodać składniki na bieżący tydzień.</p>
<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 groups = groupItemsByCategory(items);
const html = groups.map(({ cat, items: catItems }) => {
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';
const uncheckedCount = catItems.filter((i) => !i.checked).length;
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;">${uncheckedCount}/${catItems.length}</span>
<span class="text-[10px]" style="color:#6d6c67;">${catItems.length}</span>
</div>
${catItems.map((item) => itemRowHtml(item)).join('')}
${catItems.map((item) => activeItemHtml(item, sessionIds.has(item.ingredientId))).join('')}
</section>`;
}).join('');
root.innerHTML = html;
bindRowEvents(root);
bindActiveRowEvents(root, activeItems);
}
/* ══════════════════════ EVENTS ══════════════════════ */
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;
function bindRowEvents(root) {
root.querySelectorAll('.sl-row').forEach((row) => {
const id = row.dataset.id;
if (sessionLog.length === 0) { section.classList.add('hidden'); return; }
row.querySelector('.sl-check')?.addEventListener('click', () => {
handleToggle(id);
});
section.classList.remove('hidden');
countEl.textContent = String(sessionLog.length);
row.querySelector('.sl-remove')?.addEventListener('click', () => {
removeItemFromList(KITCHEN_LIST_ID, id);
renderBoard();
updateBadge();
});
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 handleToggle(itemId) {
const state = loadShoppingState();
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID);
if (!list) return;
const item = list.items.find((i) => i.id === itemId);
if (!item) return;
const wasChecked = item.checked;
item.checked = !wasChecked;
saveShoppingState(state);
if (!wasChecked) {
// Just checked → apply to pantry
applyItemToPantry(item);
}
function renderAll() {
const activeItems = computeActiveItems();
const sessionLog = getSessionLog();
renderProgress(activeItems, sessionLog);
renderBoard();
updateBadge();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
renderBought();
}
function applyItemToPantry(item) {
const def = INGREDIENTS[item.ingredientId];
if (!def) return;
/* ══════════════════════ ACTIVE ROW EVENTS ══════════════════════ */
const pantry = loadPantry();
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;
if (item.productId) {
let val = pantry[item.ingredientId];
if (!val || typeof val === 'number') {
val = { items: [], _total: 0 };
pantry[item.ingredientId] = val;
}
const idx = val.items.findIndex((i) => i.productId === item.productId);
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + item.amount) * 1000) / 1000;
else val.items.push({ productId: item.productId, qty: Math.round(item.amount * 1000) / 1000 });
val._total = Math.round(val.items.reduce((s, i) => s + i.qty, 0) * 1000) / 1000;
} else {
const cur = typeof pantry[item.ingredientId] === 'number' ? pantry[item.ingredientId] : 0;
pantry[item.ingredientId] = Math.round((cur + item.amount) * 1000) / 1000;
}
const item = activeItems.find((i) => i.ingredientId === ingredientId);
attachSwipe(wrap, { onRight: item ? () => buyItem(item.ingredientId, item.unit, item.shortfall) : undefined });
savePantry(pantry);
}
inner.querySelector('.sl-item-main')?.addEventListener('click', () => toggleExpand(ingredientId, unit, shortfall));
function handleGenerate() {
const plans = loadPlans();
const weekStart = startOfWeekMonday(new Date());
const needLines = aggregateWeekIngredientNeed(plans, weekStart);
const pantry = loadPantry();
const shortfalls = computeShortfalls(needLines, pantry);
inner.querySelector('.sl-step-minus')?.addEventListener('click', (e) => {
e.stopPropagation();
const step = stepForUnit(unit);
expandedAmount = Math.max(step, expandedAmount - step);
updateExpandedAmountDisplay();
});
if (shortfalls.length === 0) {
showAppToast('Wszystko masz w spiżarni!');
return;
}
inner.querySelector('.sl-step-plus')?.addEventListener('click', (e) => {
e.stopPropagation();
expandedAmount += stepForUnit(unit);
updateExpandedAmountDisplay();
});
const lines = shortfalls.map((s) => ({
ingredientId: s.ingredientId,
amount: s.shortfall,
unit: s.unit,
name: s.name,
category: s.category,
sourceNote: 'Z planera',
}));
addOrMergeShoppingLines(lines, KITCHEN_LIST_ID);
renderBoard();
updateBadge();
showAppToast(`Dodano ${shortfalls.length} pozycji z planera`);
}
function handleClearChecked() {
clearCheckedInList(KITCHEN_LIST_ID);
renderBoard();
updateBadge();
}
/* ══════════════════════ BADGE ══════════════════════ */
function updateBadge() {
const items = getKitchenItems();
const uncheckedCount = items.filter((i) => !i.checked).length;
const badge = document.getElementById('nav-shopping-badge');
if (!badge) return;
if (uncheckedCount > 0) {
badge.textContent = String(uncheckedCount > 99 ? '99+' : uncheckedCount);
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
inner.querySelector('.sl-step-confirm')?.addEventListener('click', (e) => {
e.stopPropagation();
if (expandedAmount > 0) buyItem(ingredientId, unit, expandedAmount);
});
});
}
/* ══════════════════════ PUBLIC API ══════════════════════ */
export function refreshShoppingList() {
renderBoard();
updateBadge();
updatePillLabel();
renderAll();
}
export function setupShoppingList() {
renderBoard();
updateBadge();
if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays());
document.getElementById('sl-generate')?.addEventListener('click', handleGenerate);
document.getElementById('sl-clear-checked')?.addEventListener('click', handleClearChecked);
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;
}