Delayed meal removal from planner
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m16s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m16s
This commit is contained in:
@@ -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 ? '<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}` : '';
|
||||
return `
|
||||
<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}">
|
||||
<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));">
|
||||
<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));">
|
||||
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
|
||||
? `<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>
|
||||
Usuń
|
||||
</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 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-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">
|
||||
@@ -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>`}
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-[11px] text-[#9b978f] mt-0.5 tabular-nums">
|
||||
<div class="flex items-center"><p class="${titleClass}">${escapeHtml(recipe.title)}</p>${customDot}</div>
|
||||
<p class="${metaClass}">
|
||||
<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>
|
||||
<i class="fas fa-fire text-[#7d7a74] mr-0.5" aria-hidden="true"></i>${entryN.kcal} kcal${servLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0 self-center">
|
||||
<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>
|
||||
<div class="${actionWrapClass}">
|
||||
${entryAction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
resetVisual();
|
||||
if (queueMealEntryRemoval(state, dayKey, slotId, entryId, onMealRemoved)) {
|
||||
renderDayContent(state, onMealRemoved);
|
||||
}
|
||||
showPlannerToast('Usunięto posiłek z planu dnia');
|
||||
}
|
||||
}, 160);
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user