Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s
This commit is contained in:
@@ -32,10 +32,11 @@ import {
|
||||
createCalendarTopbarHTML,
|
||||
createCalendarWeekdayHeaderHTML,
|
||||
isCalendarOnToday,
|
||||
renderCalendarGrid,
|
||||
renderCollapsibleCalendar,
|
||||
syncCalendarTodayButton,
|
||||
syncCollapsibleCalendarMode,
|
||||
} from '../ui/mealCalendar.js?v=11';
|
||||
} from '../ui/mealCalendar.js?v=14';
|
||||
import {
|
||||
filterRecipesByQuery,
|
||||
renderRecipeGrid,
|
||||
@@ -76,9 +77,7 @@ export function getMealPlannerHTML() {
|
||||
<div class="min-h-12 px-4 pt-4 pb-3 flex items-center justify-between gap-3 min-w-0">
|
||||
<h1 class="min-w-0 flex-1 truncate" style="margin:0;padding:0;color:rgb(var(--text-emphasis-rgb));font-family:var(--app-font);font-size:18px;font-weight:700;line-height:1.2;letter-spacing:-0.02em;">Plan posiłków</h1>
|
||||
${createCalendarTopbarHTML({
|
||||
prevId: 'cal-prev',
|
||||
todayId: 'cal-go-today',
|
||||
nextId: 'cal-next',
|
||||
wrapperClass: 'flex shrink-0 items-center justify-end',
|
||||
})}
|
||||
</div>
|
||||
@@ -201,52 +200,214 @@ function bindCalendarSwipeGesture(state, rerender) {
|
||||
const zone = document.getElementById('calendar-swipe-zone');
|
||||
if (!zone) return;
|
||||
|
||||
const ANIMATION_MS = 260;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let ptrId = null;
|
||||
let moved = false;
|
||||
let axisLocked = null;
|
||||
let suppressClickUntil = 0;
|
||||
let animatingNav = false;
|
||||
|
||||
let dragWrap = null;
|
||||
let wrapWidth = 0;
|
||||
let prevGhost = null;
|
||||
let nextGhost = null;
|
||||
let prevWrapPosition = '';
|
||||
let prevWrapOverflow = '';
|
||||
|
||||
const getActiveWrap = () => document.getElementById(
|
||||
state.mode === 'week' ? 'calendar-week-wrap' : 'calendar-month-wrap',
|
||||
);
|
||||
|
||||
const resolveDayStateForGhost = (day, meta) => {
|
||||
const today = startOfDay(new Date());
|
||||
const isSelected = sameDay(day, state.selected);
|
||||
const isPast = day.getTime() < today.getTime();
|
||||
return {
|
||||
disabled: isPast && !isSelected,
|
||||
dimmed: (isPast || (meta.mode === 'month' && !meta.inCurrentMonth)) && !isSelected,
|
||||
showIndicator: meta.mode === 'month'
|
||||
? meta.inCurrentMonth && dayHasAnyMeal(state.plans, day)
|
||||
: dayHasAnyMeal(state.plans, day),
|
||||
};
|
||||
};
|
||||
|
||||
const buildGhost = (anchorDate, mode) => {
|
||||
if (!dragWrap) return null;
|
||||
const ghost = dragWrap.cloneNode(true);
|
||||
ghost.removeAttribute('id');
|
||||
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '0';
|
||||
ghost.style.width = '100%';
|
||||
ghost.style.pointerEvents = 'none';
|
||||
ghost.setAttribute('aria-hidden', 'true');
|
||||
let ghostGridEl = null;
|
||||
ghost.querySelectorAll('.grid-cols-7').forEach((g) => {
|
||||
if (!g.classList.contains('text-center')) ghostGridEl = g;
|
||||
});
|
||||
if (ghostGridEl) {
|
||||
renderCalendarGrid({
|
||||
gridEl: ghostGridEl,
|
||||
mode,
|
||||
anchorDate,
|
||||
selectedDate: state.selected,
|
||||
resolveDayState: resolveDayStateForGhost,
|
||||
});
|
||||
}
|
||||
return ghost;
|
||||
};
|
||||
|
||||
const activateCarousel = () => {
|
||||
dragWrap = getActiveWrap();
|
||||
if (!dragWrap) return;
|
||||
wrapWidth = dragWrap.getBoundingClientRect().width || zone.getBoundingClientRect().width;
|
||||
prevWrapPosition = dragWrap.style.position;
|
||||
prevWrapOverflow = dragWrap.style.overflow;
|
||||
dragWrap.style.position = 'relative';
|
||||
dragWrap.style.overflow = 'visible';
|
||||
|
||||
const prevAnchor = state.mode === 'week'
|
||||
? addWeeks(state.weekStart, -1)
|
||||
: addMonths(state.monthAnchor, -1);
|
||||
const nextAnchor = state.mode === 'week'
|
||||
? addWeeks(state.weekStart, 1)
|
||||
: addMonths(state.monthAnchor, 1);
|
||||
|
||||
prevGhost = buildGhost(prevAnchor, state.mode);
|
||||
nextGhost = buildGhost(nextAnchor, state.mode);
|
||||
if (prevGhost) {
|
||||
prevGhost.style.left = `-${wrapWidth}px`;
|
||||
dragWrap.appendChild(prevGhost);
|
||||
}
|
||||
if (nextGhost) {
|
||||
nextGhost.style.left = `${wrapWidth}px`;
|
||||
dragWrap.appendChild(nextGhost);
|
||||
}
|
||||
dragWrap.style.willChange = 'transform';
|
||||
dragWrap.style.transition = 'none';
|
||||
};
|
||||
|
||||
const clearCarousel = () => {
|
||||
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
|
||||
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
|
||||
prevGhost = null;
|
||||
nextGhost = null;
|
||||
if (dragWrap) {
|
||||
dragWrap.style.transition = '';
|
||||
dragWrap.style.transform = '';
|
||||
dragWrap.style.willChange = '';
|
||||
dragWrap.style.position = prevWrapPosition;
|
||||
dragWrap.style.overflow = prevWrapOverflow;
|
||||
}
|
||||
dragWrap = null;
|
||||
};
|
||||
|
||||
const setTranslate = (x, ms) => {
|
||||
if (!dragWrap) return;
|
||||
dragWrap.style.transition = ms ? `transform ${ms}ms ease` : 'none';
|
||||
dragWrap.style.transform = `translate3d(${x}px, 0, 0)`;
|
||||
};
|
||||
|
||||
zone.addEventListener('pointerdown', (e) => {
|
||||
if (ptrId !== null) return;
|
||||
if (ptrId !== null || animatingNav) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
ptrId = e.pointerId;
|
||||
moved = false;
|
||||
axisLocked = null;
|
||||
try { zone.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
});
|
||||
|
||||
zone.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerId !== ptrId) return;
|
||||
if (Math.abs(e.clientY - startY) > 10) moved = true;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moved && (Math.abs(dx) > 6 || Math.abs(dy) > 6)) {
|
||||
moved = true;
|
||||
axisLocked = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
|
||||
if (axisLocked === 'x') activateCarousel();
|
||||
}
|
||||
if (axisLocked === 'x' && dragWrap) {
|
||||
setTranslate(dx, 0);
|
||||
}
|
||||
});
|
||||
|
||||
zone.addEventListener('pointerup', (e) => {
|
||||
if (e.pointerId !== ptrId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
ptrId = null;
|
||||
|
||||
if (!moved || Math.abs(dy) < 30) return;
|
||||
if (!moved) return;
|
||||
|
||||
let switched = false;
|
||||
if (state.mode === 'week' && dy > 30) {
|
||||
state.mode = 'month';
|
||||
state.monthAnchor = startOfMonth(state.selected);
|
||||
switched = true;
|
||||
} else if (state.mode === 'month' && dy < -30) {
|
||||
state.mode = 'week';
|
||||
state.weekStart = startOfWeekMonday(state.selected);
|
||||
switched = true;
|
||||
const horizontal = axisLocked === 'x';
|
||||
|
||||
if (horizontal && dragWrap) {
|
||||
if (Math.abs(dx) < 40) {
|
||||
setTranslate(0, ANIMATION_MS);
|
||||
setTimeout(clearCarousel, ANIMATION_MS + 20);
|
||||
return;
|
||||
}
|
||||
suppressClickUntil = performance.now() + 500;
|
||||
animatingNav = true;
|
||||
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
|
||||
setTranslate(targetX, ANIMATION_MS);
|
||||
setTimeout(() => {
|
||||
if (state.mode === 'week') {
|
||||
state.weekStart = addWeeks(state.weekStart, dx > 0 ? -1 : 1);
|
||||
if (!weekContains(state.weekStart, state.selected)) {
|
||||
state.selected = new Date(state.weekStart);
|
||||
}
|
||||
} else {
|
||||
state.monthAnchor = addMonths(state.monthAnchor, dx > 0 ? -1 : 1);
|
||||
if (!sameMonth(state.monthAnchor, state.selected)) {
|
||||
state.selected = startOfMonth(state.monthAnchor);
|
||||
}
|
||||
}
|
||||
clearCarousel();
|
||||
rerender();
|
||||
animatingNav = false;
|
||||
}, ANIMATION_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (switched) {
|
||||
zone.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}, { capture: true, once: true });
|
||||
rerender();
|
||||
if (!horizontal && Math.abs(dy) >= 30) {
|
||||
let triggered = false;
|
||||
if (state.mode === 'week' && dy > 0) {
|
||||
state.mode = 'month';
|
||||
state.monthAnchor = startOfMonth(state.selected);
|
||||
triggered = true;
|
||||
} else if (state.mode === 'month' && dy < 0) {
|
||||
state.mode = 'week';
|
||||
state.weekStart = startOfWeekMonday(state.selected);
|
||||
triggered = true;
|
||||
}
|
||||
if (triggered) {
|
||||
suppressClickUntil = performance.now() + 350;
|
||||
rerender();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
zone.addEventListener('click', (ev) => {
|
||||
if (performance.now() < suppressClickUntil) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
suppressClickUntil = 0;
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
zone.addEventListener('pointercancel', () => {
|
||||
ptrId = null;
|
||||
moved = false;
|
||||
if (dragWrap) {
|
||||
setTranslate(0, ANIMATION_MS);
|
||||
setTimeout(clearCarousel, ANIMATION_MS + 20);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1181,36 +1342,6 @@ export function setupMealPlanner() {
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('cal-prev')?.addEventListener('click', () => {
|
||||
if (state.mode === 'week') {
|
||||
state.weekStart = addWeeks(state.weekStart, -1);
|
||||
if (!weekContains(state.weekStart, state.selected)) {
|
||||
state.selected = new Date(state.weekStart);
|
||||
}
|
||||
} else {
|
||||
state.monthAnchor = addMonths(state.monthAnchor, -1);
|
||||
if (!sameMonth(state.monthAnchor, state.selected)) {
|
||||
state.selected = startOfMonth(state.monthAnchor);
|
||||
}
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('cal-next')?.addEventListener('click', () => {
|
||||
if (state.mode === 'week') {
|
||||
state.weekStart = addWeeks(state.weekStart, 1);
|
||||
if (!weekContains(state.weekStart, state.selected)) {
|
||||
state.selected = new Date(state.weekStart);
|
||||
}
|
||||
} else {
|
||||
state.monthAnchor = addMonths(state.monthAnchor, 1);
|
||||
if (!sameMonth(state.monthAnchor, state.selected)) {
|
||||
state.selected = startOfMonth(state.monthAnchor);
|
||||
}
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
|
||||
document.getElementById('cal-go-today')?.addEventListener('click', () => {
|
||||
const today = startOfDay(new Date());
|
||||
state.selected = today;
|
||||
|
||||
@@ -3,16 +3,13 @@ import {
|
||||
CATEGORY_LABELS,
|
||||
} from '../data/catalog.js?v=9';
|
||||
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 { loadPlans, dateKey } from '../services/planStore.js?v=2';
|
||||
import { addDays, sameDay, startOfDay, startOfMonth } from '../services/dateUtils.js';
|
||||
import { aggregateRangeIngredientNeed, dayHasAnyMeal } from '../services/planIngredients.js?v=4';
|
||||
import {
|
||||
bindCalendarDayClicks,
|
||||
createCalendarTopbarHTML,
|
||||
createCalendarWeekdayHeaderHTML,
|
||||
renderCalendarGrid,
|
||||
syncCalendarTodayButton,
|
||||
} from '../ui/mealCalendar.js?v=11';
|
||||
createSwipePopoverCalendarHTML,
|
||||
initSwipePopoverCalendar,
|
||||
} from '../ui/swipePopoverCalendar.js';
|
||||
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116';
|
||||
|
||||
/* ── helpers ── */
|
||||
@@ -79,7 +76,6 @@ 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 DEFAULT_HORIZON_DAYS = 7;
|
||||
const PANTRY_CALENDAR_DAY_ATTR = 'data-pantry-calendar-day';
|
||||
const SHORTFALL_ACCENT = 'rgb(var(--danger-rgb))';
|
||||
const PANTRY_CALENDAR_THEME = {
|
||||
bg: 'rgb(var(--app-bg-rgb))',
|
||||
@@ -105,6 +101,7 @@ let isCalendarOpen = false;
|
||||
let isFilterOpen = false;
|
||||
let calendarMonthAnchor = startOfMonth(horizonEndDate);
|
||||
let pantryGlobalListenersBound = false;
|
||||
let pantryCalendar = null;
|
||||
let pantryFilters = {
|
||||
categories: [],
|
||||
sections: [],
|
||||
@@ -140,10 +137,6 @@ 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');
|
||||
@@ -209,25 +202,8 @@ export function getPantryHTML() {
|
||||
</div>
|
||||
|
||||
|
||||
<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:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-6px) scale(0.98);">
|
||||
${createCalendarTopbarHTML({
|
||||
prevId: 'pantry-cal-prev',
|
||||
todayId: 'pantry-cal-today',
|
||||
nextId: 'pantry-cal-next',
|
||||
wrapperClass: 'pb-3 flex items-center justify-end gap-3',
|
||||
controlsStyle: `background:${PANTRY_CALENDAR_THEME.bg};border-color:${PANTRY_CALENDAR_THEME.border};`,
|
||||
navButtonClass: 'shrink-0 w-7 h-full flex items-center justify-center rounded-full border-0 bg-transparent text-[rgb(var(--text-body-soft-rgb))] transition-colors',
|
||||
todayButtonActiveClass: 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-1.5 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap',
|
||||
todayButtonDimClass: 'h-full shrink-0 inline-flex items-center justify-center rounded-full px-2 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default',
|
||||
})}
|
||||
${createCalendarWeekdayHeaderHTML(undefined, {
|
||||
wrapperClass: 'grid grid-cols-7 gap-1.5 text-center text-[8px] font-medium text-[rgb(var(--text-dim-rgb))] uppercase tracking-wide mb-1 leading-none',
|
||||
})}
|
||||
<div id="pantry-calendar-grid" class="grid grid-cols-7 gap-1.5"></div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t" style="border-color:rgb(var(--card-strong-rgb));">
|
||||
<p id="pantry-cal-selection" class="min-w-0 text-[11px] leading-snug" style="color:rgb(var(--text-dim-rgb));"></p>
|
||||
</div>
|
||||
<div id="pantry-calendar-popover" class="absolute left-0 right-0 top-full mt-2 rounded-[1.35rem] py-3 transition-all duration-200 pointer-events-none" style="background:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-6px) scale(0.98);">
|
||||
${createSwipePopoverCalendarHTML({ idPrefix: 'pantry-cal' })}
|
||||
</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:rgb(var(--sunken-rgb)) !important; border:1px solid rgb(var(--border-input-rgb)) !important; box-shadow:var(--shadow-shell) !important; opacity:0; transform:translateY(-2px) scale(0.98);">
|
||||
@@ -264,12 +240,8 @@ function syncHorizonUI() {
|
||||
const compactLabel = document.getElementById('pantry-horizon-compact-label');
|
||||
const compactPill = document.getElementById('pantry-horizon-compact');
|
||||
const chevron = document.getElementById('pantry-horizon-chevron');
|
||||
const selectionEl = document.getElementById('pantry-cal-selection');
|
||||
const prevBtn = document.getElementById('pantry-cal-prev');
|
||||
const todayBtn = document.getElementById('pantry-cal-today');
|
||||
|
||||
if (compactLabel) compactLabel.textContent = formatHorizonLabel(horizonEndDate);
|
||||
if (selectionEl) selectionEl.textContent = formatRangeSummary(horizonEndDate);
|
||||
|
||||
const showCalendar = isCalendarOpen && !isSearchExpanded;
|
||||
const showDefault = !isSearchExpanded;
|
||||
@@ -313,22 +285,6 @@ function syncHorizonUI() {
|
||||
filterCount.classList.toggle('flex', activeFilterCount > 0);
|
||||
}
|
||||
|
||||
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()),
|
||||
horizonEndDate,
|
||||
{
|
||||
ariaLabelGo: 'Pokaż bieżący miesiąc w kalendarzu',
|
||||
ariaLabelCurrent: 'Wyświetlany jest bieżący miesiąc',
|
||||
},
|
||||
);
|
||||
|
||||
if (searchShell) {
|
||||
searchShell.style.opacity = isSearchExpanded ? '1' : '0';
|
||||
searchShell.style.transform = isSearchExpanded ? 'translateY(0) scale(1)' : 'translateY(-2px) scale(0.98)';
|
||||
@@ -340,29 +296,38 @@ function syncHorizonUI() {
|
||||
}
|
||||
|
||||
function renderCalendarPopover() {
|
||||
const gridEl = document.getElementById('pantry-calendar-grid');
|
||||
if (!gridEl) return;
|
||||
pantryCalendar?.render();
|
||||
}
|
||||
|
||||
ensureValidHorizonDate();
|
||||
const today = getToday();
|
||||
const plans = loadPlans();
|
||||
|
||||
renderCalendarGrid({
|
||||
gridEl,
|
||||
mode: 'month',
|
||||
anchorDate: calendarMonthAnchor,
|
||||
selectedDate: horizonEndDate,
|
||||
resolveDayState: (day, meta) => {
|
||||
const isPast = day.getTime() < today.getTime();
|
||||
function bindPantryCalendarInteractions() {
|
||||
pantryCalendar = initSwipePopoverCalendar({
|
||||
idPrefix: 'pantry-cal',
|
||||
selectionMode: 'single',
|
||||
panelHandlePx: 10,
|
||||
panelHandleMin: 8,
|
||||
panelHandleMax: 12,
|
||||
getMonthAnchor: () => calendarMonthAnchor,
|
||||
setMonthAnchor: (nextMonth) => {
|
||||
const nextAnchor = startOfMonth(nextMonth);
|
||||
const minAnchor = startOfMonth(getToday());
|
||||
if (nextAnchor.getTime() < minAnchor.getTime()) return;
|
||||
calendarMonthAnchor = nextAnchor;
|
||||
},
|
||||
getSelectionKeys: () => dateKey(horizonEndDate),
|
||||
onSelectionCommit: (selectedKey) => {
|
||||
selectHorizonDate(new Date(`${selectedKey}T00:00:00`));
|
||||
},
|
||||
resolveDayState: (day, { inCurrentMonth }) => {
|
||||
const isPast = day.getTime() < getToday().getTime();
|
||||
return {
|
||||
disabled: isPast,
|
||||
dimmed: isPast || !meta.inCurrentMonth,
|
||||
showIndicator: dayHasAnyMeal(plans, day),
|
||||
dimmed: isPast || !inCurrentMonth,
|
||||
showDot: dayHasAnyMeal(loadPlans(), day),
|
||||
};
|
||||
},
|
||||
dayAttr: PANTRY_CALENDAR_DAY_ATTR,
|
||||
theme: PANTRY_CALENDAR_THEME,
|
||||
});
|
||||
pantryCalendar.render();
|
||||
}
|
||||
|
||||
function filterChipHtml(kind, value, label, active) {
|
||||
@@ -434,6 +399,7 @@ function openSearch() {
|
||||
function closeCalendar() {
|
||||
if (!isCalendarOpen) return;
|
||||
isCalendarOpen = false;
|
||||
pantryCalendar?.resetTrackPosition();
|
||||
syncHorizonUI();
|
||||
}
|
||||
|
||||
@@ -444,6 +410,10 @@ function openCalendar() {
|
||||
isFilterOpen = false;
|
||||
isCalendarOpen = true;
|
||||
syncHorizonUI();
|
||||
requestAnimationFrame(() => {
|
||||
pantryCalendar?.reapplyLayout();
|
||||
pantryCalendar?.resetTrackPosition();
|
||||
});
|
||||
}
|
||||
|
||||
function closeFilter() {
|
||||
@@ -767,23 +737,7 @@ export function setupPantry() {
|
||||
event.stopPropagation();
|
||||
isCalendarOpen ? closeCalendar() : 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);
|
||||
bindPantryCalendarInteractions();
|
||||
|
||||
if (!pantryGlobalListenersBound) {
|
||||
pantryGlobalListenersBound = true;
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '../services/pantryShopping.js?v=2';
|
||||
import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients.js?v=2';
|
||||
import { loadPlans, dateKey } from '../services/planStore.js?v=2';
|
||||
import { addDays, startOfDay, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
|
||||
import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
|
||||
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
|
||||
import { showAppToast } from '../ui/toast.js';
|
||||
|
||||
/* ── helpers ── */
|
||||
@@ -62,6 +63,7 @@ let expandedIngredientId = null;
|
||||
let expandedAmount = 0;
|
||||
let calendarOpen = false;
|
||||
let calendarMonth = startOfMonth(new Date());
|
||||
let shoppingCalendar = null;
|
||||
|
||||
/* ── day helpers ── */
|
||||
|
||||
@@ -112,10 +114,6 @@ function groupByCategory(items) {
|
||||
/* ══════════════════════ HTML SHELL ══════════════════════ */
|
||||
|
||||
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:rgb(var(--app-bg-rgb)) !important;">
|
||||
|
||||
@@ -136,23 +134,11 @@ export function getShoppingListHTML() {
|
||||
|
||||
<!-- 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:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
|
||||
<!-- 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:rgb(var(--app-bg-rgb)); border-color:rgb(var(--card-raised-rgb));">
|
||||
<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:rgb(var(--text-body-soft-rgb));">
|
||||
<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:rgb(var(--text-body-soft-rgb));"></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:rgb(var(--text-body-soft-rgb));">
|
||||
<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:rgb(var(--text-dim-rgb));">${weekdayHeader}</div>
|
||||
<!-- day grid -->
|
||||
<div id="sl-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
|
||||
<div class="rounded-[1.35rem] py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
|
||||
${createSwipePopoverCalendarHTML({
|
||||
idPrefix: 'sl-cal',
|
||||
weekdays: WEEKDAY_SHORT,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,115 +166,41 @@ export function getShoppingListHTML() {
|
||||
|
||||
/* ══════════════════════ CALENDAR ══════════════════════ */
|
||||
|
||||
function getDayRange(startDk, endDk) {
|
||||
const a = new Date(startDk + 'T00:00:00');
|
||||
const b = new Date(endDk + 'T00:00:00');
|
||||
const [from, to] = a <= b ? [a, b] : [b, a];
|
||||
const days = [];
|
||||
for (let d = new Date(from); d <= to; d = addDays(d, 1)) days.push(dateKey(d));
|
||||
return days;
|
||||
}
|
||||
|
||||
function renderCalendarGrid(previewDays = null) {
|
||||
const grid = document.getElementById('sl-cal-grid');
|
||||
const monthLabel = document.getElementById('sl-cal-month-label');
|
||||
if (!grid) return;
|
||||
|
||||
if (monthLabel) {
|
||||
monthLabel.textContent = `${MONTHS_LONG[calendarMonth.getMonth()]} ${calendarMonth.getFullYear()}`;
|
||||
}
|
||||
|
||||
const selected = previewDays ?? new Set(getSelectedDays());
|
||||
const today = todayKey();
|
||||
const first = startOfMonth(calendarMonth);
|
||||
const gridStart = startOfWeekMonday(first);
|
||||
|
||||
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||||
while (cells.length > 35 && cells.slice(-7).every((d) => d.getMonth() !== first.getMonth())) {
|
||||
cells.splice(-7);
|
||||
}
|
||||
|
||||
grid.innerHTML = cells.map((day) => {
|
||||
const dk = dateKey(day);
|
||||
const inMonth = day.getMonth() === first.getMonth();
|
||||
const isSel = selected.has(dk);
|
||||
const isToday = dk === today;
|
||||
|
||||
let bg, borderColor, color, opacity, borderClass;
|
||||
if (isSel) {
|
||||
bg = 'rgb(var(--card-rgb))'; borderColor = 'rgb(var(--border-input-rgb))'; color = 'rgb(var(--text-emphasis-rgb))'; opacity = '1'; borderClass = 'border';
|
||||
} else if (!inMonth) {
|
||||
bg = 'transparent'; borderColor = 'transparent'; color = CALENDAR_DIM_TEXT; opacity = CALENDAR_DIM_OPACITY; borderClass = 'border-0';
|
||||
} else {
|
||||
bg = 'rgb(var(--app-bg-rgb))'; borderColor = 'rgb(var(--card-raised-rgb))'; color = 'rgb(var(--text-body-soft-rgb))'; opacity = '1'; borderClass = 'border';
|
||||
}
|
||||
|
||||
const dot = isToday && !isSel
|
||||
? `<span class="absolute left-1/2 -translate-x-1/2 w-1 h-1 rounded-full opacity-75" style="bottom:0.24rem; background:rgb(var(--text-faint-rgb));"></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]));
|
||||
function initShoppingCalendar() {
|
||||
shoppingCalendar = initSwipePopoverCalendar({
|
||||
idPrefix: 'sl-cal',
|
||||
selectionMode: 'range',
|
||||
monthsLong: MONTHS_LONG,
|
||||
getMonthAnchor: () => calendarMonth,
|
||||
setMonthAnchor: (nextMonth) => {
|
||||
calendarMonth = startOfMonth(nextMonth);
|
||||
},
|
||||
getSelectionKeys: () => getSelectedDays(),
|
||||
onSelectionCommit: (rangeKeys) => {
|
||||
setSelectedDays(rangeKeys);
|
||||
expandedIngredientId = null;
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
},
|
||||
resolveDayState: (day, { inCurrentMonth, isSelected }) => ({
|
||||
disabled: false,
|
||||
dimmed: !inCurrentMonth,
|
||||
showDot: dateKey(day) === todayKey() && !isSelected,
|
||||
}),
|
||||
theme: {
|
||||
selectedBg: 'rgb(var(--card-rgb))',
|
||||
selectedBorder: 'rgb(var(--border-input-rgb))',
|
||||
selectedText: 'rgb(var(--text-emphasis-rgb))',
|
||||
selectedDot: 'rgb(var(--text-emphasis-rgb))',
|
||||
bg: 'rgb(var(--app-bg-rgb))',
|
||||
border: 'rgb(var(--card-raised-rgb))',
|
||||
text: 'rgb(var(--text-body-soft-rgb))',
|
||||
dimmedBg: 'transparent',
|
||||
dimText: CALENDAR_DIM_TEXT,
|
||||
dimOpacity: Number(CALENDAR_DIM_OPACITY),
|
||||
dot: 'rgb(var(--text-faint-rgb))',
|
||||
},
|
||||
});
|
||||
|
||||
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() {
|
||||
@@ -301,6 +213,7 @@ function openCalendar() {
|
||||
calendarOpen = true;
|
||||
calendarMonth = startOfMonth(new Date());
|
||||
const popup = document.getElementById('sl-calendar-popup');
|
||||
const viewport = document.getElementById('sl-cal-viewport');
|
||||
const chevron = document.getElementById('sl-range-chevron');
|
||||
const pill = document.getElementById('sl-range-pill');
|
||||
if (popup) {
|
||||
@@ -313,7 +226,31 @@ function openCalendar() {
|
||||
pill.style.background = 'rgb(var(--sunken-rgb))';
|
||||
pill.style.borderColor = 'rgb(var(--border-input-rgb))';
|
||||
}
|
||||
renderCalendarGrid();
|
||||
if (viewport) {
|
||||
viewport.style.opacity = '0';
|
||||
viewport.style.visibility = 'hidden';
|
||||
viewport.style.transition = 'opacity 120ms ease';
|
||||
}
|
||||
shoppingCalendar?.render();
|
||||
// Compute geometry while hidden; reveal only after stable layout.
|
||||
const ensureStableCalendarLayout = (attempt = 0) => {
|
||||
const vw = viewport ? (viewport.clientWidth || viewport.getBoundingClientRect().width) : 0;
|
||||
if (vw < 8 && attempt < 8) {
|
||||
requestAnimationFrame(() => ensureStableCalendarLayout(attempt + 1));
|
||||
return;
|
||||
}
|
||||
shoppingCalendar?.reapplyLayout();
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
requestAnimationFrame(() => {
|
||||
shoppingCalendar?.reapplyLayout();
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
if (viewport) {
|
||||
viewport.style.visibility = 'visible';
|
||||
viewport.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => ensureStableCalendarLayout());
|
||||
}
|
||||
|
||||
function closeCalendar() {
|
||||
@@ -331,6 +268,7 @@ function closeCalendar() {
|
||||
pill.style.background = 'rgb(var(--card-rgb))';
|
||||
pill.style.borderColor = 'rgb(var(--border-card-rgb))';
|
||||
}
|
||||
shoppingCalendar?.resetTrackPosition();
|
||||
}
|
||||
|
||||
function toggleCalendar() {
|
||||
@@ -671,6 +609,7 @@ function bindActiveRowEvents(root, activeItems) {
|
||||
export function refreshShoppingList() {
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
if (calendarOpen) shoppingCalendar?.render();
|
||||
}
|
||||
|
||||
export function setupShoppingList() {
|
||||
@@ -679,7 +618,7 @@ export function setupShoppingList() {
|
||||
updatePillLabel();
|
||||
renderAll();
|
||||
|
||||
bindCalendarEvents();
|
||||
initShoppingCalendar();
|
||||
|
||||
document.getElementById('sl-range-pill')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -708,19 +647,6 @@ export function setupShoppingList() {
|
||||
}
|
||||
});
|
||||
|
||||
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', (e) => {
|
||||
e.stopPropagation();
|
||||
clearSessionLog();
|
||||
|
||||
Reference in New Issue
Block a user