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,5 +1,5 @@
import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=9'; import { INGREDIENTS, CATEGORY_LABELS, PRODUCTS, ingredientHasProducts } from '../data/catalog.js?v=9';
import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY } from '../storageKeys.js'; import { PANTRY_STORAGE_KEY, PANTRY_STORAGE_KEY_V2, SHOPPING_STORAGE_KEY, SHOPPING_SESSION_KEY } from '../storageKeys.js';
export const KITCHEN_LIST_ID = 'kitchen'; export const KITCHEN_LIST_ID = 'kitchen';
export const MISC_LIST_ID = 'misc'; export const MISC_LIST_ID = 'misc';
@@ -535,6 +535,148 @@ export function setPantryProductQty(ingredientId, productId, qty) {
return pantry; return pantry;
} }
/**
* Dodaj delta do spiżarni (używane przy zakupie ze listy).
* @param {string} ingredientId
* @param {string|undefined} productId
* @param {number} amount
*/
export function addAmountToPantry(ingredientId, productId, amount) {
const pantry = loadPantry();
const rounded = Math.round(amount * 1000) / 1000;
if (productId && PRODUCTS[productId]) {
let val = pantry[ingredientId];
if (!val || typeof val === 'number') {
val = { items: [], _total: 0 };
pantry[ingredientId] = val;
}
const idx = val.items.findIndex((i) => i.productId === productId);
if (idx >= 0) val.items[idx].qty = Math.round((val.items[idx].qty + rounded) * 1000) / 1000;
else val.items.push({ productId, qty: rounded });
recalcTotal(val);
} else {
const cur = typeof pantry[ingredientId] === 'number' ? pantry[ingredientId] : 0;
pantry[ingredientId] = Math.round((cur + rounded) * 1000) / 1000;
}
savePantry(pantry);
return pantry;
}
/**
* Odejmij delta ze spiżarni (undo zakupu).
* @param {string} ingredientId
* @param {string|undefined} productId
* @param {number} amount
*/
export function subtractFromPantry(ingredientId, productId, amount) {
const pantry = loadPantry();
if (productId && PRODUCTS[productId]) {
const val = pantry[ingredientId];
if (!val || typeof val === 'number') return pantry;
const idx = val.items.findIndex((i) => i.productId === productId);
if (idx < 0) return pantry;
val.items[idx].qty = Math.max(0, Math.round((val.items[idx].qty - amount) * 1000) / 1000);
if (val.items[idx].qty <= 0) val.items.splice(idx, 1);
recalcTotal(val);
if (val._total <= 0) delete pantry[ingredientId];
} else {
const cur = typeof pantry[ingredientId] === 'number' ? pantry[ingredientId] : 0;
const next = Math.max(0, Math.round((cur - amount) * 1000) / 1000);
if (next <= 0) delete pantry[ingredientId];
else pantry[ingredientId] = next;
}
savePantry(pantry);
return pantry;
}
/* ══════════════════════════════════════════════════════════════════════
* Session zakupowy — selectedDays + log zakupów bieżącej sesji
* ══════════════════════════════════════════════════════════════════════ */
/**
* @typedef {{ id: string, ingredientId: string, productId?: string, name: string, addedAmount: number, unit: string, category: string, timestamp: number }} SessionLogEntry
* @typedef {{ selectedDays: string[], sessionLog: SessionLogEntry[] }} ShoppingSessionState
*/
function defaultShoppingSession() {
return { selectedDays: [], sessionLog: [] };
}
function normalizeShoppingSession(raw) {
if (!raw || typeof raw !== 'object') return defaultShoppingSession();
const selectedDays = Array.isArray(raw.selectedDays)
? raw.selectedDays.filter((d) => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d))
: [];
const sessionLog = Array.isArray(raw.sessionLog)
? raw.sessionLog.filter((e) => e && typeof e.ingredientId === 'string' && Number.isFinite(e.addedAmount))
: [];
return { selectedDays, sessionLog };
}
/** @returns {ShoppingSessionState} */
export function loadShoppingSession() {
try {
const raw = localStorage.getItem(SHOPPING_SESSION_KEY);
if (!raw) return defaultShoppingSession();
return normalizeShoppingSession(JSON.parse(raw));
} catch {
return defaultShoppingSession();
}
}
/** @param {ShoppingSessionState} s */
export function saveShoppingSession(s) {
try {
localStorage.setItem(SHOPPING_SESSION_KEY, JSON.stringify(s));
} catch { /* ignore */ }
}
/** @returns {string[]} */
export function getSelectedDays() {
return loadShoppingSession().selectedDays;
}
/** @param {string[]} days */
export function setSelectedDays(days) {
const s = loadShoppingSession();
s.selectedDays = days;
saveShoppingSession(s);
}
/**
* @param {{ ingredientId: string, productId?: string, name: string, addedAmount: number, unit: string, category: string }} entry
* @returns {string} new entry id
*/
export function addSessionLogEntry(entry) {
const s = loadShoppingSession();
const id = newId('sl');
s.sessionLog.push({
id,
ingredientId: entry.ingredientId,
productId: entry.productId || undefined,
name: entry.name,
addedAmount: Math.round(entry.addedAmount * 100) / 100,
unit: entry.unit,
category: entry.category,
timestamp: Date.now(),
});
saveShoppingSession(s);
return id;
}
/** @param {string} entryId */
export function removeSessionLogEntry(entryId) {
const s = loadShoppingSession();
s.sessionLog = s.sessionLog.filter((e) => e.id !== entryId);
saveShoppingSession(s);
}
export function clearSessionLog() {
const s = loadShoppingSession();
s.sessionLog = [];
saveShoppingSession(s);
}
/** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */ /** Kupione ze listy kuchennej → spiżarnia (zawsze ta lista, niezależnie od aktywnej zakładki). */
export function applyCheckedKitchenListToPantry() { export function applyCheckedKitchenListToPantry() {
const s = loadShoppingState(); const s = loadShoppingState();

View File

@@ -218,6 +218,35 @@ export function aggregateRangeIngredientNeed(plans, startDate, numDays) {
}); });
} }
/**
* Zapotrzebowanie składników dla konkretnych dni (tablica dateKey-ów).
* @param {Record<string, unknown>} plans
* @param {string[]} selectedDays — tablica dateKey-ów ('YYYY-MM-DD')
*/
export function aggregateSelectedDaysIngredientNeed(plans, selectedDays) {
if (!selectedDays || selectedDays.length === 0) return [];
const map = new Map();
for (const dk of selectedDays) {
const [y, m, d] = dk.split('-').map(Number);
const day = new Date(y, m - 1, d);
const dayPlan = getDayPlan(plans, day);
const lines = flattenDayIngredientLines(dayPlan);
for (const line of lines) {
const key = `${line.ingredientId}\t${line.unit}`;
const cur = map.get(key);
if (!cur) {
map.set(key, { ...line });
} else {
cur.amount = Math.round((cur.amount + line.amount) * 10) / 10;
}
}
}
return [...map.values()].sort((a, b) => {
const c = a.category.localeCompare(b.category);
return c !== 0 ? c : a.name.localeCompare(b.name, 'pl');
});
}
/** /**
* Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami. * Jedna grupa na porę dnia: nagłówek pory raz, potem bloki przepisów ze składnikami.
*/ */

