From 08a275093c8594114a0cf0de5ea77b9a2daa9652 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Mon, 20 Apr 2026 23:44:18 +0200 Subject: [PATCH] Unify calendar code --- js/ui/calendarPopover.js | 167 ++++++++++++++ js/ui/mealCalendar.js | 410 ++++++++++++++++++++++++++++++++-- js/ui/mealPlanEditor.js | 170 +++++++------- js/ui/swipePopoverCalendar.js | 86 +++---- js/views/MealPlanner.js | 299 ++++--------------------- js/views/Pantry.js | 38 ++-- js/views/ShoppingList.js | 99 +++----- 7 files changed, 783 insertions(+), 486 deletions(-) create mode 100644 js/ui/calendarPopover.js diff --git a/js/ui/calendarPopover.js b/js/ui/calendarPopover.js new file mode 100644 index 0000000..4496426 --- /dev/null +++ b/js/ui/calendarPopover.js @@ -0,0 +1,167 @@ +const DEFAULT_POPOVER_CLASS = 'absolute left-0 right-0 top-full mt-2 z-[50] transition-all duration-200 pointer-events-none'; +const DEFAULT_POPOVER_STYLE = 'opacity:0; transform:translateY(-6px) scale(0.98);'; +const DEFAULT_PANEL_CLASS = 'rounded-[1.35rem] py-3'; +const DEFAULT_PANEL_STYLE = 'background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);'; + +const DEFAULT_OPEN_TRIGGER_STYLE = { + background: 'rgb(var(--sunken-rgb))', + borderColor: 'rgb(var(--border-input-rgb))', +}; + +const DEFAULT_CLOSED_TRIGGER_STYLE = { + background: 'rgb(var(--card-rgb))', + borderColor: 'rgb(var(--border-card-rgb))', +}; + +function byId(idOrElement) { + if (!idOrElement) return null; + if (typeof idOrElement === 'string') return document.getElementById(idOrElement); + return idOrElement; +} + +function setStyles(el, styles = {}, important = false) { + if (!el) return; + Object.entries(styles).forEach(([name, value]) => { + if (value == null) return; + if (important) { + const cssName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + el.style.setProperty(cssName, value, 'important'); + } + else el.style[name] = value; + }); +} + +export function createCalendarPopoverHTML({ + id, + calendarHTML, + popoverClass = DEFAULT_POPOVER_CLASS, + popoverStyle = DEFAULT_POPOVER_STYLE, + panelClass = DEFAULT_PANEL_CLASS, + panelStyle = DEFAULT_PANEL_STYLE, + wrapInPanel = true, +}) { + const body = wrapInPanel + ? `
${calendarHTML}
` + : calendarHTML; + return ` +
+ ${body} +
+ `; +} + +export function syncCalendarPopoverVisibility({ + popup, + isOpen, + chevron, + trigger, + openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE, + closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE, + triggerImportant = false, +}) { + const popupEl = byId(popup); + if (popupEl) { + popupEl.style.opacity = isOpen ? '1' : '0'; + popupEl.style.transform = isOpen ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)'; + popupEl.style.pointerEvents = isOpen ? 'auto' : 'none'; + } + + const chevronEl = byId(chevron); + if (chevronEl) chevronEl.style.transform = isOpen ? 'rotate(180deg)' : 'rotate(0deg)'; + + setStyles( + byId(trigger), + isOpen ? openTriggerStyle : closedTriggerStyle, + triggerImportant, + ); +} + +export function stabilizeSwipeCalendarLayout({ + calendar, + viewport, + hideViewport = false, + maxAttempts = 8, +}) { + const viewportEl = byId(viewport); + if (hideViewport && viewportEl) { + viewportEl.style.opacity = '0'; + viewportEl.style.visibility = 'hidden'; + viewportEl.style.transition = 'opacity 120ms ease'; + } + + calendar?.render?.(); + + const ensureStableLayout = (attempt = 0) => { + const width = viewportEl ? (viewportEl.clientWidth || viewportEl.getBoundingClientRect().width) : 0; + if (viewportEl && width < 8 && attempt < maxAttempts) { + requestAnimationFrame(() => ensureStableLayout(attempt + 1)); + return; + } + + calendar?.reapplyLayout?.(); + calendar?.resetTrackPosition?.(); + requestAnimationFrame(() => { + calendar?.reapplyLayout?.(); + calendar?.resetTrackPosition?.(); + if (hideViewport && viewportEl) { + viewportEl.style.visibility = 'visible'; + viewportEl.style.opacity = '1'; + } + }); + }; + + requestAnimationFrame(() => ensureStableLayout()); +} + +export function createCalendarPopoverController({ + popupId, + viewportId, + triggerId, + chevronId, + getCalendar, + openTriggerStyle = DEFAULT_OPEN_TRIGGER_STYLE, + closedTriggerStyle = DEFAULT_CLOSED_TRIGGER_STYLE, + triggerImportant = false, + hideViewportDuringLayout = false, +}) { + const calendar = () => (typeof getCalendar === 'function' ? getCalendar() : null); + + const sync = (isOpen) => { + syncCalendarPopoverVisibility({ + popup: popupId, + isOpen, + chevron: chevronId, + trigger: triggerId, + openTriggerStyle, + closedTriggerStyle, + triggerImportant, + }); + }; + + const stabilize = () => { + stabilizeSwipeCalendarLayout({ + calendar: calendar(), + viewport: viewportId, + hideViewport: hideViewportDuringLayout, + }); + }; + + const close = ({ clearPendingRange = false } = {}) => { + sync(false); + const instance = calendar(); + if (clearPendingRange) instance?.clearPendingRange?.(); + instance?.resetTrackPosition?.(); + }; + + const open = () => { + sync(true); + stabilize(); + }; + + return { + sync, + open, + close, + stabilize, + }; +} diff --git a/js/ui/mealCalendar.js b/js/ui/mealCalendar.js index f68b504..2acfde7 100644 --- a/js/ui/mealCalendar.js +++ b/js/ui/mealCalendar.js @@ -1,6 +1,9 @@ import { addDays, + addMonths, + addWeeks, sameDay, + sameMonth, startOfDay, startOfMonth, startOfWeekMonday, @@ -21,10 +24,19 @@ export const CALENDAR_WEEKDAYS_SHORT = ['pn', 'wt', 'śr', 'cz', 'pt', 'so', 'nd export const CALENDAR_DAY_ATTR = 'data-calendar-day'; export const CALENDAR_HANDLE_CLASS = 'block h-1 w-10 rounded-full bg-[rgb(var(--text-subdued-rgb))]/75'; -function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) { +function escapeAttrValue(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/ + style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}"> ${day.getDate()} ${showIndicator @@ -75,12 +101,13 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) { `; } -function getMonthCells(monthAnchor) { +export function getCalendarMonthCells(monthAnchor, { fixedWeekCount = null } = {}) { const first = startOfMonth(monthAnchor); const startGrid = startOfWeekMonday(first); const cells = []; - for (let i = 0; i < 42; i++) cells.push(addDays(startGrid, i)); - while (cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) { + const maxCells = fixedWeekCount ? fixedWeekCount * 7 : 42; + for (let i = 0; i < maxCells; i++) cells.push(addDays(startGrid, i)); + while (!fixedWeekCount && cells.length > 35 && cells.slice(-7).every((day) => day.getMonth() !== first.getMonth())) { cells.splice(-7); } return { cells, month: first.getMonth() }; @@ -99,7 +126,7 @@ function getDayState(day, meta, resolveDayState) { return { disabled: !!resolved.disabled, dimmed: !!resolved.dimmed, - showIndicator: !!resolved.showIndicator, + showIndicator: !!(resolved.showIndicator || resolved.showDot), }; } @@ -116,8 +143,8 @@ export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT export function createCalendarTopbarHTML({ todayId, wrapperClass = 'px-4 pt-4 pb-3 flex items-center justify-end', - controlsStyle = 'background:rgb(var(--card-soft-rgb));border-color:rgb(var(--card-strong-rgb));', - todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap', + controlsStyle = 'background:transparent;border-color:rgb(var(--card-strong-rgb));', + todayButtonActiveClass = 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 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-3 text-[10px] font-semibold leading-none text-[rgb(var(--text-faint-rgb))] cursor-default', }) { return ` @@ -133,6 +160,45 @@ export function createCalendarTopbarHTML({ `; } +export function createCollapsibleCalendarHTML({ + idPrefix = 'calendar', + swipeZoneId = `${idPrefix}-swipe-zone`, + weekWrapId = `${idPrefix}-week-wrap`, + weekGridId = `${idPrefix}-week-grid`, + monthWrapId = `${idPrefix}-month-wrap`, + monthGridId = `${idPrefix}-month-grid`, + toggleId = `${idPrefix}-mode-toggle`, + iconId = `${idPrefix}-handle-icon`, + wrapperClass = 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]', + wrapperStyle = 'touch-action: none', + weekWrapClass = 'px-3 overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]', + weekWrapStyle = 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem', + monthWrapClass = 'px-3 bg-[rgb(var(--app-bg-rgb))]', + monthWrapStyle = 'overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0', + weekGridClass = 'grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden', + monthGridClass = 'grid grid-cols-7 gap-1.5', + toggleClass = 'w-full flex items-center justify-center py-1 pb-2 pt-0.5 text-[rgb(var(--text-faint-rgb))] hover:text-[rgb(var(--text-body-soft-rgb))] transition-colors', + toggleLabel = 'Przełącz widok kalendarza', + weekdayLabels = CALENDAR_WEEKDAYS_SHORT, + weekdayHeaderOptions = {}, +} = {}) { + return ` +
+
+ ${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)} +
+
+
+ ${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)} +
+
+ +
+ `; +} + export function formatCalendarMonthYear(date) { return `${CALENDAR_MONTHS_LONG[date.getMonth()]} ${date.getFullYear()}`; } @@ -141,6 +207,27 @@ export function formatCalendarSelectedDate(date) { return `${date.getDate()} ${CALENDAR_MONTHS_SHORT[date.getMonth()]} ${date.getFullYear()}`; } +export function formatCalendarWeekRange(weekAnchorDate) { + const start = startOfWeekMonday(weekAnchorDate); + const end = addDays(start, 6); + const sameYear = start.getFullYear() === end.getFullYear(); + const sameMonth = sameYear && start.getMonth() === end.getMonth(); + + if (sameMonth) { + return `${start.getDate()}–${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`; + } + if (sameYear) { + return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} – ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`; + } + return `${start.getDate()} ${CALENDAR_MONTHS_SHORT[start.getMonth()]} ${start.getFullYear()} – ${end.getDate()} ${CALENDAR_MONTHS_SHORT[end.getMonth()]} ${end.getFullYear()}`; +} + +export function formatCalendarPeriodLabel(mode, weekAnchorDate, monthAnchorDate) { + return mode === 'week' + ? formatCalendarWeekRange(weekAnchorDate) + : formatCalendarMonthYear(monthAnchorDate); +} + export function isCalendarOnToday(mode, weekStart, monthAnchor, selectedDate) { const today = startOfDay(new Date()); if (!sameDay(selectedDate, today)) return false; @@ -157,10 +244,13 @@ export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, optio const { ariaLabelGo = 'Przejdź do dzisiejszego dnia', ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres', + labelText, } = options; const active = buttonEl.dataset.calActiveClass - || 'h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap'; - if (selectedDate != null) { + || 'h-full shrink-0 inline-flex min-w-[7.25rem] max-w-[12.5rem] items-center justify-center rounded-full bg-transparent px-3 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap'; + if (labelText != null) { + buttonEl.textContent = labelText; + } else if (selectedDate != null) { buttonEl.textContent = formatCalendarSelectedDate(selectedDate); } buttonEl.className = active; @@ -174,8 +264,13 @@ export function renderCalendarGrid({ mode, anchorDate, selectedDate, + isSelectedDate, resolveDayState, dayAttr = CALENDAR_DAY_ATTR, + getDayAttrValue, + dayClassName = '', + dayStyle = '', + fixedWeekCount = null, theme, }) { if (!gridEl) return; @@ -185,25 +280,48 @@ export function renderCalendarGrid({ const cells = []; for (let i = 0; i < 7; i++) { const day = addDays(weekStart, i); + const selected = typeof isSelectedDate === 'function' + ? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth: true }) + : !!(selectedDate && sameDay(day, selectedDate)); const meta = { mode, selectedDate, inCurrentMonth: true, + isSelected: selected, }; - cells.push(getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme)); + cells.push(getCalendarDayHTML( + day, + meta, + getDayState(day, meta, resolveDayState), + dayAttr, + theme, + { getDayAttrValue, dayClassName, dayStyle }, + )); } gridEl.innerHTML = cells.join(''); return; } - const { cells, month } = getMonthCells(anchorDate); + const { cells, month } = getCalendarMonthCells(anchorDate, { fixedWeekCount }); gridEl.innerHTML = cells.map((day) => { + const inCurrentMonth = day.getMonth() === month; + const selected = typeof isSelectedDate === 'function' + ? !!isSelectedDate(day, { mode, selectedDate, inCurrentMonth }) + : !!(selectedDate && sameDay(day, selectedDate)); const meta = { mode, selectedDate, - inCurrentMonth: day.getMonth() === month, + inCurrentMonth, + isSelected: selected, }; - return getCalendarDayHTML(day, meta, getDayState(day, meta, resolveDayState), dayAttr, theme); + return getCalendarDayHTML( + day, + meta, + getDayState(day, meta, resolveDayState), + dayAttr, + theme, + { getDayAttrValue, dayClassName, dayStyle }, + ); }).join(''); } @@ -215,6 +333,7 @@ export function renderCollapsibleCalendar({ selectedDate, resolveDayState, dayAttr = CALENDAR_DAY_ATTR, + theme, }) { renderCalendarGrid({ gridEl: weekGridEl, @@ -223,6 +342,7 @@ export function renderCollapsibleCalendar({ selectedDate, resolveDayState, dayAttr, + theme, }); renderCalendarGrid({ gridEl: monthGridEl, @@ -231,6 +351,7 @@ export function renderCollapsibleCalendar({ selectedDate, resolveDayState, dayAttr, + theme, }); } @@ -256,6 +377,253 @@ export function syncCollapsibleCalendarMode({ if (handleEl) handleEl.className = CALENDAR_HANDLE_CLASS; } +export function syncCollapsibleCalendarToggleIcon(iconEl, mode) { + if (iconEl) iconEl.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]'; +} + +export function bindCollapsibleCalendarSwipeGesture({ + zoneEl, + weekWrapEl, + monthWrapEl, + getMode, + setMode, + getWeekAnchor, + setWeekAnchor, + getMonthAnchor, + setMonthAnchor, + getSelectedDate, + setSelectedDate, + rerender, + resolveDayState, + dayAttr = CALENDAR_DAY_ATTR, + theme, + selectOnNavigateOutside = true, + enableVerticalModeSwipe = true, + threshold = 40, + animationMs = 260, +} = {}) { + if (!zoneEl) return () => {}; + + 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 = () => (getMode?.() === 'week' ? weekWrapEl : monthWrapEl); + + 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((grid) => { + if (!grid.classList.contains('text-center')) ghostGridEl = grid; + }); + if (ghostGridEl) { + renderCalendarGrid({ + gridEl: ghostGridEl, + mode, + anchorDate, + selectedDate: getSelectedDate?.(), + resolveDayState, + dayAttr, + theme, + }); + } + return ghost; + }; + + const activateCarousel = () => { + const mode = getMode?.() || 'week'; + dragWrap = getActiveWrap(); + if (!dragWrap) return; + wrapWidth = dragWrap.getBoundingClientRect().width || zoneEl.getBoundingClientRect().width; + if (wrapWidth <= 0) return; + prevWrapPosition = dragWrap.style.position; + prevWrapOverflow = dragWrap.style.overflow; + dragWrap.style.position = 'relative'; + dragWrap.style.overflow = 'visible'; + + const prevAnchor = mode === 'week' + ? addWeeks(getWeekAnchor?.() || new Date(), -1) + : addMonths(getMonthAnchor?.() || new Date(), -1); + const nextAnchor = mode === 'week' + ? addWeeks(getWeekAnchor?.() || new Date(), 1) + : addMonths(getMonthAnchor?.() || new Date(), 1); + + prevGhost = buildGhost(prevAnchor, mode); + nextGhost = buildGhost(nextAnchor, 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)`; + }; + + const onPointerDown = (event) => { + if (ptrId !== null || animatingNav) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + startX = event.clientX; + startY = event.clientY; + ptrId = event.pointerId; + moved = false; + axisLocked = null; + try { zoneEl.setPointerCapture(event.pointerId); } catch (_) {} + }; + + const onPointerMove = (event) => { + if (event.pointerId !== ptrId) return; + const dx = event.clientX - startX; + const dy = event.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); + } + }; + + const onPointerUp = (event) => { + if (event.pointerId !== ptrId) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + ptrId = null; + + if (!moved) return; + const horizontal = axisLocked === 'x'; + + if (horizontal && dragWrap) { + if (Math.abs(dx) < threshold) { + setTranslate(0, animationMs); + setTimeout(clearCarousel, animationMs + 20); + return; + } + + suppressClickUntil = performance.now() + 500; + animatingNav = true; + const targetX = dx > 0 ? wrapWidth : -wrapWidth; + setTranslate(targetX, animationMs); + setTimeout(() => { + const mode = getMode?.() || 'week'; + const sign = dx > 0 ? -1 : 1; + const selected = getSelectedDate?.(); + if (mode === 'week') { + const nextWeek = addWeeks(getWeekAnchor?.() || selected || new Date(), sign); + setWeekAnchor?.(nextWeek); + if (selectOnNavigateOutside && selected && !weekContains(nextWeek, selected)) { + setSelectedDate?.(new Date(nextWeek)); + } + } else { + const nextMonth = addMonths(getMonthAnchor?.() || selected || new Date(), sign); + setMonthAnchor?.(nextMonth); + if (selectOnNavigateOutside && selected && !sameMonth(nextMonth, selected)) { + setSelectedDate?.(startOfMonth(nextMonth)); + } + } + clearCarousel(); + rerender?.(); + animatingNav = false; + }, animationMs); + return; + } + + if (enableVerticalModeSwipe && !horizontal && Math.abs(dy) >= 30) { + const mode = getMode?.() || 'week'; + const selected = getSelectedDate?.() || new Date(); + let triggered = false; + if (mode === 'week' && dy > 0) { + setMode?.('month'); + setMonthAnchor?.(startOfMonth(selected)); + triggered = true; + } else if (mode === 'month' && dy < 0) { + setMode?.('week'); + setWeekAnchor?.(startOfWeekMonday(selected)); + triggered = true; + } + if (triggered) { + suppressClickUntil = performance.now() + 350; + rerender?.(); + } + } + }; + + const onClickCapture = (event) => { + if (performance.now() < suppressClickUntil) { + event.stopPropagation(); + event.preventDefault(); + suppressClickUntil = 0; + } + }; + + const onPointerCancel = () => { + ptrId = null; + moved = false; + if (dragWrap) { + setTranslate(0, animationMs); + setTimeout(clearCarousel, animationMs + 20); + } + }; + + zoneEl.addEventListener('pointerdown', onPointerDown); + zoneEl.addEventListener('pointermove', onPointerMove); + zoneEl.addEventListener('pointerup', onPointerUp); + zoneEl.addEventListener('click', onClickCapture, { capture: true }); + zoneEl.addEventListener('pointercancel', onPointerCancel); + + return () => { + zoneEl.removeEventListener('pointerdown', onPointerDown); + zoneEl.removeEventListener('pointermove', onPointerMove); + zoneEl.removeEventListener('pointerup', onPointerUp); + zoneEl.removeEventListener('click', onClickCapture, { capture: true }); + zoneEl.removeEventListener('pointercancel', onPointerCancel); + if (dragWrap) clearCarousel(); + }; +} + export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_DAY_ATTR) { if (!containerEl || typeof onSelect !== 'function') return; containerEl.addEventListener('click', (event) => { diff --git a/js/ui/mealPlanEditor.js b/js/ui/mealPlanEditor.js index 453a5c7..b233782 100644 --- a/js/ui/mealPlanEditor.js +++ b/js/ui/mealPlanEditor.js @@ -1,10 +1,7 @@ import { INGREDIENTS, RECIPES, PRODUCTS, getProductsForIngredient } from '../data/catalog.js?v=9'; import { MEAL_SLOTS } from '../planner/mealSlots.js'; import { - addDays, - addMonths, sameDay, - sameMonth, startOfDay, startOfWeekMonday, } from '../services/dateUtils.js'; @@ -17,14 +14,17 @@ import { import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../services/planIngredients.js?v=4'; import { loadPantry } from '../services/pantryShopping.js?v=2'; import { + bindCollapsibleCalendarSwipeGesture, bindCalendarDayClicks, - bindCalendarHorizontalSwipe, + createCollapsibleCalendarHTML, createCalendarTopbarHTML, - createCalendarWeekdayHeaderHTML, + formatCalendarPeriodLabel, isCalendarOnToday, - renderCalendarGrid, + renderCollapsibleCalendar, syncCalendarTodayButton, -} from './mealCalendar.js?v=14'; + syncCollapsibleCalendarMode, + syncCollapsibleCalendarToggleIcon, +} from './mealCalendar.js?v=15'; import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116'; function esc(s) { @@ -52,25 +52,38 @@ export function getMealPlanEditorHTML() { -