Swipeable calendar
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m15s

This commit is contained in:
2026-04-20 11:33:43 +02:00
parent 070a0a61db
commit 570e44257f
7 changed files with 919 additions and 307 deletions

View File

@@ -682,7 +682,7 @@
</div>
<script>
const APP_ASSET_VERSION = '20260417-svg6';
const APP_ASSET_VERSION = '20260417-svg7';
const APP_VERSION_STORAGE_KEY = 'recipe-app-asset-version';
const APP_VERSION_QUERY_KEY = 'appv';

View File

@@ -114,29 +114,20 @@ export function createCalendarWeekdayHeaderHTML(labels = CALENDAR_WEEKDAYS_SHORT
}
export function createCalendarTopbarHTML({
prevId,
todayId,
nextId,
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));',
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',
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',
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 `
<div class="${wrapperClass}">
<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="${controlsStyle}">
<button type="button" id="${prevId}" class="${navButtonClass}" aria-label="Poprzedni okres">
<i class="fas fa-chevron-left text-[10px]" aria-hidden="true"></i>
</button>
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="${controlsStyle}">
<button type="button" id="${todayId}"
class="${todayButtonActiveClass}"
data-cal-active-class="${todayButtonActiveClass}"
data-cal-dim-class="${todayButtonDimClass}">
</button>
<button type="button" id="${nextId}" class="${navButtonClass}" aria-label="Następny okres">
<i class="fas fa-chevron-right text-[10px]" aria-hidden="true"></i>
</button>
</div>
</div>
`;
@@ -168,7 +159,7 @@ export function syncCalendarTodayButton(buttonEl, isOnToday, selectedDate, optio
ariaLabelCurrent = 'Widok jest ustawiony na bieżący okres',
} = 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-1.5 text-[10px] font-semibold leading-none tabular-nums text-[rgb(var(--text-body-soft-rgb))] active:bg-transparent whitespace-nowrap';
|| '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) {
buttonEl.textContent = formatCalendarSelectedDate(selectedDate);
}
@@ -275,3 +266,208 @@ export function bindCalendarDayClicks(containerEl, onSelect, dayAttr = CALENDAR_
onSelect(new Date(ts), button, event);
});
}
/**
* Binds a carousel-style horizontal swipe on zoneEl. Swipe right → onPrev,
* swipe left → onNext. Pass `renderGhost(ghostGridEl, direction)` to render
* adjacent periods that appear alongside the zone during the gesture. The
* callback can return `false` to block that direction (ghost not added).
* Returns an unbind function.
*/
export function bindCalendarHorizontalSwipe(zoneEl, {
onPrev,
onNext,
renderGhost,
threshold = 40,
animationMs = 260,
} = {}) {
if (!zoneEl) return () => {};
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axisLocked = null;
let suppressClickUntil = 0;
let animatingNav = false;
let wrapWidth = 0;
let prevGhost = null;
let nextGhost = null;
let savedStyles = null;
let carouselActive = false;
const prevTouchAction = zoneEl.style.touchAction;
const prevUserSelect = zoneEl.style.userSelect;
zoneEl.style.touchAction = 'pan-y';
zoneEl.style.userSelect = 'none';
const buildGhost = (direction) => {
if (typeof renderGhost !== 'function') return null;
const ghost = zoneEl.cloneNode(false);
ghost.removeAttribute('id');
ghost.style.position = 'absolute';
ghost.style.top = '0';
ghost.style.width = '100%';
ghost.style.pointerEvents = 'none';
ghost.setAttribute('aria-hidden', 'true');
let ok = true;
try {
const res = renderGhost(ghost, direction);
if (res === false) ok = false;
} catch (_) { ok = false; }
return ok ? ghost : null;
};
const activateCarousel = () => {
if (typeof renderGhost !== 'function') return;
wrapWidth = zoneEl.getBoundingClientRect().width;
if (wrapWidth <= 0) return;
const parentEl = zoneEl.parentElement;
savedStyles = {
position: zoneEl.style.position,
overflow: zoneEl.style.overflow,
parentEl,
parentOverflow: parentEl ? parentEl.style.overflow : '',
};
zoneEl.style.position = 'relative';
zoneEl.style.overflow = 'visible';
if (parentEl) parentEl.style.overflow = 'hidden';
prevGhost = buildGhost('prev');
if (prevGhost) {
prevGhost.style.left = `-${wrapWidth}px`;
zoneEl.appendChild(prevGhost);
}
nextGhost = buildGhost('next');
if (nextGhost) {
nextGhost.style.left = `${wrapWidth}px`;
zoneEl.appendChild(nextGhost);
}
zoneEl.style.willChange = 'transform';
zoneEl.style.transition = 'none';
carouselActive = true;
};
const clearCarousel = () => {
if (prevGhost?.parentNode) prevGhost.parentNode.removeChild(prevGhost);
if (nextGhost?.parentNode) nextGhost.parentNode.removeChild(nextGhost);
prevGhost = null;
nextGhost = null;
if (savedStyles) {
zoneEl.style.position = savedStyles.position;
zoneEl.style.overflow = savedStyles.overflow;
if (savedStyles.parentEl) savedStyles.parentEl.style.overflow = savedStyles.parentOverflow;
savedStyles = null;
}
zoneEl.style.transition = '';
zoneEl.style.transform = '';
zoneEl.style.willChange = '';
carouselActive = false;
};
const setTranslate = (x, ms) => {
zoneEl.style.transition = ms ? `transform ${ms}ms ease` : 'none';
zoneEl.style.transform = `translate3d(${x}px, 0, 0)`;
};
const onDown = (e) => {
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 { zoneEl.setPointerCapture(e.pointerId); } catch (_) {}
if (e.pointerType === 'mouse') e.preventDefault();
};
const onMove = (e) => {
if (e.pointerId !== ptrId) return;
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' && carouselActive) {
let tx = dx;
if (dx > 0 && !prevGhost) tx = dx * 0.15;
if (dx < 0 && !nextGhost) tx = dx * 0.15;
setTranslate(tx, 0);
}
};
const onUp = (e) => {
if (e.pointerId !== ptrId) return;
const dx = e.clientX - startX;
ptrId = null;
if (!moved || axisLocked !== 'x' || !carouselActive) {
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
return;
}
const directionGhost = dx > 0 ? prevGhost : nextGhost;
const handler = dx > 0 ? onPrev : onNext;
const passes = Math.abs(dx) >= threshold
&& directionGhost
&& typeof handler === 'function';
if (!passes) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
return;
}
suppressClickUntil = performance.now() + 500;
animatingNav = true;
const targetX = dx > 0 ? wrapWidth : -wrapWidth;
setTranslate(targetX, animationMs);
setTimeout(() => {
clearCarousel();
handler();
animatingNav = false;
}, animationMs);
};
const onClickCapture = (ev) => {
if (performance.now() < suppressClickUntil) {
ev.stopPropagation();
ev.preventDefault();
suppressClickUntil = 0;
}
};
const onCancel = () => {
ptrId = null;
moved = false;
if (carouselActive) {
setTranslate(0, animationMs);
setTimeout(clearCarousel, animationMs + 20);
}
};
zoneEl.addEventListener('pointerdown', onDown);
zoneEl.addEventListener('pointermove', onMove);
zoneEl.addEventListener('pointerup', onUp);
zoneEl.addEventListener('pointercancel', onCancel);
zoneEl.addEventListener('click', onClickCapture, { capture: true });
return () => {
zoneEl.removeEventListener('pointerdown', onDown);
zoneEl.removeEventListener('pointermove', onMove);
zoneEl.removeEventListener('pointerup', onUp);
zoneEl.removeEventListener('pointercancel', onCancel);
zoneEl.removeEventListener('click', onClickCapture, { capture: true });
zoneEl.style.touchAction = prevTouchAction;
zoneEl.style.userSelect = prevUserSelect;
if (carouselActive) clearCarousel();
};
}

