import {
INGREDIENTS,
CATEGORY_LABELS,
} from '../data/catalog.js?v=8';
import { loadPantry, getPantryTotal } from '../services/pantryShopping.js?v=2';
import { loadPlans } from '../services/planStore.js?v=2';
import { addDays, addMonths, sameDay, sameMonth, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { aggregateRangeIngredientNeed, dayHasAnyMeal } from '../services/planIngredients.js?v=4';
import {
bindCalendarDayClicks,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
formatCalendarMonthYear,
renderCalendarGrid,
syncCalendarTodayButton,
} from '../ui/mealCalendar.js';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260410-107';
/* ── helpers ── */
function esc(s) {
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function unitLabel(u) {
return u === 'szt' ? 'szt.' : u;
}
function normalizeSearch(q) {
return String(q).trim().toLowerCase();
}
function formatQty(n) {
const rounded = Math.round((Number(n) || 0) * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
}
const CATEGORY_ICONS = {
pieczywo: 'fa-bread-slice',
nabial: 'fa-cheese',
mieso_ryby: 'fa-drumstick-bite',
warzywa: 'fa-carrot',
owoce: 'fa-apple-whole',
suche: 'fa-wheat-awn',
przyprawy: 'fa-leaf',
inne: 'fa-jar',
};
const DAY_NAMES_SHORT = ['nd.', 'pon.', 'wt.', 'śr.', 'czw.', 'pt.', 'sob.'];
const MONTHS_SHORT = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru'];
const SEARCH_SHELL_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 DEFAULT_HORIZON_DAYS = 7;
const PANTRY_CALENDAR_DAY_ATTR = 'data-pantry-calendar-day';
/* ── state ── */
let ingredientCard = null;
let horizonEndDate = addDays(startOfDay(new Date()), DEFAULT_HORIZON_DAYS - 1);
let isSearchExpanded = false;
let isCalendarOpen = false;
let calendarMonthAnchor = startOfMonth(horizonEndDate);
let pantryGlobalListenersBound = false;
/* ── date formatting ── */
function getToday() {
return startOfDay(new Date());
}
function ensureValidHorizonDate() {
const today = getToday();
if (!(horizonEndDate instanceof Date) || Number.isNaN(horizonEndDate.getTime()) || horizonEndDate.getTime() < today.getTime()) {
horizonEndDate = today;
}
if (!(calendarMonthAnchor instanceof Date) || Number.isNaN(calendarMonthAnchor.getTime())) {
calendarMonthAnchor = startOfMonth(horizonEndDate);
}
}
function getHorizonDays() {
ensureValidHorizonDate();
const diffMs = startOfDay(horizonEndDate).getTime() - getToday().getTime();
return Math.max(1, Math.floor(diffMs / 86400000) + 1);
}
function formatEndDate(date) {
return `${DAY_NAMES_SHORT[date.getDay()]} ${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`;
}
function formatHorizonLabel(date) {
return sameDay(date, getToday()) ? 'Do dziś' : `Do ${formatEndDate(date)}`;
}
function formatRangeSummary(date) {
return sameDay(date, getToday()) ? 'Zakres: tylko dziś' : `Zakres: dziś - ${formatEndDate(date)}`;
}
function formatDayContext(dayStrings) {
const dayNames = dayStrings.map((ds) => {
const d = new Date(ds + 'T00:00:00');
return DAY_NAMES_SHORT[d.getDay()];
});
if (dayNames.length <= 3) return dayNames.join(', ');
return dayNames.slice(0, 3).join(', ') + ', \u2026';
}
/* ── media helpers ── */
function photoStripMedia(image, icon, accentBg) {
if (image) {
return `
`;
}
return `
`;
}
/* ══════════════════════ HTML SHELL ══════════════════════ */
export function getPantryHTML() {
return `
${createCalendarTopbarHTML({
titleId: 'pantry-cal-title',
prevId: 'pantry-cal-prev',
todayId: 'pantry-cal-today',
nextId: 'pantry-cal-next',
wrapperClass: 'pb-3 flex items-center gap-3',
titleClass: 'text-[13px] font-semibold text-[#ddd6ca] leading-none tracking-[-0.02em]',
})}
${createCalendarWeekdayHeaderHTML()}
${getIngredientCardHTML({
idBase: 'pv2-card',
overlayClass: 'absolute inset-0 z-[60] hidden opacity-0 transition-opacity duration-200 flex items-center justify-center p-5',
})}
`;
}
/* ══════════════════════ HORIZON SELECTOR ══════════════════════ */
function syncHorizonUI() {
ensureValidHorizonDate();
const compactControls = document.getElementById('pantry-compact-controls');
const searchShell = document.getElementById('pantry-search-shell');
const popover = document.getElementById('pantry-calendar-popover');
const horizonLabel = document.getElementById('pantry-horizon-label');
const chevron = document.getElementById('pantry-horizon-chevron');
const titleEl = document.getElementById('pantry-cal-title');
const selectionEl = document.getElementById('pantry-cal-selection');
const prevBtn = document.getElementById('pantry-cal-prev');
const todayBtn = document.getElementById('pantry-cal-today');
if (horizonLabel) horizonLabel.textContent = formatHorizonLabel(horizonEndDate);
if (titleEl) titleEl.textContent = formatCalendarMonthYear(calendarMonthAnchor);
if (selectionEl) selectionEl.textContent = formatRangeSummary(horizonEndDate);
const showCalendar = isCalendarOpen && !isSearchExpanded;
if (popover) {
popover.style.opacity = showCalendar ? '1' : '0';
popover.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popover.style.pointerEvents = showCalendar ? 'auto' : 'none';
}
if (chevron) {
chevron.style.transform = showCalendar ? 'rotate(180deg)' : 'rotate(0deg)';
}
if (prevBtn) {
const isCurrentMonth = sameMonth(calendarMonthAnchor, getToday());
prevBtn.disabled = isCurrentMonth;
prevBtn.style.opacity = isCurrentMonth ? '0.45' : '1';
prevBtn.style.cursor = isCurrentMonth ? 'default' : 'pointer';
}
syncCalendarTodayButton(todayBtn, sameMonth(calendarMonthAnchor, getToday()));
if (compactControls) {
compactControls.style.opacity = isSearchExpanded ? '0' : '1';
compactControls.style.transform = isSearchExpanded ? 'translateY(-2px) scale(0.98)' : 'translateY(0) scale(1)';
compactControls.style.pointerEvents = isSearchExpanded ? 'none' : 'auto';
}
if (searchShell) {
searchShell.style.opacity = isSearchExpanded ? '1' : '0';
searchShell.style.transform = isSearchExpanded ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)';
searchShell.style.pointerEvents = isSearchExpanded ? 'auto' : 'none';
}
renderCalendarPopover();
}
function renderCalendarPopover() {
const gridEl = document.getElementById('pantry-calendar-grid');
if (!gridEl) return;
ensureValidHorizonDate();
const today = getToday();
const plans = loadPlans();
renderCalendarGrid({
gridEl,
mode: 'month',
anchorDate: calendarMonthAnchor,
selectedDate: horizonEndDate,
resolveDayState: (day, meta) => {
const isPast = day.getTime() < today.getTime();
return {
disabled: isPast,
dimmed: isPast || !meta.inCurrentMonth,
showIndicator: dayHasAnyMeal(plans, day),
};
},
dayAttr: PANTRY_CALENDAR_DAY_ATTR,
});
}
function closeSearch() {
const input = document.getElementById('pantry-search');
const hadQuery = Boolean(input?.value);
if (input) {
input.value = '';
input.blur();
}
isSearchExpanded = false;
syncHorizonUI();
if (hadQuery) renderBoard();
}
function openSearch() {
isSearchExpanded = true;
isCalendarOpen = false;
syncHorizonUI();
window.requestAnimationFrame(() => {
document.getElementById('pantry-search')?.focus();
});
}
function closeCalendar() {
if (!isCalendarOpen) return;
isCalendarOpen = false;
syncHorizonUI();
}
function openCalendar() {
ensureValidHorizonDate();
calendarMonthAnchor = startOfMonth(horizonEndDate);
isCalendarOpen = true;
syncHorizonUI();
}
function selectHorizonDate(date) {
const next = startOfDay(date);
if (next.getTime() < getToday().getTime()) return;
horizonEndDate = next;
calendarMonthAnchor = startOfMonth(next);
isCalendarOpen = false;
syncHorizonUI();
renderBoard();
}
/* ══════════════════════ DATA CLASSIFICATION ══════════════════════ */
/**
* Classify all ingredients into 3 groups based on plan needs and pantry stock.
*/
function classifyIngredients(searchQuery) {
const plans = loadPlans();
const pantry = loadPantry();
const today = getToday();
const needs = aggregateRangeIngredientNeed(plans, today, getHorizonDays());
const q = normalizeSearch(searchQuery);
const matchesSearch = (name, category) => {
if (!q) return true;
return name.toLowerCase().includes(q) || (CATEGORY_LABELS[category] || '').toLowerCase().includes(q);
};
const neededIds = new Set();
const shortfalls = [];
const sufficient = [];
for (const need of needs) {
neededIds.add(need.ingredientId);
if (!matchesSearch(need.name, need.category)) continue;
const have = getPantryTotal(need.ingredientId, pantry);
const def = INGREDIENTS[need.ingredientId];
const icon = CATEGORY_ICONS[def?.category] || 'fa-jar';
const item = {
ingredientId: need.ingredientId,
name: need.name,
category: need.category,
needed: need.amount,
unit: need.unit,
pantryQty: have,
days: need.days,
image: def?.image || null,
icon,
};
if (have < need.amount) {
shortfalls.push({
...item,
shortfall: Math.round((need.amount - have) * 10) / 10,
fillPct: need.amount > 0 ? Math.min(100, Math.round((have / need.amount) * 100)) : 0,
});
} else {
sufficient.push({
...item,
fillPct: 100,
});
}
}
// "Poza planem": all ingredients NOT in any plan need
const notPlanned = Object.keys(INGREDIENTS)
.filter((id) => !neededIds.has(id))
.filter((id) => matchesSearch(INGREDIENTS[id].name, INGREDIENTS[id].category))
.map((id) => {
const def = INGREDIENTS[id];
return {
ingredientId: id,
name: def.name,
qty: getPantryTotal(id, pantry),
unit: def.pantryUnit,
};
})
.sort((a, b) => a.name.localeCompare(b.name, 'pl'));
return { shortfalls, sufficient, notPlanned };
}
/* ══════════════════════ TILE RENDERING ══════════════════════ */
function shortfallTileHtml(item) {
const dayCtx = formatDayContext(item.days);
return `
${esc(item.name)}
${esc(formatQty(item.pantryQty))} / ${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}
`;
}
function sufficientTileHtml(item) {
const dayCtx = formatDayContext(item.days);
return `
${esc(item.name)}
${esc(formatQty(item.pantryQty))} / ${esc(formatQty(item.needed))} ${esc(unitLabel(item.unit))}
`;
}
function notPlannedChipHtml(item) {
const hasStock = item.qty > 0;
return `
${esc(item.name)}
${esc(formatQty(item.qty))} ${esc(unitLabel(item.unit))}
`;
}
/* ══════════════════════ SECTION RENDERING ══════════════════════ */
function sectionHeaderHtml(icon, iconBg, iconColor, title, titleColor, count) {
return `
`;
}
function renderBoard() {
const root = document.getElementById('pantry-board');
if (!root) return;
const q = document.getElementById('pantry-search')?.value || '';
const { shortfalls, sufficient, notPlanned } = classifyIngredients(q);
const totalVisible = shortfalls.length + sufficient.length + notPlanned.length;
if (totalVisible === 0 && q) {
root.innerHTML = `Brak wyników — zmień wyszukiwanie.
`;
return;
}
let html = '';
// Section 1: Potrzebne (shortfalls)
if (shortfalls.length > 0) {
html += ``;
html += sectionHeaderHtml('fa-exclamation', '#914945', '#f2efe8', 'Potrzebne', '#7B756D', shortfalls.length);
html += shortfalls.map(shortfallTileHtml).join('');
html += ` `;
}
// Section 2: W spiżarni (sufficient)
if (sufficient.length > 0) {
html += ``;
html += sectionHeaderHtml('fa-check', '#1a2e22', '#6ee7b7', 'W spiżarni', '#7B756D', sufficient.length);
html += sufficient.map(sufficientTileHtml).join('');
html += ` `;
}
// Section 3: Poza planem
if (notPlanned.length > 0) {
html += ``;
html += sectionHeaderHtml('fa-minus', '#2a2a28', '#6d6c67', 'Poza planem', '#7B756D', notPlanned.length);
html += ``;
html += notPlanned.map(notPlannedChipHtml).join('');
html += `
`;
}
// Empty state: no plans at all
if (shortfalls.length === 0 && sufficient.length === 0 && !q) {
html = `
Brak zaplanowanych posiłków
Zaplanuj posiłki, a spiżarnia pokaże czego potrzebujesz i co masz na stanie.
` + html;
}
root.innerHTML = html;
// Bind tile clicks
root.querySelectorAll('.pv2-tile').forEach((btn) => {
btn.addEventListener('click', () => openIngredientCard(btn.dataset.id, null));
});
}
/* ══════════════════════ INGREDIENT SHEET ══════════════════════ */
function openIngredientCard(ingredientId, productId) {
ingredientCard?.open({
ingredientId,
productId,
sourceNote: 'Ze spiżarni',
onAfterChange: () => renderBoard(),
});
}
/* ══════════════════════ PUBLIC API ══════════════════════ */
export function refreshPantry() {
syncHorizonUI();
renderBoard();
ingredientCard?.refresh();
}
export function setupPantry() {
if (!ingredientCard) {
ingredientCard = createIngredientCardController({ idBase: 'pv2-card', defaultSourceNote: 'Ze spiżarni' });
ingredientCard.bind();
}
syncHorizonUI();
renderBoard();
// Search
document.getElementById('pantry-search')?.addEventListener('input', () => renderBoard());
document.getElementById('pantry-search')?.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeSearch();
});
document.getElementById('pantry-search-toggle')?.addEventListener('click', () => openSearch());
document.getElementById('pantry-search-close')?.addEventListener('click', () => closeSearch());
// Horizon pill + calendar
document.getElementById('pantry-horizon-toggle')?.addEventListener('click', () => {
if (isCalendarOpen) {
closeCalendar();
return;
}
openCalendar();
});
document.getElementById('pantry-cal-prev')?.addEventListener('click', () => {
const prevMonth = addMonths(calendarMonthAnchor, -1);
if (prevMonth.getTime() < startOfMonth(getToday()).getTime()) return;
calendarMonthAnchor = prevMonth;
syncHorizonUI();
});
document.getElementById('pantry-cal-next')?.addEventListener('click', () => {
calendarMonthAnchor = addMonths(calendarMonthAnchor, 1);
syncHorizonUI();
});
document.getElementById('pantry-cal-today')?.addEventListener('click', () => {
calendarMonthAnchor = startOfMonth(getToday());
syncHorizonUI();
});
bindCalendarDayClicks(document.getElementById('pantry-calendar-grid'), (date) => {
selectHorizonDate(date);
}, PANTRY_CALENDAR_DAY_ATTR);
if (!pantryGlobalListenersBound) {
pantryGlobalListenersBound = true;
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (isCalendarOpen && !target.closest('#pantry-horizon-wrap')) {
closeCalendar();
}
});
}
window.refreshPantry = refreshPantry;
}