From 775fa38095d7c3b986ca1fd1bc5c163d9ed50fb6 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Thu, 9 Apr 2026 15:55:28 +0200 Subject: [PATCH] Delayed meal removal from planner --- js/views/MealPlanner.js | 216 ++++++++++++++++++++++++++++++++++------ 1 file changed, 184 insertions(+), 32 deletions(-) diff --git a/js/views/MealPlanner.js b/js/views/MealPlanner.js index 232fbb0..2d5dad3 100644 --- a/js/views/MealPlanner.js +++ b/js/views/MealPlanner.js @@ -53,6 +53,8 @@ const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTT const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)'; const PLANNER_MEAL_SWIPE_MAX = 132; const PLANNER_MEAL_SWIPE_DELETE_THRESHOLD = 96; +const PLANNER_MEAL_PENDING_DELETE_MS = 3200; +const PLANNER_MEAL_PENDING_DELETE_TICK_MS = 100; const PICKER_FILTER_MIN_MINUTES = 5; const PICKER_FILTER_MAX_MINUTES = 120; @@ -408,6 +410,8 @@ function bindPlannerSheetDragClose(sheet, closeFn, options = {}) { function renderDayContent(state, onMealRemoved = null) { const sel = state.selected; + syncPendingMealRemovals(state); + const selectedDayKey = dateKey(sel); const dayPlan = getDayPlan(state.plans, sel); const totals = sumDayNutrition(dayPlan); @@ -469,21 +473,53 @@ function renderDayContent(state, onMealRemoved = null) { const servings = Math.max(1, Number(entry.servings) || 1); const entryN = computeEntryNutrition(entry); const eid = escapeHtml(entry.id); + const isPendingDelete = Boolean(getPendingMealRemoval(state, selectedDayKey, slot.id, entry.id)); const hasCustom = (entry.excludedIngredients?.length > 0) || (entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) || (entry.addedIngredients?.length > 0) || (entry.substitutions && Object.keys(entry.substitutions).length > 0); const customDot = hasCustom ? '' : ''; const servLabel = servings > 1 ? `·×${servings}` : ''; - return ` -
-
- + const rowStyle = `--planner-swipe-progress:${isPendingDelete ? '1' : '0'};`; + const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : ''; + const backgroundStyle = isPendingDelete + ? 'background:rgba(122,58,52,0.78);' + : 'background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));'; + const backgroundLabel = isPendingDelete + ? ` + + ` + : ` Usuń - + `; + const titleClass = isPendingDelete + ? 'text-[13px] font-normal text-[#d6bbb5] truncate line-through decoration-[#b9877f]' + : 'text-[13px] font-normal text-[#ddd6ca] truncate'; + const metaClass = isPendingDelete + ? 'text-[11px] text-[#c29a93] mt-0.5 tabular-nums' + : 'text-[11px] text-[#9b978f] mt-0.5 tabular-nums'; + const actionWrapClass = isPendingDelete + ? 'flex items-center shrink-0 self-center' + : 'flex items-center gap-1 shrink-0 self-center'; + const remainingProgress = isPendingDelete + ? getPendingMealRemovalProgress(state, selectedDayKey, slot.id, entry.id) + : 0; + const entryAction = isPendingDelete + ? `` + : ``; + return ` +
+
+ ${backgroundLabel}
-
+
@@ -492,18 +528,16 @@ function renderDayContent(state, onMealRemoved = null) { : `${escapeHtml(recipe.thumbLabel)}`}
-

${escapeHtml(recipe.title)}

${customDot}
-

+

${escapeHtml(recipe.title)}

${customDot}
+

${recipe.minutes} min · ${entryN.kcal} kcal${servLabel}

-
- +
+ ${entryAction}
@@ -545,11 +579,126 @@ function renderDayContent(state, onMealRemoved = null) { }).join(''); bindMealEntrySwipe(state, onMealRemoved); + syncPendingMealRemovalTicker(state); } -function removeMealEntry(state, slotId, entryId) { - const key = dateKey(state.selected); - const day = state.plans[key]; +function getPendingMealRemovalKey(dayKey, slotId, entryId) { + return `${dayKey}::${slotId}::${entryId}`; +} + +function getPendingMealRemoval(state, dayKey, slotId, entryId) { + if (!(state.pendingMealRemovals instanceof Map)) return null; + return state.pendingMealRemovals.get(getPendingMealRemovalKey(dayKey, slotId, entryId)) || null; +} + +function getPendingMealRemovalProgress(state, dayKey, slotId, entryId) { + const pending = getPendingMealRemoval(state, dayKey, slotId, entryId); + if (!pending) return 0; + return Math.max(0, Math.min(1, (pending.expiresAt - Date.now()) / PLANNER_MEAL_PENDING_DELETE_MS)); +} + +function getPendingMealRemovalButtonStyle(progress) { + const clamped = Math.max(0, Math.min(1, progress)); + const angle = Math.round(clamped * 360); + return `conic-gradient(from -90deg, rgba(243,198,191,0.96) 0deg, rgba(243,198,191,0.96) ${angle}deg, rgba(139,90,84,0.22) ${angle}deg, rgba(139,90,84,0.22) 360deg)`; +} + +function stopPendingMealRemovalTicker(state) { + if (state.pendingMealRemovalTickerId) { + clearInterval(state.pendingMealRemovalTickerId); + state.pendingMealRemovalTickerId = null; + } +} + +function refreshPendingMealRemovalButtons(state) { + const buttons = document.querySelectorAll('[data-pending-delete-progress]'); + buttons.forEach((button) => { + const dayKey = button.getAttribute('data-day-key'); + const slotId = button.getAttribute('data-slot-id'); + const entryId = button.getAttribute('data-entry-id'); + if (!dayKey || !slotId || !entryId) return; + const progress = getPendingMealRemovalProgress(state, dayKey, slotId, entryId); + button.style.background = getPendingMealRemovalButtonStyle(progress); + }); +} + +function syncPendingMealRemovalTicker(state) { + const hasPending = state.pendingMealRemovals instanceof Map && state.pendingMealRemovals.size > 0; + if (!hasPending) { + stopPendingMealRemovalTicker(state); + return; + } + + refreshPendingMealRemovalButtons(state); + if (state.pendingMealRemovalTickerId) return; + + state.pendingMealRemovalTickerId = window.setInterval(() => { + syncPendingMealRemovals(state); + refreshPendingMealRemovalButtons(state); + if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) { + stopPendingMealRemovalTicker(state); + } + }, PLANNER_MEAL_PENDING_DELETE_TICK_MS); +} + +function clearPendingMealRemoval(state, pendingKey) { + if (!(state.pendingMealRemovals instanceof Map)) return false; + const pending = state.pendingMealRemovals.get(pendingKey); + if (!pending) return false; + clearTimeout(pending.timeoutId); + state.pendingMealRemovals.delete(pendingKey); + if (state.pendingMealRemovals.size === 0) stopPendingMealRemovalTicker(state); + return true; +} + +function syncPendingMealRemovals(state) { + if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) return; + + for (const [pendingKey, pending] of state.pendingMealRemovals.entries()) { + const arr = state.plans[pending.dayKey]?.[pending.slotId]; + const stillExists = Array.isArray(arr) && arr.some((entry) => entry && entry.id === pending.entryId); + if (!stillExists) clearPendingMealRemoval(state, pendingKey); + } +} + +function queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved = null) { + if (!dayKey || !slotId || !entryId) return false; + + if (!(state.pendingMealRemovals instanceof Map)) state.pendingMealRemovals = new Map(); + const pendingKey = getPendingMealRemovalKey(dayKey, slotId, entryId); + if (state.pendingMealRemovals.has(pendingKey)) return false; + + const timeoutId = window.setTimeout(() => { + state.pendingMealRemovals.delete(pendingKey); + const removed = removeMealEntry(state, dayKey, slotId, entryId); + if (typeof onMealRemoved === 'function') onMealRemoved(); + else { + if (removed) savePlans(state.plans); + renderDayContent(state); + } + if (!(state.pendingMealRemovals instanceof Map) || state.pendingMealRemovals.size === 0) { + stopPendingMealRemovalTicker(state); + } + }, PLANNER_MEAL_PENDING_DELETE_MS); + + state.pendingMealRemovals.set(pendingKey, { + dayKey, + slotId, + entryId, + expiresAt: Date.now() + PLANNER_MEAL_PENDING_DELETE_MS, + timeoutId, + }); + + return true; +} + +function cancelQueuedMealEntryRemoval(state, dayKey, slotId, entryId) { + if (!dayKey || !slotId || !entryId) return false; + return clearPendingMealRemoval(state, getPendingMealRemovalKey(dayKey, slotId, entryId)); +} + +function removeMealEntry(state, dayKey, slotId, entryId) { + const day = state.plans[dayKey]; const arr = day?.[slotId]; if (!Array.isArray(arr) || !entryId) return false; @@ -559,7 +708,7 @@ function removeMealEntry(state, slotId, entryId) { if (next.length === 0) delete day[slotId]; else day[slotId] = next; - if (Object.keys(day).length === 0) delete state.plans[key]; + if (Object.keys(day).length === 0) delete state.plans[dayKey]; return true; } @@ -628,24 +777,13 @@ function bindMealEntrySwipe(state, onMealRemoved = null) { stopTracking(); if (shouldDelete) { + const dayKey = dateKey(state.selected); const slotId = card.getAttribute('data-slot-id'); const entryId = card.getAttribute('data-entry-id'); - row.style.pointerEvents = 'none'; - row.style.setProperty('--planner-swipe-progress', '1'); - card.style.willChange = ''; - card.style.transition = 'transform 160ms cubic-bezier(0.32, 0.72, 0, 1), opacity 160ms ease'; - card.style.transform = 'translateX(-120%)'; - card.style.opacity = '0'; - window.setTimeout(() => { - if (removeMealEntry(state, slotId, entryId)) { - if (typeof onMealRemoved === 'function') onMealRemoved(); - else { - savePlans(state.plans); - renderDayContent(state); - } - showPlannerToast('Usunięto posiłek z planu dnia'); - } - }, 160); + resetVisual(); + if (queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved)) { + renderDayContent(state, onMealRemoved); + } return; } @@ -672,6 +810,7 @@ function bindMealEntrySwipe(state, onMealRemoved = null) { card.addEventListener('pointerdown', (e) => { if (pointerId !== null) return; if (e.pointerType === 'mouse' && e.button !== 0) return; + if (row.getAttribute('data-pending-delete') === 'true') return; pointerId = e.pointerId; startX = e.clientX; @@ -975,6 +1114,8 @@ export function setupMealPlanner() { monthAnchor: startOfDay(new Date()), selected: startOfDay(new Date()), plans, + pendingMealRemovals: new Map(), + pendingMealRemovalTickerId: null, pickerSlot: null, }; @@ -1102,6 +1243,17 @@ export function setupMealPlanner() { persist(); return; } + const cancelPendingRemoveBtn = e.target.closest('.planner-cancel-pending-remove'); + if (cancelPendingRemoveBtn) { + const dayKey = cancelPendingRemoveBtn.getAttribute('data-day-key'); + const slotId = cancelPendingRemoveBtn.getAttribute('data-slot-id'); + const entryId = cancelPendingRemoveBtn.getAttribute('data-entry-id'); + if (!dayKey || !slotId || !entryId) return; + if (cancelQueuedMealEntryRemoval(state, dayKey, slotId, entryId)) { + renderDayContent(state, persist); + } + return; + } const openRecipe = e.target.closest('.planner-open-recipe'); if (openRecipe) { const recipeId = openRecipe.getAttribute('data-recipe-id');