View File

@@ -2,3 +2,4 @@ export const PLANS_STORAGE_KEY = 'recipe-planner-plans-v1';
export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1'; export const PANTRY_STORAGE_KEY = 'recipe-pantry-v1';
export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2'; export const PANTRY_STORAGE_KEY_V2 = 'recipe-pantry-v2';
export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1'; export const SHOPPING_STORAGE_KEY = 'recipe-shopping-v1';
export const SHOPPING_SESSION_KEY = 'recipe-shopping-session-v1';

View File

@@ -46,7 +46,6 @@ export function getBottomNavHTML() {
<button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab" aria-label="Zakupy"> <button type="button" data-tab="shopping" id="nav-shopping" class="nav-tab" aria-label="Zakupy">
<i class="fas fa-cart-shopping" aria-hidden="true"></i> <i class="fas fa-cart-shopping" aria-hidden="true"></i>
</button> </button>
<span id="nav-shopping-badge" class="hidden absolute -top-0.5 -right-0.5 min-w-[16px] h-4 rounded-full flex items-center justify-center text-[9px] font-bold leading-none px-1" style="background:rgb(var(--accent-rgb)); color:#1a1a1a;"></span>
</div> </div>
<div class="nav-slot"> <div class="nav-slot">
<button type="button" id="nav-theme-toggle" class="nav-action" aria-label="${isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw'}" title="${isDark ? 'Jasny motyw' : 'Ciemny motyw'}"> <button type="button" id="nav-theme-toggle" class="nav-action" aria-label="${isDark ? 'Włącz jasny motyw' : 'Włącz ciemny motyw'}" title="${isDark ? 'Jasny motyw' : 'Ciemny motyw'}">

View File

@@ -175,15 +175,17 @@ export function getPantryHTML() {
<!-- ── floating top bar ── --> <!-- ── floating top bar ── -->
<div id="pantry-topbar-outer" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;"> <div id="pantry-topbar-outer" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;">
<div class="pointer-events-auto relative z-[1] mx-auto" style="width:min(calc(100% - 0.5rem), 22.4rem);"> <div class="pointer-events-auto relative z-[1] w-full">
<div id="pantry-topbar" class="relative min-h-12"> <div id="pantry-topbar" class="relative min-h-12">
<div id="pantry-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);"> <div id="pantry-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);">
<h1 class="flex-1 min-w-0 truncate" style="margin:0;padding:0;color:#f2efe8;font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Zapasy</h1> <h1 class="flex-1 min-w-0 truncate" style="margin:0;padding:0;color:#f2efe8;font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Zapasy</h1>
<button type="button" id="pantry-horizon-compact" class="min-w-0 max-w-[12rem] h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all shrink" style="background:#393937 !important; border:1px solid #41423f !important; box-shadow:${SEARCH_SHELL_SHADOW} !important;"> <div id="pantry-horizon-wrap" class="relative shrink">
<button type="button" id="pantry-horizon-compact" class="min-w-0 max-w-[12rem] h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all" style="background:#393937 !important; border:1px solid #41423f !important; box-shadow:${SEARCH_SHELL_SHADOW} !important;">
<span id="pantry-horizon-compact-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span> <span id="pantry-horizon-compact-label" class="min-w-0 flex-1 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span>
<i class="fas fa-chevron-down text-[10px] shrink-0" style="color:#9b978f;"></i> <i id="pantry-horizon-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:#9b978f;"></i>
</button> </button>
</div>
<div id="pantry-filter-wrap" class="relative shrink-0"> <div id="pantry-filter-wrap" class="relative shrink-0">
<button type="button" id="pantry-filter-toggle" class="relative w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937; border:1px solid #41423f; box-shadow:${SEARCH_SHELL_SHADOW}; color:#ddd6ca;"> <button type="button" id="pantry-filter-toggle" class="relative w-11 h-11 rounded-full shrink-0 flex items-center justify-center transition-all duration-200" style="background:#393937; border:1px solid #41423f; box-shadow:${SEARCH_SHELL_SHADOW}; color:#ddd6ca;">
@@ -207,12 +209,6 @@ export function getPantryHTML() {
</button> </button>
</div> </div>
<div id="pantry-horizon-expanded" class="absolute inset-0 transition-all duration-200 pointer-events-none" style="opacity:0; transform:translateY(-2px) scale(0.98);">
<div id="pantry-horizon-wrap" class="relative">
<button type="button" id="pantry-horizon-toggle" class="w-full h-10 rounded-full flex items-center gap-1.5 px-2.5 transition-all" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important;">
<span id="pantry-horizon-label" class="flex-1 min-w-0 text-left text-[13px] font-normal truncate" style="color:#ddd6ca;"></span>
<i id="pantry-horizon-chevron" class="fas fa-chevron-down text-[10px] shrink-0 transition-transform duration-200" style="color:#9b978f;"></i>
</button>
<div id="pantry-calendar-popover" class="absolute left-0 right-0 top-full mt-2 rounded-[1.35rem] px-3 py-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-6px) scale(0.98);"> <div id="pantry-calendar-popover" class="absolute left-0 right-0 top-full mt-2 rounded-[1.35rem] px-3 py-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-6px) scale(0.98);">
${createCalendarTopbarHTML({ ${createCalendarTopbarHTML({
@@ -234,8 +230,6 @@ export function getPantryHTML() {
<p id="pantry-cal-selection" class="min-w-0 text-[11px] leading-snug" style="color:#9b978f;"></p> <p id="pantry-cal-selection" class="min-w-0 text-[11px] leading-snug" style="color:#9b978f;"></p>
</div> </div>
</div> </div>
</div>
</div>
<div id="pantry-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-2px) scale(0.98);"> <div id="pantry-search-shell" class="absolute inset-0 flex items-center gap-2 rounded-full px-3 transition-all duration-200 pointer-events-none" style="background:#23221e !important; border:1px solid #787876 !important; box-shadow:${SEARCH_SHELL_SHADOW} !important; opacity:0; transform:translateY(-2px) scale(0.98);">
<i class="fas fa-search text-[13px] shrink-0" style="color:#9b978f;"></i> <i class="fas fa-search text-[13px] shrink-0" style="color:#9b978f;"></i>
@@ -263,25 +257,23 @@ function syncHorizonUI() {
ensureValidHorizonDate(); ensureValidHorizonDate();
const defaultRow = document.getElementById('pantry-default-row'); const defaultRow = document.getElementById('pantry-default-row');
const horizonExpanded = document.getElementById('pantry-horizon-expanded');
const searchShell = document.getElementById('pantry-search-shell'); const searchShell = document.getElementById('pantry-search-shell');
const popover = document.getElementById('pantry-calendar-popover'); const popover = document.getElementById('pantry-calendar-popover');
const filterPopover = document.getElementById('pantry-filter-popover'); const filterPopover = document.getElementById('pantry-filter-popover');
const filterToggle = document.getElementById('pantry-filter-toggle'); const filterToggle = document.getElementById('pantry-filter-toggle');
const filterCount = document.getElementById('pantry-filter-count'); const filterCount = document.getElementById('pantry-filter-count');
const compactLabel = document.getElementById('pantry-horizon-compact-label'); const compactLabel = document.getElementById('pantry-horizon-compact-label');
const horizonLabel = document.getElementById('pantry-horizon-label'); const compactPill = document.getElementById('pantry-horizon-compact');
const chevron = document.getElementById('pantry-horizon-chevron'); const chevron = document.getElementById('pantry-horizon-chevron');
const selectionEl = document.getElementById('pantry-cal-selection'); const selectionEl = document.getElementById('pantry-cal-selection');
const prevBtn = document.getElementById('pantry-cal-prev'); const prevBtn = document.getElementById('pantry-cal-prev');
const todayBtn = document.getElementById('pantry-cal-today'); const todayBtn = document.getElementById('pantry-cal-today');
if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate); if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate);
if (horizonLabel) horizonLabel.textContent = formatHorizonLabel(horizonEndDate);
if (selectionEl) selectionEl.textContent = formatRangeSummary(horizonEndDate); if (selectionEl) selectionEl.textContent = formatRangeSummary(horizonEndDate);
const showCalendar = isCalendarOpen && !isSearchExpanded; const showCalendar = isCalendarOpen && !isSearchExpanded;
const showDefault = !isSearchExpanded && !isCalendarOpen; const showDefault = !isSearchExpanded;
const showFilter = isFilterOpen && showDefault; const showFilter = isFilterOpen && showDefault;
const activeFilterCount = getActivePantryFilterCount(); const activeFilterCount = getActivePantryFilterCount();
@@ -291,10 +283,9 @@ function syncHorizonUI() {
defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none'; defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none';
} }
if (horizonExpanded) { if (compactPill) {
horizonExpanded.style.opacity = showCalendar ? '1' : '0'; compactPill.style.setProperty('background', showCalendar ? '#23221e' : '#393937', 'important');
horizonExpanded.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)'; compactPill.style.setProperty('border-color', showCalendar ? '#787876' : '#41423f', 'important');
horizonExpanded.style.pointerEvents = showCalendar ? 'auto' : 'none';
} }
if (popover) { if (popover) {
@@ -770,14 +761,7 @@ export function setupPantry() {
// Horizon pill + calendar // Horizon pill + calendar
document.getElementById('pantry-horizon-compact')?.addEventListener('click', (event) => { document.getElementById('pantry-horizon-compact')?.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
openCalendar(); isCalendarOpen ? closeCalendar() : openCalendar();
});
document.getElementById('pantry-horizon-toggle')?.addEventListener('click', () => {
if (isCalendarOpen) {
closeCalendar();
return;
}
openCalendar();
}); });
document.getElementById('pantry-cal-prev')?.addEventListener('click', () => { document.getElementById('pantry-cal-prev')?.addEventListener('click', () => {
const prevMonth = addMonths(calendarMonthAnchor, -1); const prevMonth = addMonths(calendarMonthAnchor, -1);

View File

@@ -115,7 +115,7 @@ export function getRecipeListHTML() {
return ` return `
<div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;"> <div id="main-view" class="flex flex-col h-full absolute inset-0 bg-[#2d2e2b] z-10" style="background:#2d2e2b !important;">
<div id="recipe-top-bar" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;"> <div id="recipe-top-bar" class="pointer-events-none absolute inset-x-0 top-0 z-[12] px-4 pt-4 pb-4" style="background:#2d2e2b !important; border:none !important;">
<div class="pointer-events-auto relative z-[1] mx-auto" style="width:min(calc(100% - 0.5rem), 22.4rem);"> <div class="pointer-events-auto relative z-[1] w-full">
<div id="recipe-topbar" class="relative min-h-12"> <div id="recipe-topbar" class="relative min-h-12">
<div id="recipe-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);"> <div id="recipe-default-row" class="flex min-h-12 items-center gap-2 transition-all duration-200" style="opacity:1; transform:translateY(0) scale(1);">

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 { import {
KITCHEN_LIST_ID,
loadShoppingState,
saveShoppingState,
addOrMergeShoppingLines,
removeItemFromList,
clearCheckedInList,
loadPantry, loadPantry,
savePantry,
computeShortfalls, computeShortfalls,
categoryLabel, categoryLabel,
addAmountToPantry,
subtractFromPantry,
getSelectedDays,
setSelectedDays,
addSessionLogEntry,
removeSessionLogEntry,
clearSessionLog,
loadShoppingSession,
} from '../services/pantryShopping.js?v=2'; } from '../services/pantryShopping.js?v=2';
import { aggregateWeekIngredientNeed } from '../services/planIngredients.js?v=2'; import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2';
import { loadPlans } from '../services/planStore.js?v=2'; import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { startOfWeekMonday } from '../services/dateUtils.js'; import { addDays, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
import { showAppToast } from '../ui/toast.js'; import { showAppToast } from '../ui/toast.js';
/* ── helpers ── */ /* ── helpers ── */
@@ -22,15 +23,21 @@ function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 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) { function formatQty(n) {
const rounded = Math.round((Number(n) || 0) * 10) / 10; const rounded = Math.round((Number(n) || 0) * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, ''); 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 = { const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice', pieczywo: 'fa-bread-slice',
nabial: 'fa-cheese', nabial: 'fa-cheese',
@@ -41,46 +48,55 @@ const CATEGORY_ICONS = {
przyprawy: 'fa-leaf', przyprawy: 'fa-leaf',
inne: 'fa-jar', inne: 'fa-jar',
}; };
const CATEGORY_ORDER = ['pieczywo', 'nabial', 'mieso_ryby', 'warzywa', 'owoce', 'suche', 'przyprawy', 'inne']; 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() { /* ── day helpers ── */
return `
<div id="shopping-view" class="hidden flex flex-col h-full absolute inset-0 overflow-hidden z-10" style="background:#2d2e2b !important;">
<!-- ── header ── --> function todayKey() { return dateKey(new Date()); }
<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>
<!-- ── scrollable list ── --> function getDefaultSelectedDays() {
<div id="sl-scroll" class="flex-1 overflow-y-auto no-scrollbar px-4 pt-2 pb-24" style="background:#2d2e2b !important;"> const today = startOfDay(new Date());
<div id="sl-board"></div> return Array.from({ length: 7 }, (_, i) => dateKey(addDays(today, i)));
</div>
</div>`;
} }
/* ══════════════════════ RENDERING ══════════════════════ */ function formatRangePill(selectedDays) {
if (selectedDays.length === 0) return 'Wybierz dni';
function getKitchenItems() { const sorted = [...selectedDays].sort();
const state = loadShoppingState(); const fmt = (dk) => {
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID && l.type === 'kitchen'); const d = new Date(dk + 'T00:00:00');
return list ? /** @type {import('../services/pantryShopping.js').KitchenShoppingItem[]} */ (list.items) : []; 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) { /* ── computed data ── */
/** @type {Map<string, typeof items>} */
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(); const groups = new Map();
for (const item of items) { for (const item of items) {
const cat = item.category || 'inne'; const cat = item.category || 'inne';
@@ -92,195 +108,591 @@ function groupItemsByCategory(items) {
.map((cat) => ({ cat, items: groups.get(cat) })); .map((cat) => ({ cat, items: groups.get(cat) }));
} }
function itemRowHtml(item) { /* ══════════════════════ HTML SHELL ══════════════════════ */
const def = INGREDIENTS[item.ingredientId];
const icon = def ? (CATEGORY_ICONS[def.category] || 'fa-jar') : 'fa-jar';
const image = def?.image;
const checked = item.checked;
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 const mediaHtml = image
? `<img src="${esc(image)}" alt="" class="w-8 h-8 rounded-lg ${mediaFit} shrink-0">` ? `<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>`; : `<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 ` 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)}"> <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);">
<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'}"> <div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end">
${checked ? '<i class="fas fa-check text-[10px]" style="color:#1a1a1a;"></i>' : ''} <i class="fas fa-check text-white text-lg"></i>
</button> </div>
${mediaHtml} <div class="sl-swipe-inner rounded-xl" style="background:#393937; position:relative;"
<div class="flex-1 min-w-0 ${checked ? 'opacity-40' : ''}"> data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<span class="block text-[13px] font-medium leading-tight truncate ${checked ? 'line-through' : ''}" style="color:#ddd6ca;">${esc(item.name)}</span> <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>
<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>`; </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() { function renderBoard() {
const root = document.getElementById('sl-board'); const root = document.getElementById('sl-board');
if (!root) return; if (!root) return;
const items = getKitchenItems(); const selectedDays = getSelectedDays();
if (selectedDays.length === 0) {
if (items.length === 0) {
root.innerHTML = ` root.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center"> <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;"> <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> </div>
<p class="text-[14px] font-semibold mb-1" style="color:#ddd6ca;">Lista jest pusta</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;">Kliknij „Generuj braki z planera" aby dodać składniki na bieżący tydzień.</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>`; </div>`;
return; return;
} }
const groups = groupItemsByCategory(items); const activeItems = computeActiveItems();
const html = groups.map(({ cat, items: catItems }) => { 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 icon = CATEGORY_ICONS[cat] || 'fa-jar';
const uncheckedCount = catItems.filter((i) => !i.checked).length;
return ` return `
<section class="mb-4"> <section class="mb-4">
<div class="flex items-center gap-1.5 mb-2 px-1"> <div class="flex items-center gap-1.5 mb-2 px-1">
<i class="fas ${icon} text-[10px]" style="color:#9b978f;"></i> <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> <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> </div>
${catItems.map((item) => itemRowHtml(item)).join('')} ${catItems.map((item) => activeItemHtml(item, sessionIds.has(item.ingredientId))).join('')}
</section>`; </section>`;
}).join(''); }).join('');
root.innerHTML = html; bindActiveRowEvents(root, activeItems);
bindRowEvents(root);
} }
/* ══════════════════════ 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) { if (sessionLog.length === 0) { section.classList.add('hidden'); return; }
root.querySelectorAll('.sl-row').forEach((row) => {
const id = row.dataset.id;
row.querySelector('.sl-check')?.addEventListener('click', () => { section.classList.remove('hidden');
handleToggle(id); countEl.textContent = String(sessionLog.length);
});
row.querySelector('.sl-remove')?.addEventListener('click', () => { const chevron = document.getElementById('sl-bought-chevron');
removeItemFromList(KITCHEN_LIST_ID, id); if (chevron) chevron.style.transform = boughtSectionOpen ? 'rotate(90deg)' : '';
renderBoard();
updateBadge(); 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) { function renderAll() {
const state = loadShoppingState(); const activeItems = computeActiveItems();
const list = state.lists.find((l) => l.id === KITCHEN_LIST_ID); const sessionLog = getSessionLog();
if (!list) return; renderProgress(activeItems, sessionLog);
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);
}
renderBoard(); renderBoard();
updateBadge(); renderBought();
if (typeof window.refreshPantry === 'function') window.refreshPantry();
} }
function applyItemToPantry(item) { /* ══════════════════════ ACTIVE ROW EVENTS ══════════════════════ */
const def = INGREDIENTS[item.ingredientId];
if (!def) return;
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) { const item = activeItems.find((i) => i.ingredientId === ingredientId);
let val = pantry[item.ingredientId]; attachSwipe(wrap, { onRight: item ? () => buyItem(item.ingredientId, item.unit, item.shortfall) : undefined });
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;
}
savePantry(pantry); inner.querySelector('.sl-item-main')?.addEventListener('click', () => toggleExpand(ingredientId, unit, shortfall));
}
function handleGenerate() { inner.querySelector('.sl-step-minus')?.addEventListener('click', (e) => {
const plans = loadPlans(); e.stopPropagation();
const weekStart = startOfWeekMonday(new Date()); const step = stepForUnit(unit);
const needLines = aggregateWeekIngredientNeed(plans, weekStart); expandedAmount = Math.max(step, expandedAmount - step);
const pantry = loadPantry(); updateExpandedAmountDisplay();
const shortfalls = computeShortfalls(needLines, pantry); });
if (shortfalls.length === 0) { inner.querySelector('.sl-step-plus')?.addEventListener('click', (e) => {
showAppToast('Wszystko masz w spiżarni!'); e.stopPropagation();
return; expandedAmount += stepForUnit(unit);
} updateExpandedAmountDisplay();
});
const lines = shortfalls.map((s) => ({ inner.querySelector('.sl-step-confirm')?.addEventListener('click', (e) => {
ingredientId: s.ingredientId, e.stopPropagation();
amount: s.shortfall, if (expandedAmount > 0) buyItem(ingredientId, unit, expandedAmount);
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');
}
} }
/* ══════════════════════ PUBLIC API ══════════════════════ */ /* ══════════════════════ PUBLIC API ══════════════════════ */
export function refreshShoppingList() { export function refreshShoppingList() {
renderBoard(); updatePillLabel();
updateBadge(); renderAll();
} }
export function setupShoppingList() { export function setupShoppingList() {
renderBoard(); if (getSelectedDays().length === 0) setSelectedDays(getDefaultSelectedDays());
updateBadge();
document.getElementById('sl-generate')?.addEventListener('click', handleGenerate); updatePillLabel();
document.getElementById('sl-clear-checked')?.addEventListener('click', handleClearChecked); 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; window.refreshShoppingList = refreshShoppingList;
} }