Delayed meal removal from planner
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m16s

This commit is contained in:
2026-04-09 15:55:28 +02:00
parent 6bf50f67ad
commit 775fa38095

View File

@@ -53,6 +53,8 @@ const PLANNER_SHEET_OFF_TRANSFORM = `translateY(calc(100% + ${PLANNER_SHEET_BOTT
const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)'; const PLANNER_PICKER_OFF_TRANSFORM = 'translateY(100%)';
const PLANNER_MEAL_SWIPE_MAX = 132; const PLANNER_MEAL_SWIPE_MAX = 132;
const PLANNER_MEAL_SWIPE_DELETE_THRESHOLD = 96; 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_MIN_MINUTES = 5;
const PICKER_FILTER_MAX_MINUTES = 120; const PICKER_FILTER_MAX_MINUTES = 120;
@@ -408,6 +410,8 @@ function bindPlannerSheetDragClose(sheet, closeFn, options = {}) {
function renderDayContent(state, onMealRemoved = null) { function renderDayContent(state, onMealRemoved = null) {
const sel = state.selected; const sel = state.selected;
syncPendingMealRemovals(state);
const selectedDayKey = dateKey(sel);
const dayPlan = getDayPlan(state.plans, sel); const dayPlan = getDayPlan(state.plans, sel);
const totals = sumDayNutrition(dayPlan); const totals = sumDayNutrition(dayPlan);
@@ -469,21 +473,53 @@ function renderDayContent(state, onMealRemoved = null) {
const servings = Math.max(1, Number(entry.servings) || 1); const servings = Math.max(1, Number(entry.servings) || 1);
const entryN = computeEntryNutrition(entry); const entryN = computeEntryNutrition(entry);
const eid = escapeHtml(entry.id); const eid = escapeHtml(entry.id);
const isPendingDelete = Boolean(getPendingMealRemoval(state, selectedDayKey, slot.id, entry.id));
const hasCustom = (entry.excludedIngredients?.length > 0) || const hasCustom = (entry.excludedIngredients?.length > 0) ||
(entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) || (entry.amountOverrides && Object.keys(entry.amountOverrides).length > 0) ||
(entry.addedIngredients?.length > 0) || (entry.addedIngredients?.length > 0) ||
(entry.substitutions && Object.keys(entry.substitutions).length > 0); (entry.substitutions && Object.keys(entry.substitutions).length > 0);
const customDot = hasCustom ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : ''; const customDot = hasCustom ? '<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block shrink-0 ml-1"></span>' : '';
const servLabel = servings > 1 ? `<span class="mx-1.5 text-[#6d6c67]">·</span>×${servings}` : ''; const servLabel = servings > 1 ? `<span class="mx-1.5 text-[#6d6c67]">·</span>×${servings}` : '';
return ` const rowStyle = `--planner-swipe-progress:${isPendingDelete ? '1' : '0'};`;
<div class="relative overflow-hidden rounded-lg" data-planner-swipe-row style="--planner-swipe-progress:0;" data-slot-id="${slot.id}" data-entry-id="${eid}"> const rowAttrs = isPendingDelete ? 'data-pending-delete="true"' : '';
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));"> const backgroundStyle = isPendingDelete
<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(250,234,234, calc(0.55 + var(--planner-swipe-progress) * 0.45));"> ? 'background:rgba(122,58,52,0.78);'
: 'background:rgba(203,74,72, calc(0.18 + var(--planner-swipe-progress) * 0.5));';
const backgroundLabel = isPendingDelete
? `<span class="inline-flex items-center text-[#f6d4cf]/75">
<i class="fas fa-trash text-[11px]" aria-hidden="true"></i>
</span>`
: `<span class="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide uppercase" style="color:rgba(250,234,234, calc(0.55 + var(--planner-swipe-progress) * 0.45));">
<i class="fas fa-trash text-[10px]" aria-hidden="true"></i> <i class="fas fa-trash text-[10px]" aria-hidden="true"></i>
Usuń Usuń
</span> </span>`;
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
? `<button type="button" class="planner-cancel-pending-remove rounded-full p-[1.5px] transition-transform hover:scale-[1.02] active:scale-[0.98]" style="background:${getPendingMealRemovalButtonStyle(remainingProgress)};" data-pending-delete-progress data-day-key="${selectedDayKey}" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Anuluj usunięcie posiłku">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-[#372826] text-[#f3c6bf]">
<i class="fas fa-rotate-left text-[10px]" aria-hidden="true"></i>
</span>
</button>`
: `<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis">
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
</button>`;
return `
<div class="relative overflow-hidden rounded-lg ${isPendingDelete ? 'ring-1 ring-red-300/25' : ''}" data-planner-swipe-row style="${rowStyle}" data-slot-id="${slot.id}" data-entry-id="${eid}" ${rowAttrs}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-end px-4" style="${backgroundStyle}">
${backgroundLabel}
</div> </div>
<div class="relative z-[1] rounded-lg bg-[#2d2e2b] p-2" style="box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:translateX(0); transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease; touch-action:pan-y;" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}"> <div class="relative z-[1] rounded-lg p-2" style="background:${isPendingDelete ? '#372826' : '#2d2e2b'}; box-shadow:inset 0 1px 3px rgba(0,0,0,0.3); transform:translateX(0); transition:transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background-color 180ms ease; touch-action:pan-y; opacity:${isPendingDelete ? '0.84' : '1'};" data-planner-swipe-card data-slot-id="${slot.id}" data-entry-id="${eid}">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}"> <div class="flex items-center gap-2 min-w-0 cursor-pointer planner-open-recipe" data-recipe-id="${escapeHtml(recipe.id)}">
<div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0"> <div class="w-8 h-8 rounded-lg bg-[#3a3a37] overflow-hidden shrink-0">
@@ -492,18 +528,16 @@ function renderDayContent(state, onMealRemoved = null) {
: `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`} : `<span class="w-full h-full flex items-center justify-center text-white text-[8px] font-medium">${escapeHtml(recipe.thumbLabel)}</span>`}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center"><p class="text-[13px] font-normal text-[#ddd6ca] truncate">${escapeHtml(recipe.title)}</p>${customDot}</div> <div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div>
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums"> <p class="${metaClass}">
<i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min <i class="fas fa-clock text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${recipe.minutes} min
<span class="mx-1.5 text-[#6d6c67]">·</span> <span class="mx-1.5 text-[#6d6c67]">·</span>
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel} <i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
</p> </p>
</div> </div>
</div> </div>
<div class="flex items-center gap-1 shrink-0 self-center"> <div class="${actionWrapClass}">
<button type="button" class="planner-edit-meal w-6 h-6 rounded-full border border-[#444442] text-[#9b978f] hover:text-[#ddd6ca] hover:border-[#6d6c67] hover:bg-[#3a3a37] flex items-center justify-center transition-colors" data-slot-id="${slot.id}" data-entry-id="${eid}" aria-label="Edytuj ten przepis"> ${entryAction}
<i class="fas fa-pencil text-[9px]" aria-hidden="true"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -545,11 +579,126 @@ function renderDayContent(state, onMealRemoved = null) {
}).join(''); }).join('');
bindMealEntrySwipe(state, onMealRemoved); bindMealEntrySwipe(state, onMealRemoved);
syncPendingMealRemovalTicker(state);
} }
function removeMealEntry(state, slotId, entryId) { function getPendingMealRemovalKey(dayKey, slotId, entryId) {
const key = dateKey(state.selected); return `${dayKey}::${slotId}::${entryId}`;
const day = state.plans[key]; }
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]; const arr = day?.[slotId];
if (!Array.isArray(arr) || !entryId) return false; if (!Array.isArray(arr) || !entryId) return false;
@@ -559,7 +708,7 @@ function removeMealEntry(state, slotId, entryId) {
if (next.length === 0) delete day[slotId]; if (next.length === 0) delete day[slotId];
else day[slotId] = next; 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; return true;
} }
@@ -628,24 +777,13 @@ function bindMealEntrySwipe(state, onMealRemoved = null) {
stopTracking(); stopTracking();
if (shouldDelete) { if (shouldDelete) {
const dayKey = dateKey(state.selected);
const slotId = card.getAttribute('data-slot-id'); const slotId = card.getAttribute('data-slot-id');
const entryId = card.getAttribute('data-entry-id'); const entryId = card.getAttribute('data-entry-id');
row.style.pointerEvents = 'none'; resetVisual();
row.style.setProperty('--planner-swipe-progress', '1'); if (queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved)) {
card.style.willChange = ''; renderDayContent(state, onMealRemoved);
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);
return; return;
} }
@@ -672,6 +810,7 @@ function bindMealEntrySwipe(state, onMealRemoved = null) {
card.addEventListener('pointerdown', (e) => { card.addEventListener('pointerdown', (e) => {
if (pointerId !== null) return; if (pointerId !== null) return;
if (e.pointerType === 'mouse' && e.button !== 0) return; if (e.pointerType === 'mouse' && e.button !== 0) return;
if (row.getAttribute('data-pending-delete') === 'true') return;
pointerId = e.pointerId; pointerId = e.pointerId;
startX = e.clientX; startX = e.clientX;
@@ -975,6 +1114,8 @@ export function setupMealPlanner() {
monthAnchor: startOfDay(new Date()), monthAnchor: startOfDay(new Date()),
selected: startOfDay(new Date()), selected: startOfDay(new Date()),
plans, plans,
pendingMealRemovals: new Map(),
pendingMealRemovalTickerId: null,
pickerSlot: null, pickerSlot: null,
}; };
@@ -1102,6 +1243,17 @@ export function setupMealPlanner() {
persist(); persist();
return; 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'); const openRecipe = e.target.closest('.planner-open-recipe');
if (openRecipe) { if (openRecipe) {
const recipeId = openRecipe.getAttribute('data-recipe-id'); const recipeId = openRecipe.getAttribute('data-recipe-id');