Redesign shopping list
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m19s
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m19s
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'}">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);">
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user