View File

@@ -18,12 +18,13 @@ import { dayHasAnyMeal, autoSelectProducts, saveLastProductSelection } from '../
import { loadPantry } from '../services/pantryShopping.js?v=2';
import {
bindCalendarDayClicks,
bindCalendarHorizontalSwipe,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
isCalendarOnToday,
renderCalendarGrid,
syncCalendarTodayButton,
} from './mealCalendar.js?v=11';
} from './mealCalendar.js?v=14';
import { createIngredientCardController, getIngredientCardHTML } from './ingredientCard.js?v=20260417-116';
function esc(s) {
@@ -54,9 +55,7 @@ export function getMealPlanEditorHTML() {
<div id="mpe-cal-wrap" class="hidden relative z-[1] shrink-0 px-5 pt-3 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-cal-section" class="hidden">
${createCalendarTopbarHTML({
prevId: 'mpe-cal-prev',
todayId: 'mpe-cal-today',
nextId: 'mpe-cal-next',
wrapperClass: 'mb-2 flex items-center justify-end gap-3',
})}
${createCalendarWeekdayHeaderHTML()}
@@ -618,15 +617,44 @@ export function setupMealPlanEditor() {
renderCal();
});
document.getElementById('mpe-cal-prev')?.addEventListener('click', () => {
bindCalendarHorizontalSwipe(document.getElementById('mpe-cal-grid'), {
onPrev: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
renderCal();
});
document.getElementById('mpe-cal-next')?.addEventListener('click', () => {
},
onNext: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7);
renderCal();
},
renderGhost: (ghostGrid, direction) => {
if (!S.showCal) return false;
const sign = direction === 'prev' ? -1 : 1;
const mode = S.calExpanded ? 'month' : 'week';
const anchor = S.calExpanded
? addMonths(S.calDate, sign)
: addDays(S.calDate, 7 * sign);
const today = startOfDay(new Date());
const plans = loadPlans();
renderCalendarGrid({
gridEl: ghostGrid,
mode,
anchorDate: anchor,
selectedDate: S.date,
resolveDayState: (day, meta) => {
const isSelected = S.date && sameDay(day, S.date);
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(plans, day)
: dayHasAnyMeal(plans, day),
};
},
});
},
});
document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());

