Unify calendar code
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m16s

This commit is contained in:
2026-04-20 23:44:18 +02:00
parent c43b3766cd
commit 08a275093c
7 changed files with 783 additions and 486 deletions

167
js/ui/calendarPopover.js Normal file
View File

@@ -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
? `<div class="${panelClass}" style="${panelStyle}">${calendarHTML}</div>`
: calendarHTML;
return `
<div id="${id}" class="${popoverClass}" style="${popoverStyle}">
${body}
</div>
`;
}
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,
};
}

View File

@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}, options = {}) {
const { mode, selectedDate } = meta;
const isSelected = selectedDate && sameDay(day, selectedDate);
const showIndicator = !!dayState.showIndicator;
const isSelected = typeof meta.isSelected === 'boolean'
? meta.isSelected
: !!(selectedDate && sameDay(day, selectedDate));
const showIndicator = !!(dayState.showIndicator || dayState.showDot);
const isDisabled = !!dayState.disabled;
const isDimmed = !!dayState.dimmed && !isSelected;
const defaultBg = 'rgb(var(--card-soft-rgb))';
@@ -34,16 +46,25 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
let bg;
let borderColor;
let text;
let borderClass = 'border';
let borderClass = theme.borderClass || 'border';
let shadow = theme.shadow || 'none';
if (isSelected) {
bg = theme.selectedBg || 'rgb(var(--card-rgb))';
const keepDimmedBg = !!dayState.dimmed
&& theme.selectedBg == null
&& theme.selectedUsesDimmedBg !== false;
bg = theme.selectedBg
|| (keepDimmedBg ? (theme.dimmedBg ?? 'transparent') : null)
|| theme.bg
|| 'rgb(var(--card-rgb))';
borderColor = theme.selectedBorder || 'rgb(var(--border-input-rgb))';
text = theme.selectedText || 'rgb(var(--text-emphasis-rgb))';
shadow = theme.selectedShadow || shadow;
borderClass = theme.selectedBorderClass || 'border';
} else if (isDimmed) {
bg = theme.dimmedBg ?? 'transparent';
text = theme.dimText || 'rgb(var(--text-faint-rgb))';
borderClass = 'border-0';
borderClass = theme.dimmedBorderClass || 'border-0';
} else {
bg = theme.bg || defaultBg;
borderColor = theme.border || defaultBorder;
@@ -53,18 +74,23 @@ function getCalendarDayHTML(day, meta, dayState, dayAttr, theme = {}) {
const dot = isSelected ? (theme.selectedDot || 'rgb(var(--text-emphasis-rgb))') : (theme.dot || 'rgb(var(--text-faint-rgb))');
const opacity = isDimmed ? String(theme.dimOpacity ?? 0.72) : '1';
const borderStyle = borderColor ? `border-color:${borderColor};` : 'border:none;';
const outerClass = `${mode === 'month' ? '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 transition-colors leading-tight overflow-hidden`;
const extraClass = !isDisabled && options.dayClassName ? ` ${options.dayClassName}` : '';
const outerClass = `${mode === 'month' ? 'mx-auto ' : ''}flex h-[2.05rem] w-full min-w-0 max-w-full items-center justify-center rounded-full ${borderClass}${extraClass} text-xs font-medium transition-colors leading-tight overflow-hidden`;
const innerClass = mode === 'month'
? 'relative flex h-full w-full flex-col items-center justify-center'
: 'relative flex h-full w-full items-center justify-center';
const dotBottom = mode === 'month' ? '0.24rem' : '0.2rem';
const tagName = isDisabled ? 'div' : 'button';
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${day.getTime()}"`;
const dayAttrValue = typeof options.getDayAttrValue === 'function'
? options.getDayAttrValue(day, meta)
: day.getTime();
const buttonAttrs = isDisabled ? '' : ` type="button" ${dayAttr}="${escapeAttrValue(dayAttrValue)}"`;
const dayStyle = options.dayStyle || '';
return `
<${tagName}${buttonAttrs}
class="${outerClass}"
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};">
style="background:${bg};${borderStyle}color:${text};opacity:${opacity};box-shadow:${shadow};${dayStyle}">
<span class="${innerClass}">
<span class="text-[13px] font-semibold leading-none ${showIndicator ? '-translate-y-[0.18rem]' : ''}">${day.getDate()}</span>
${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 `
<div id="${swipeZoneId}" class="${wrapperClass}" style="${wrapperStyle}">
<div id="${weekWrapId}" class="${weekWrapClass}" style="${weekWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${weekGridId}" class="${weekGridClass}"></div>
</div>
<div id="${monthWrapId}" class="${monthWrapClass}" style="${monthWrapStyle}">
${createCalendarWeekdayHeaderHTML(weekdayLabels, weekdayHeaderOptions)}
<div id="${monthGridId}" class="${monthGridClass}"></div>
</div>
<button id="${toggleId}" type="button" class="${toggleClass}" aria-label="${toggleLabel}">
<i id="${iconId}" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
`;
}
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) => {

View File

@@ -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() {
</button>
</div>
</div>
<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-wrap" class="hidden relative z-[2] 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-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(var(--overlay-rgb),0.12), rgba(var(--overlay-rgb),0.03), rgba(var(--overlay-rgb),0));"></div>
<div id="mpe-cal-section" class="hidden">
${createCalendarTopbarHTML({
todayId: 'mpe-cal-today',
wrapperClass: 'mb-2 flex items-center justify-end gap-3',
})}
${createCalendarWeekdayHeaderHTML()}
<div id="mpe-cal-grid" class="grid grid-cols-7 gap-1.5"></div>
<button id="mpe-cal-toggle" type="button" class="w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors"><i id="mpe-cal-toggle-icon" class="fas fa-chevron-down text-[10px]"></i></button>
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mt-3 mb-2">Pora posiłku</p>
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
${createCollapsibleCalendarHTML({
idPrefix: 'mpe-cal',
swipeZoneId: 'mpe-cal-swipe-zone',
weekWrapId: 'mpe-cal-week-wrap',
weekGridId: 'mpe-cal-week-grid',
monthWrapId: 'mpe-cal-month-wrap',
monthGridId: 'mpe-cal-month-grid',
toggleId: 'mpe-cal-toggle',
iconId: 'mpe-cal-toggle-icon',
weekWrapClass: 'overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]',
monthWrapClass: 'bg-[rgb(var(--app-bg-rgb))]',
weekWrapStyle: 'overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.25rem',
toggleClass: 'w-full flex items-center justify-center py-1 mt-1 text-gray-400 hover:text-gray-600 transition-colors',
})}
</div>
</div>
<div id="mpe-summary-wrap" class="relative z-[1] shrink-0 px-5 pb-3 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important;">
<div id="mpe-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
<div id="mpe-top-shadow" class="pointer-events-none absolute inset-x-0 -bottom-3 h-3 opacity-0 transition-opacity duration-200" style="background:linear-gradient(to bottom, rgba(var(--overlay-rgb),0.12), rgba(var(--overlay-rgb),0.03), rgba(var(--overlay-rgb),0));"></div>
</div>
<div id="mpe-ing-scroll" class="flex-1 min-h-0 overflow-y-auto no-scrollbar px-5 bg-[rgb(var(--app-bg-rgb))]" style="background:rgb(var(--app-bg-rgb)) !important; background-image:none !important; padding-bottom:calc(1.5rem + env(safe-area-inset-bottom));">
<div id="mpe-slot-section" class="pt-3 pb-1 hidden">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Pora posiłku</p>
<div id="mpe-slot-chips" class="flex flex-wrap gap-1.5"></div>
</div>
<div id="mpe-summary-wrap" class="relative z-[1] 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-nutrition-section"></div>
<div id="mpe-servings-row" class="mt-3"></div>
</div>
<div id="mpe-ing-section" class="mb-4">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-2">Składniki</p>
<div id="mpe-ing-list" class="space-y-1.5"></div>
@@ -155,6 +168,18 @@ export function setupMealPlanEditor() {
/* ── Calendar ──────────────────────────────────── */
function resolveCalendarDayState(day, meta, plans = loadPlans(), today = startOfDay(new Date())) {
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),
};
}
function renderCal() {
const wrap = document.getElementById('mpe-cal-wrap');
const sec = document.getElementById('mpe-cal-section');
@@ -169,45 +194,46 @@ export function setupMealPlanEditor() {
}
wrap.classList.remove('hidden');
sec.classList.remove('hidden');
const grid = document.getElementById('mpe-cal-grid');
const weekGrid = document.getElementById('mpe-cal-week-grid');
const monthGrid = document.getElementById('mpe-cal-month-grid');
const todayBtn = document.getElementById('mpe-cal-today');
const icon = document.getElementById('mpe-cal-toggle-icon');
if (!grid) return;
if (!weekGrid || !monthGrid) return;
const today = startOfDay(new Date());
const plans = loadPlans();
const mode = S.calExpanded ? 'month' : 'week';
if (icon) {
icon.className = S.calExpanded ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
syncCollapsibleCalendarMode({
mode,
weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
activePaddingBottom: '0.25rem',
});
syncCollapsibleCalendarToggleIcon(icon, mode);
syncCalendarTodayButton(
todayBtn,
isCalendarOnToday(mode, startOfWeekMonday(S.calDate), S.calDate, S.date),
S.date,
{
labelText: formatCalendarPeriodLabel(mode, S.calDate, S.calDate),
},
);
renderCalendarGrid({
gridEl: grid,
mode,
anchorDate: S.calDate,
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: S.calDate,
monthAnchorDate: S.calDate,
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),
};
},
resolveDayState: (day, meta) => resolveCalendarDayState(day, meta, plans, today),
});
syncScrollShadows();
}
function renderSlots() {
const el = document.getElementById('mpe-slot-chips');
const sec = document.getElementById('mpe-slot-section');
if (sec) sec.classList.toggle('hidden', !S.showCal);
if (!el || !S.showCal) return;
const r = RECIPES[S.recipeId];
if (!r) return;
@@ -611,50 +637,39 @@ export function setupMealPlanEditor() {
});
}
document.getElementById('mpe-confirm-btn')?.addEventListener('click', handleConfirm);
bindCalendarDayClicks(document.getElementById('mpe-cal-grid'), (date) => {
const selectCalendarDate = (date) => {
S.date = date;
S.calDate = new Date(date);
renderCal();
});
};
bindCalendarHorizontalSwipe(document.getElementById('mpe-cal-grid'), {
onPrev: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, -1) : addDays(S.calDate, -7);
renderCal();
bindCalendarDayClicks(document.getElementById('mpe-cal-week-grid'), selectCalendarDate);
bindCalendarDayClicks(document.getElementById('mpe-cal-month-grid'), selectCalendarDate);
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('mpe-cal-swipe-zone'),
weekWrapEl: document.getElementById('mpe-cal-week-wrap'),
monthWrapEl: document.getElementById('mpe-cal-month-wrap'),
getMode: () => (S.calExpanded ? 'month' : 'week'),
setMode: (mode) => {
S.calExpanded = mode === 'month';
},
onNext: () => {
if (!S.showCal) return;
S.calDate = S.calExpanded ? addMonths(S.calDate, 1) : addDays(S.calDate, 7);
renderCal();
getWeekAnchor: () => S.calDate,
setWeekAnchor: (date) => {
S.calDate = startOfDay(date);
},
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),
};
},
});
getMonthAnchor: () => S.calDate,
setMonthAnchor: (date) => {
S.calDate = startOfDay(date);
},
getSelectedDate: () => S.date,
setSelectedDate: (date) => {
S.date = startOfDay(date);
},
rerender: renderCal,
resolveDayState: (day, meta) => resolveCalendarDayState(day, meta),
selectOnNavigateOutside: false,
enableVerticalModeSwipe: false,
});
document.getElementById('mpe-cal-today')?.addEventListener('click', () => {
const today = startOfDay(new Date());
@@ -824,7 +839,10 @@ export function setupMealPlanEditor() {
if (e.target.closest('#mpe-add-btn')) {
S.addOpen = true; S.addQuery = '';
renderAddArea();
document.getElementById('mpe-add-search')?.focus();
requestAnimationFrame(() => {
document.getElementById('mpe-add-area')?.scrollIntoView({ behavior: 'smooth', block: 'end' });
document.getElementById('mpe-add-search')?.focus({ preventScroll: true });
});
return;
}

View File

@@ -1,4 +1,5 @@
import { addDays, startOfMonth, startOfWeekMonday } from '../services/dateUtils.js';
import { addDays, startOfMonth } from '../services/dateUtils.js';
import { renderCalendarGrid } from './mealCalendar.js?v=15';
const DEFAULT_WEEKDAYS = ['pn', 'wt', 'śr', 'cz', 'pt', 'sb', 'nd'];
const DEFAULT_MONTHS_LONG = [
@@ -8,13 +9,16 @@ const DEFAULT_MONTHS_LONG = [
const DEFAULT_THEME = {
selectedBorder: 'rgba(var(--text-emphasis-rgb),0.34)',
selectedBorderClass: 'border',
selectedText: 'rgb(var(--text-emphasis-rgb))',
selectedDot: 'rgb(var(--text-emphasis-rgb))',
selectedShadow: '0 0 0 1px rgba(var(--text-emphasis-rgb),0.10)',
bg: 'rgb(var(--app-bg-rgb))',
border: 'transparent',
borderClass: 'border-0',
text: 'rgb(var(--text-body-soft-rgb))',
dimmedBg: 'transparent',
dimmedBorderClass: 'border-0',
dimText: 'rgb(var(--text-faint-rgb))',
dimOpacity: 0.58,
dot: 'rgb(var(--text-faint-rgb))',
@@ -186,57 +190,37 @@ export function initSwipePopoverCalendar({
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;
const calendarTheme = {
...DEFAULT_THEME,
...theme,
borderClass: theme.borderClass || DEFAULT_THEME.borderClass,
dimmedBorderClass: theme.dimmedBorderClass || DEFAULT_THEME.dimmedBorderClass,
selectedBorderClass: theme.selectedBorderClass || DEFAULT_THEME.selectedBorderClass,
};
let bg;
let borderColor;
let text;
let shadow = 'none';
let borderClass = 'border-0';
if (dimmed) {
bg = theme.dimmedBg ?? DEFAULT_THEME.dimmedBg;
borderColor = 'transparent';
text = theme.dimText || DEFAULT_THEME.dimText;
} else {
bg = theme.bg || DEFAULT_THEME.bg;
borderColor = theme.border || DEFAULT_THEME.border;
text = theme.text || DEFAULT_THEME.text;
}
if (isSelected) {
borderColor = theme.selectedBorder || DEFAULT_THEME.selectedBorder;
text = theme.selectedText || DEFAULT_THEME.selectedText;
shadow = theme.selectedShadow || DEFAULT_THEME.selectedShadow;
borderClass = 'border';
}
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}; box-shadow:${shadow}; touch-action:pan-y;">
<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('');
renderCalendarGrid({
gridEl: targetGrid,
mode: 'month',
anchorDate: monthAnchor,
fixedWeekCount: 6,
selectedDate: null,
isSelectedDate: (day) => selectedSet.has(dateKeyLocal(day)),
dayAttr: 'data-dk',
getDayAttrValue: dateKeyLocal,
dayClassName: 'swc-day',
dayStyle: 'touch-action:pan-y;',
theme: calendarTheme,
resolveDayState: (day, { inCurrentMonth, isSelected }) => {
const resolved = (typeof resolveDayState === 'function'
? resolveDayState(day, { inCurrentMonth, isSelected })
: {}) || {};
return {
disabled: !!resolved.disabled,
dimmed: !!resolved.dimmed,
showIndicator: !!resolved.showDot,
};
},
});
};
const render = (previewSelection = null) => {

View File

@@ -1,14 +1,10 @@
import { INGREDIENTS, RECIPES } from '../data/catalog.js?v=9';
import { MEAL_SLOTS } from '../planner/mealSlots.js';
import {
addMonths,
addWeeks,
sameDay,
sameMonth,
startOfDay,
startOfMonth,
startOfWeekMonday,
weekContains,
} from '../services/dateUtils.js';
import {
computeEntryNutrition,
@@ -26,17 +22,18 @@ import {
savePlans,
} from '../services/planStore.js?v=2';
import {
CALENDAR_HANDLE_CLASS,
CALENDAR_MONTHS_SHORT,
bindCollapsibleCalendarSwipeGesture,
bindCalendarDayClicks,
createCollapsibleCalendarHTML,
createCalendarTopbarHTML,
createCalendarWeekdayHeaderHTML,
formatCalendarPeriodLabel,
isCalendarOnToday,
renderCalendarGrid,
renderCollapsibleCalendar,
syncCalendarTodayButton,
syncCollapsibleCalendarMode,
} from '../ui/mealCalendar.js?v=14';
syncCollapsibleCalendarToggleIcon,
} from '../ui/mealCalendar.js?v=15';
import {
filterRecipesByQuery,
renderRecipeGrid,
@@ -67,6 +64,9 @@ function syncTodayButton(mode, weekStart, monthAnchor, selected) {
document.getElementById('cal-go-today'),
isCalendarOnToday(mode, weekStart, monthAnchor, selected),
selected,
{
labelText: formatCalendarPeriodLabel(mode, weekStart, monthAnchor),
},
);
}
@@ -81,19 +81,7 @@ export function getMealPlannerHTML() {
wrapperClass: 'flex shrink-0 items-center justify-end',
})}
</div>
<div id="calendar-swipe-zone" class="overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]" style="touch-action: none">
<div id="calendar-week-wrap" class="px-3 overflow-x-hidden bg-[rgb(var(--app-bg-rgb))]" style="overflow: hidden; max-height: 10rem; opacity: 1; padding-bottom: 0.75rem">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-week-grid" class="grid grid-cols-7 gap-1.5 max-w-full overflow-x-hidden"></div>
</div>
<div id="calendar-month-wrap" class="px-3 bg-[rgb(var(--app-bg-rgb))]" style="overflow: hidden; max-height: 0; opacity: 0; padding-bottom: 0">
${createCalendarWeekdayHeaderHTML()}
<div id="calendar-month-grid" class="grid grid-cols-7 gap-1.5"></div>
</div>
<button id="calendar-mode-toggle" type="button" class="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" aria-label="Przełącz widok kalendarza">
<i id="calendar-handle-icon" class="fas fa-chevron-down text-[10px]"></i>
</button>
</div>
${createCollapsibleCalendarHTML({ idPrefix: 'calendar' })}
</div>
<div id="planner-scroll" class="flex-1 overflow-y-auto px-4 pt-3 pb-24 bg-[rgb(var(--app-bg-rgb))]">
@@ -192,223 +180,7 @@ function syncModeToggle(mode) {
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
});
const icon = document.getElementById('calendar-handle-icon');
if (icon) icon.className = mode === 'month' ? 'fas fa-chevron-up text-[10px]' : 'fas fa-chevron-down text-[10px]';
}
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 || 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;
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) return;
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);
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);
}
});
syncCollapsibleCalendarToggleIcon(document.getElementById('calendar-handle-icon'), mode);
}
function showPlannerToast(message) {
@@ -1291,27 +1063,29 @@ export function setupMealPlanner() {
maxMinutes: PICKER_FILTER_MAX_MINUTES,
};
const resolveCalendarDayState = (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 rerender = () => {
syncModeToggle(state.mode);
syncTodayButton(state.mode, state.weekStart, state.monthAnchor, state.selected);
const today = startOfDay(new Date());
renderCollapsibleCalendar({
weekGridEl: weekGrid,
monthGridEl: monthGrid,
weekAnchorDate: state.weekStart,
monthAnchorDate: state.monthAnchor,
selectedDate: state.selected,
resolveDayState: (day, meta) => {
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),
};
},
resolveDayState: resolveCalendarDayState,
});
renderDayContent(state, persist);
};
@@ -1559,7 +1333,30 @@ export function setupMealPlanner() {
rerender();
};
bindCalendarSwipeGesture(state, rerender);
bindCollapsibleCalendarSwipeGesture({
zoneEl: document.getElementById('calendar-swipe-zone'),
weekWrapEl: document.getElementById('calendar-week-wrap'),
monthWrapEl: document.getElementById('calendar-month-wrap'),
getMode: () => state.mode,
setMode: (mode) => {
state.mode = mode;
},
getWeekAnchor: () => state.weekStart,
setWeekAnchor: (date) => {
state.weekStart = startOfWeekMonday(date);
},
getMonthAnchor: () => state.monthAnchor,
setMonthAnchor: (date) => {
state.monthAnchor = startOfMonth(date);
},
getSelectedDate: () => state.selected,
setSelectedDate: (date) => {
state.selected = startOfDay(date);
},
rerender,
resolveDayState: resolveCalendarDayState,
selectOnNavigateOutside: false,
});
document.getElementById('calendar-mode-toggle')?.addEventListener('click', () => {
if (state.mode === 'week') {

View File

@@ -10,6 +10,11 @@ import {
createSwipePopoverCalendarHTML,
initSwipePopoverCalendar,
} from '../ui/swipePopoverCalendar.js';
import {
createCalendarPopoverHTML,
stabilizeSwipeCalendarLayout,
syncCalendarPopoverVisibility,
} from '../ui/calendarPopover.js';
import { createIngredientCardController, getIngredientCardHTML } from '../ui/ingredientCard.js?v=20260417-116';
/* ── helpers ── */
@@ -202,9 +207,10 @@ export function getPantryHTML() {
</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>
${createCalendarPopoverHTML({
id: 'pantry-calendar-popover',
calendarHTML: createSwipePopoverCalendarHTML({ idPrefix: 'pantry-cal' }),
})}
<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);">
<i class="fas fa-search text-[13px] shrink-0" style="color:rgb(var(--text-dim-rgb));"></i>
@@ -254,19 +260,13 @@ function syncHorizonUI() {
defaultRow.style.pointerEvents = showDefault ? 'auto' : 'none';
}
if (compactPill) {
compactPill.style.setProperty('background', showCalendar ? 'rgb(var(--sunken-rgb))' : 'rgb(var(--card-rgb))', 'important');
compactPill.style.setProperty('border-color', showCalendar ? 'rgb(var(--border-input-rgb))' : 'rgb(var(--border-card-rgb))', 'important');
}
if (popover) {
popover.style.opacity = showCalendar ? '1' : '0';
popover.style.transform = showCalendar ? 'translateY(0) scale(1)' : 'translateY(-6px) scale(0.98)';
popover.style.pointerEvents = showCalendar ? 'auto' : 'none';
}
if (chevron) {
chevron.style.transform = showCalendar ? 'rotate(180deg)' : 'rotate(0deg)';
}
syncCalendarPopoverVisibility({
popup: popover,
isOpen: showCalendar,
chevron,
trigger: compactPill,
triggerImportant: true,
});
if (filterPopover) {
filterPopover.style.opacity = showFilter ? '1' : '0';
@@ -415,9 +415,9 @@ function openCalendar() {
isFilterOpen = false;
isCalendarOpen = true;
syncHorizonUI();
requestAnimationFrame(() => {
pantryCalendar?.reapplyLayout();
pantryCalendar?.resetTrackPosition();
stabilizeSwipeCalendarLayout({
calendar: pantryCalendar,
viewport: 'pantry-cal-viewport',
});
}

View File

@@ -16,6 +16,7 @@ import { aggregateSelectedDaysIngredientNeed } from '../services/planIngredients
import { loadPlans, dateKey } from '../services/planStore.js?v=2';
import { addDays, startOfDay, startOfMonth } from '../services/dateUtils.js';
import { createSwipePopoverCalendarHTML, initSwipePopoverCalendar } from '../ui/swipePopoverCalendar.js';
import { createCalendarPopoverController, createCalendarPopoverHTML } from '../ui/calendarPopover.js';
import { showAppToast } from '../ui/toast.js';
/* ── helpers ── */
@@ -64,6 +65,7 @@ let expandedAmount = 0;
let calendarOpen = false;
let calendarMonth = startOfMonth(new Date());
let shoppingCalendar = null;
let shoppingCalendarPopover = null;
/* ── day helpers ── */
@@ -133,14 +135,13 @@ export function getShoppingListHTML() {
</button>
<!-- popup calendar (absolute, overlays content below) -->
<div id="sl-calendar-popup" style="position:absolute; top:calc(100% + 0.5rem); left:0; right:0; z-index:50; pointer-events:none; opacity:0; transform:translateY(-6px) scale(0.98); transition: opacity 0.2s ease, transform 0.2s ease;">
<div class="rounded-[1.35rem] py-3" style="background:rgb(var(--sunken-rgb)); border:1px solid rgb(var(--border-input-rgb)); box-shadow:var(--shadow-shell);">
${createSwipePopoverCalendarHTML({
${createCalendarPopoverHTML({
id: 'sl-calendar-popup',
calendarHTML: createSwipePopoverCalendarHTML({
idPrefix: 'sl-cal',
weekdays: WEEKDAY_SHORT,
}),
})}
</div>
</div>
<!-- popup bought (absolute, overlays content below) -->
<div id="sl-bought-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;">
@@ -201,6 +202,14 @@ function initShoppingCalendar() {
dot: 'rgb(var(--text-faint-rgb))',
},
});
shoppingCalendarPopover = createCalendarPopoverController({
popupId: 'sl-calendar-popup',
viewportId: 'sl-cal-viewport',
triggerId: 'sl-range-pill',
chevronId: 'sl-range-chevron',
getCalendar: () => shoppingCalendar,
hideViewportDuringLayout: true,
});
}
function updatePillLabel() {
@@ -212,64 +221,12 @@ function openCalendar() {
if (boughtPopupOpen) closeBoughtPopup();
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) {
popup.style.pointerEvents = 'auto';
popup.style.opacity = '1';
popup.style.transform = 'translateY(0) scale(1)';
}
if (chevron) chevron.style.transform = 'rotate(180deg)';
if (pill) {
pill.style.background = 'rgb(var(--sunken-rgb))';
pill.style.borderColor = 'rgb(var(--border-input-rgb))';
}
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());
shoppingCalendarPopover?.open();
}
function closeCalendar() {
calendarOpen = false;
const popup = document.getElementById('sl-calendar-popup');
const chevron = document.getElementById('sl-range-chevron');
const pill = document.getElementById('sl-range-pill');
if (popup) {
popup.style.pointerEvents = 'none';
popup.style.opacity = '0';
popup.style.transform = 'translateY(-6px) scale(0.98)';
}
if (chevron) chevron.style.transform = '';
if (pill) {
pill.style.background = 'rgb(var(--card-rgb))';
pill.style.borderColor = 'rgb(var(--border-card-rgb))';
}
shoppingCalendar?.clearPendingRange?.();
shoppingCalendar?.resetTrackPosition();
shoppingCalendarPopover?.close({ clearPendingRange: true });
}
function toggleCalendar() {
@@ -327,11 +284,11 @@ function activeItemHtml(item) {
const stepAmt = isExpanded ? expandedAmount : Math.max(step, Math.round(item.shortfall / step) * step);
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--success-rgb)); box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end">
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-buy absolute inset-0 flex items-center pr-5 justify-end" style="background:rgb(var(--success-rgb)); opacity:0;">
<i class="fas fa-check text-white text-lg"></i>
</div>
<div class="sl-swipe-inner rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;"
<div class="sl-swipe-inner" style="background:rgb(var(--card-rgb)); position:relative;"
data-id="${esc(item.ingredientId)}" data-unit="${esc(item.unit)}" data-shortfall="${item.shortfall}">
<div class="sl-item-main flex items-center gap-3 py-1.5 px-3 cursor-pointer select-none">
${mediaHtml}
@@ -371,11 +328,11 @@ function boughtItemHtml(entry) {
: `<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style="background:rgb(var(--card-soft-rgb));"><i class="fas ${icon} text-xs" style="color:rgb(var(--text-faint-rgb));"></i></div>`;
return `
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="background:rgb(var(--danger-rgb)); box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start">
<div class="sl-swipe-wrap relative rounded-xl mb-1.5 overflow-hidden" style="box-shadow:var(--shadow-card);">
<div class="sl-swipe-bg-undo absolute inset-0 flex items-center pl-5 justify-start" style="background:rgb(var(--danger-rgb)); opacity:0;">
<i class="fas fa-rotate-left text-white text-lg"></i>
</div>
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3 rounded-xl" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}">
<div class="sl-swipe-inner flex items-center gap-3 py-1.5 px-3" style="background:rgb(var(--card-rgb)); position:relative;" data-entry-id="${esc(entry.id)}">
${mediaHtml}
<div class="flex-1 min-w-0">
<span class="block text-[13px] font-medium leading-tight truncate" style="color:rgb(var(--text-body-rgb));">${esc(entry.name)}</span>
@@ -390,6 +347,10 @@ function boughtItemHtml(entry) {
function attachSwipe(container, opts) {
const inner = container.querySelector('.sl-swipe-inner');
if (!inner) return;
const bgBuy = container.querySelector('.sl-swipe-bg-buy');
const bgUndo = container.querySelector('.sl-swipe-bg-undo');
const showBg = (el) => { if (el) el.style.opacity = '1'; };
const hideBgs = () => { if (bgBuy) bgBuy.style.opacity = '0'; if (bgUndo) bgUndo.style.opacity = '0'; };
let startX = 0, startY = 0, dx = 0, tracking = false, decided = false, goingH = false;
container.addEventListener('pointerdown', (e) => {
@@ -408,10 +369,10 @@ function attachSwipe(container, opts) {
decided = true;
goingH = Math.abs(ddx) > Math.abs(ddy);
}
if (!goingH) { tracking = false; inner.style.transform = ''; return; }
if (!goingH) { tracking = false; inner.style.transform = ''; hideBgs(); return; }
dx = ddx;
if (dx > 0 && opts.onRight) inner.style.transform = `translateX(${Math.min(dx, 90)}px)`;
else if (dx < 0 && opts.onLeft) inner.style.transform = `translateX(${Math.max(dx, -90)}px)`;
if (dx > 0 && opts.onRight) { inner.style.transform = `translateX(${Math.min(dx, 90)}px)`; showBg(bgBuy); }
else if (dx < 0 && opts.onLeft) { inner.style.transform = `translateX(${Math.max(dx, -90)}px)`; showBg(bgUndo); }
});
const finish = () => {
@@ -427,6 +388,7 @@ function attachSwipe(container, opts) {
setTimeout(opts.onLeft, 180);
} else {
inner.style.transform = '';
hideBgs();
}
dx = 0;
};
@@ -436,6 +398,7 @@ function attachSwipe(container, opts) {
tracking = false;
inner.style.transition = 'transform 0.2s ease';
inner.style.transform = '';
hideBgs();
dx = 0;
});
}