View File

@@ -0,0 +1,377 @@
import { addDays, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const DEFAULT_MONTHS_LONG = [
'Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec',
'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień',
];
const DEFAULT_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: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.58,
dot: 'rgb(var(--text-faint-rgb))',
};
function dateKeyLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function keyToDate(key) {
return new Date(`${key}T00:00:00`);
}
function normalizeMonth(date) {
return startOfMonth(date instanceof Date ? date : new Date());
}
function monthLabel(date, monthsLong) {
return `${monthsLong[date.getMonth()]} ${date.getFullYear()}`;
}
function dayRange(aKey, bKey) {
const a = keyToDate(aKey);
const b = keyToDate(bKey);
const [from, to] = a <= b ? [a, b] : [b, a];
const out = [];
for (let d = new Date(from); d <= to; d = addDays(d, 1)) out.push(dateKeyLocal(d));
return out;
}
export function createSwipePopoverCalendarHTML({
idPrefix,
weekdays = DEFAULT_WEEKDAYS,
monthLabelTextClass = 'text-[10px] font-semibold leading-none tabular-nums whitespace-nowrap',
}) {
const weekdayHeader = `
<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));">
${weekdays.map((d) => `<div>${d}</div>`).join('')}
</div>
`;
const gripLeft = `
<div data-swc-grip aria-hidden="true" style="position:absolute; left:0; top:50%; transform:translateY(-50%); width:0.76rem; height:1rem; pointer-events:none; opacity:0.66;">
<span style="position:absolute; left:0; top:50%; transform:translateY(-50%); width:1.5px; height:1.22rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.84);"></span>
<span style="position:absolute; left:0.16rem; top:50%; transform:translateY(-50%); width:1.5px; height:1.0rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.74);"></span>
<span style="position:absolute; left:0.34rem; top:50%; transform:translateY(-50%); width:1.5px; height:0.56rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.62);"></span>
</div>
`;
const gripRight = `
<div data-swc-grip aria-hidden="true" style="position:absolute; right:0; top:50%; transform:translateY(-50%); width:0.76rem; height:1rem; pointer-events:none; opacity:0.66;">
<span style="position:absolute; right:0; top:50%; transform:translateY(-50%); width:1.5px; height:1.22rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.84);"></span>
<span style="position:absolute; right:0.16rem; top:50%; transform:translateY(-50%); width:1.5px; height:1.0rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.74);"></span>
<span style="position:absolute; right:0.34rem; top:50%; transform:translateY(-50%); width:1.5px; height:0.56rem; border-radius:999px; background:rgba(var(--text-faint-rgb),0.62);"></span>
</div>
`;
return `
<div class="pb-3 px-3 flex items-center justify-end gap-3">
<div class="flex h-[2.05rem] min-w-0 max-w-[min(100%,20rem)] items-center justify-center rounded-full border" style="background:rgb(var(--app-bg-rgb)); border-color:rgb(var(--card-raised-rgb));">
<span id="${idPrefix}-month-label" class="h-full shrink-0 inline-flex min-w-[5.75rem] max-w-[9rem] items-center justify-center px-3 ${monthLabelTextClass}" style="color:rgb(var(--text-body-soft-rgb));"></span>
</div>
</div>
<div id="${idPrefix}-viewport" style="overflow:hidden; position:relative; touch-action:pan-y;">
<div id="${idPrefix}-track" style="display:flex; align-items:flex-start; position:relative; will-change:transform;">
<div class="swc-panel" data-panel="prev" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-prev-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div>
<div class="swc-panel" data-panel="current" style="flex-shrink:0; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div>
<div class="swc-panel" data-panel="next" style="flex-shrink:0; pointer-events:none; position:relative; overflow:hidden;">
${weekdayHeader}
<div id="${idPrefix}-next-grid" class="grid grid-cols-7 gap-1.5"></div>
${gripLeft}
${gripRight}
</div>
</div>
</div>
`;
}
export function initSwipePopoverCalendar({
idPrefix,
selectionMode = 'single',
monthsLong = DEFAULT_MONTHS_LONG,
theme = DEFAULT_THEME,
getMonthAnchor,
setMonthAnchor,
getSelectionKeys,
onSelectionCommit,
resolveDayState,
panelHandlePx = null,
panelHandleRatio = 0.045,
panelHandleMin = 10,
panelHandleMax = 16,
}) {
const monthLabelEl = document.getElementById(`${idPrefix}-month-label`);
const gridEl = document.getElementById(`${idPrefix}-grid`);
const prevGridEl = document.getElementById(`${idPrefix}-prev-grid`);
const nextGridEl = document.getElementById(`${idPrefix}-next-grid`);
const track = document.getElementById(`${idPrefix}-track`);
const viewport = document.getElementById(`${idPrefix}-viewport`);
if (!gridEl || !track || !viewport) {
return {
render: () => {},
reapplyLayout: () => {},
resetTrackPosition: () => {},
};
}
const MOVE_THRESHOLD = 6;
const SWIPE_THRESHOLD = 40;
const ANIMATION_MS = 260;
let viewportWidth = 0;
let panelWidth = 0;
let panelHandle = 0;
let dragHandleWidth = 0;
let restOffset = 0;
let animatingNav = false;
const panels = Array.from(track.querySelectorAll('.swc-panel'));
const syncGripVisibility = (showAdjacent = false) => {
panels.forEach((panel) => {
const isCurrent = panel.dataset.panel === 'current';
panel.querySelectorAll('[data-swc-grip]').forEach((grip) => {
grip.style.opacity = (isCurrent || showAdjacent) ? '0.66' : '0';
});
});
};
const applyLayout = () => {
const vw = viewport.clientWidth || viewport.getBoundingClientRect().width;
if (!vw) return;
viewportWidth = vw;
const computedHandle = panelHandlePx == null
? Math.round(vw * panelHandleRatio)
: panelHandlePx;
panelHandle = Math.max(panelHandleMin, Math.min(panelHandleMax, computedHandle));
dragHandleWidth = panelHandle;
panelWidth = vw;
restOffset = -panelWidth;
panels.forEach((panel) => {
panel.style.width = `${panelWidth}px`;
panel.style.boxSizing = 'border-box';
panel.style.paddingLeft = `${panelHandle}px`;
panel.style.paddingRight = `${panelHandle}px`;
});
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
syncGripVisibility(false);
};
const resetTrackPosition = () => {
track.style.transition = 'none';
track.style.transform = `translate3d(${restOffset}px, 0, 0)`;
syncGripVisibility(false);
};
const setDragTranslate = (dx, ms) => {
track.style.transition = ms ? `transform ${ms}ms ease` : 'none';
track.style.transform = `translate3d(${restOffset + dx}px, 0, 0)`;
};
const getSelectedSet = (previewSelection = null) => {
const raw = previewSelection ?? getSelectionKeys();
if (Array.isArray(raw)) return new Set(raw);
if (typeof raw === 'string' && raw) return new Set([raw]);
return new Set();
};
const renderMonthGrid = (targetGrid, monthAnchor, selectedSet) => {
if (!targetGrid) return;
const first = startOfMonth(monthAnchor);
const gridStart = startOfWeekMonday(first);
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
targetGrid.innerHTML = cells.map((day) => {
const dk = dateKeyLocal(day);
const inCurrentMonth = day.getMonth() === first.getMonth();
const isSelected = selectedSet.has(dk);
const resolved = (typeof resolveDayState === 'function'
? resolveDayState(day, { inCurrentMonth, isSelected })
: {}) || {};
const disabled = !!resolved.disabled;
const dimmed = !!resolved.dimmed;
const showDot = !!resolved.showDot;
let bg;
let borderColor;
let text;
let borderClass = 'border';
if (isSelected) {
bg = theme.selectedBg;
borderColor = theme.selectedBorder;
text = theme.selectedText;
} else if (dimmed) {
bg = theme.dimmedBg;
borderColor = 'transparent';
text = theme.dimText;
borderClass = 'border-0';
} else {
bg = theme.bg;
borderColor = theme.border;
text = theme.text;
}
const opacity = dimmed && !isSelected ? String(theme.dimOpacity ?? 0.58) : '1';
const dotColor = isSelected ? theme.selectedDot : theme.dot;
const tag = disabled ? 'div' : 'button';
const attrs = disabled ? '' : `type="button" data-dk="${dk}"`;
const dayClass = disabled ? '' : ' swc-day';
return `
<${tag} ${attrs}
class="mx-auto flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${dayClass} text-xs font-medium leading-tight overflow-hidden"
style="background:${bg}; border-color:${borderColor}; color:${text}; opacity:${opacity}; touch-action:none;">
<span class="relative flex h-full w-full flex-col items-center justify-center">
<span class="text-[13px] font-semibold leading-none ${showDot ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${showDot ? `<span class="absolute left-1/2 w-1 h-1 -translate-x-1/2 rounded-full opacity-75" style="bottom:0.24rem; background:${dotColor};" aria-hidden="true"></span>` : ''}
</span>
</${tag}>
`;
}).join('');
};
const render = (previewSelection = null) => {
const anchor = normalizeMonth(getMonthAnchor());
const selectedSet = getSelectedSet(previewSelection);
if (monthLabelEl) monthLabelEl.textContent = monthLabel(anchor, monthsLong);
renderMonthGrid(gridEl, anchor, selectedSet);
renderMonthGrid(prevGridEl, new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1), selectedSet);
renderMonthGrid(nextGridEl, new Date(anchor.getFullYear(), anchor.getMonth() + 1, 1), selectedSet);
applyLayout();
};
const commitNavigation = (monthDelta) => {
animatingNav = true;
const targetDx = monthDelta < 0 ? panelWidth : -panelWidth;
setDragTranslate(targetDx, ANIMATION_MS);
setTimeout(() => {
const anchor = normalizeMonth(getMonthAnchor());
setMonthAnchor(startOfMonth(new Date(anchor.getFullYear(), anchor.getMonth() + monthDelta, 1)));
render();
resetTrackPosition();
animatingNav = false;
}, ANIMATION_MS);
};
if (selectionMode === 'range') {
let dragStart = null;
let dragCurrent = null;
let dragging = false;
gridEl.addEventListener('pointerdown', (e) => {
if (animatingNav) return;
const btn = e.target.closest('.swc-day');
if (!btn) return;
e.stopPropagation();
dragStart = btn.dataset.dk;
dragCurrent = btn.dataset.dk;
dragging = true;
gridEl.setPointerCapture(e.pointerId);
render([dragStart]);
});
gridEl.addEventListener('pointermove', (e) => {
if (!dragging || animatingNav) return;
e.preventDefault();
const el = document.elementFromPoint(e.clientX, e.clientY);
const btn = el?.closest('.swc-day');
if (btn?.dataset.dk && btn.dataset.dk !== dragCurrent) {
dragCurrent = btn.dataset.dk;
render(dayRange(dragStart, dragCurrent));
}
});
gridEl.addEventListener('pointerup', () => {
if (!dragging) return;
dragging = false;
const range = dayRange(dragStart, dragCurrent);
dragStart = null;
dragCurrent = null;
if (typeof onSelectionCommit === 'function') onSelectionCommit(range);
render();
});
gridEl.addEventListener('pointercancel', () => {
dragging = false;
dragStart = null;
dragCurrent = null;
render();
});
} else {
gridEl.addEventListener('click', (e) => {
const btn = e.target.closest('.swc-day');
if (!btn) return;
if (typeof onSelectionCommit === 'function') onSelectionCommit(btn.dataset.dk);
render();
});
}
let ptrId = null;
let startX = 0;
let startY = 0;
let moved = false;
let axis = null;
viewport.addEventListener('pointerdown', (e) => {
if (animatingNav || ptrId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (!panelWidth) applyLayout();
const rect = viewport.getBoundingClientRect();
const localX = e.clientX - rect.left;
const inLeft = localX <= dragHandleWidth;
const inRight = localX >= (viewportWidth - dragHandleWidth);
if (!inLeft && !inRight) return;
ptrId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
axis = null;
try { viewport.setPointerCapture(e.pointerId); } catch (_) {}
});
viewport.addEventListener('pointermove', (e) => {
if (e.pointerId !== ptrId || animatingNav) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD)) {
moved = true;
axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
if (axis === 'x') syncGripVisibility(true);
}
if (axis === 'x') setDragTranslate(dx, 0);
});
const endGesture = (e) => {
if (e && e.pointerId !== ptrId) return;
ptrId = null;
if (!moved || axis !== 'x') return;
const dx = e ? e.clientX - startX : 0;
if (Math.abs(dx) >= SWIPE_THRESHOLD) commitNavigation(dx > 0 ? -1 : 1);
else {
setDragTranslate(0, ANIMATION_MS);
setTimeout(() => syncGripVisibility(false), ANIMATION_MS + 20);
}
moved = false;
axis = null;
};
viewport.addEventListener('pointerup', endGesture);
viewport.addEventListener('pointercancel', endGesture);
window.addEventListener('resize', applyLayout);
return { render, reapplyLayout: applyLayout, resetTrackPosition };
}

View File

@@ -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) {
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 (!horizontal && Math.abs(dy) >= 30) {
let triggered = false;
if (state.mode === 'week' && dy > 0) {
state.mode = 'month';
state.monthAnchor = startOfMonth(state.selected);
switched = true;
} else if (state.mode === 'month' && dy < -30) {
triggered = true;
} else if (state.mode === 'month' && dy < 0) {
state.mode = 'week';
state.weekStart = startOfWeekMonday(state.selected);
switched = true;
triggered = true;
}
if (switched) {
zone.addEventListener('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}, { capture: true, once: 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;

View File

@@ -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;

View File

@@ -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]));
});
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);
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();
renderCalendarGrid();
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('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();