From 6fd209ba5ecd1011a2abb8f5c93780d393349eb8 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 10:23:39 +0200 Subject: [PATCH] feat: meals drag & drop between slots and days (BL-03) Pointer Events-based drag & drop (touch + mouse compatible): - Ghost element follows pointer; drops on empty slots move the meal, drops on occupied slots swap both meals via concurrent PUT requests - prefers-reduced-motion: no ghost animation, interaction still works - Suppress-click guard prevents accidental edit modal after drag Co-Authored-By: Claude Sonnet 4.6 --- BACKLOG.md | 2 +- CHANGELOG.md | 1 + public/pages/meals.js | 135 +++++++++++++++++++++++++++++++++++++++- public/styles/meals.css | 29 +++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 6e051d4..d654b56 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -37,7 +37,7 @@ SPEC: „Monatsvergleich (aktuell vs. Vormonat)". Derzeit zeigt die Budget-Seite ### BL-03 — Essensplan: Drag & Drop zwischen Slots und Tagen -**Status:** Offen +**Status:** Erledigt (v0.3.0) **Aufwand:** M (2–4 Tage) SPEC: „Drag & Drop zwischen Tagen/Slots". Die Wochenansicht zeigt Mahlzeit-Karten aber unterstützt kein Drag & Drop. Mahlzeiten können nur gelöscht und neu angelegt, nicht verschoben werden. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee27ec..f5008e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Calendar: recurring events are now expanded in GET /api/v1/calendar — all occurrences within the requested date window are returned as virtual instances; duration is preserved; instances are marked with is_recurring_instance=1 and shown with a ↻ icon in the agenda view; /upcoming also expands recurring events within a 90-day window - Budget: recurring entries auto-generate instances for each viewed month; instances deleted by the user are skipped permanently via `budget_recurrence_skipped` table; generated instances are marked with ↩ in the transaction list - Budget: month-over-month comparison in summary cards — each card (Einnahmen, Ausgaben, Saldo) shows a trend line (▲/▼ + delta amount vs. previous month); previous month summary is fetched in parallel with current month +- Meals: drag & drop between slots and days using Pointer Events (touch + mouse); ghost element follows pointer; drop on occupied slot swaps meals; reduced-motion: no ghost animation, interaction still works ## [0.2.1] - 2026-03-30 diff --git a/public/pages/meals.js b/public/pages/meals.js index ae0061f..d66cdf7 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -197,7 +197,7 @@ function renderSlot(date, type, mealsForDay) { const canTransfer = ingCount > 0 && ingDone < ingCount; return ` -
+
${type.label}
{ + const card = e.target.closest('.meal-card'); + if (!card) return; + if (e.target.closest('[data-action="delete-meal"], [data-action="transfer-meal"]')) return; + + const slot = card.closest('.meal-slot'); + if (!slot) return; + + const mealId = parseInt(slot.dataset.mealId, 10); + const sourceDate = slot.dataset.date; + const sourceType = slot.dataset.type; + + e.preventDefault(); + card.setPointerCapture(e.pointerId); + + let ghost = null; + if (!reducedMotion) { + ghost = card.cloneNode(true); + ghost.classList.add('meal-card--ghost'); + ghost.style.width = card.offsetWidth + 'px'; + ghost.style.height = card.offsetHeight + 'px'; + ghost.style.left = (e.clientX - card.offsetWidth / 2) + 'px'; + ghost.style.top = (e.clientY - card.offsetHeight / 2) + 'px'; + document.body.appendChild(ghost); + } + + slot.classList.add('meal-slot--dragging'); + dragging = { mealId, sourceDate, sourceType, ghost, card, slot }; + + let lastTarget = null; + + function onMove(ev) { + if (!dragging) return; + if (ghost) { + ghost.style.left = (ev.clientX - ghost.offsetWidth / 2) + 'px'; + ghost.style.top = (ev.clientY - ghost.offsetHeight / 2) + 'px'; + } + if (ghost) ghost.style.display = 'none'; + const el = document.elementFromPoint(ev.clientX, ev.clientY); + if (ghost) ghost.style.display = ''; + + const targetSlot = el?.closest('.meal-slot'); + if (targetSlot !== lastTarget) { + lastTarget?.classList.remove('meal-slot--drop-target'); + if (targetSlot && targetSlot !== dragging.slot) { + targetSlot.classList.add('meal-slot--drop-target'); + } + lastTarget = targetSlot; + } + } + + async function onUp(ev) { + if (!dragging) return; + cleanup(); + + if (ghost) ghost.style.display = 'none'; + const el = document.elementFromPoint(ev.clientX, ev.clientY); + if (ghost) ghost.style.display = ''; + + const targetSlot = el?.closest('.meal-slot'); + if (targetSlot && targetSlot !== dragging.slot) { + const targetDate = targetSlot.dataset.date; + const targetType = targetSlot.dataset.type; + const targetMealId = targetSlot.dataset.mealId ? parseInt(targetSlot.dataset.mealId, 10) : null; + _suppressNextClick = true; + setTimeout(() => { _suppressNextClick = false; }, 300); + await moveMeal(dragging.mealId, dragging.sourceDate, dragging.sourceType, targetDate, targetType, targetMealId); + } + } + + function onCancel() { cleanup(); } + + function cleanup() { + ghost?.remove(); + dragging?.slot?.classList.remove('meal-slot--dragging'); + lastTarget?.classList.remove('meal-slot--drop-target'); + dragging = null; + card.removeEventListener('pointermove', onMove); + card.removeEventListener('pointerup', onUp); + card.removeEventListener('pointercancel', onCancel); + } + + card.addEventListener('pointermove', onMove); + card.addEventListener('pointerup', onUp); + card.addEventListener('pointercancel', onCancel); + }); + + // Suppress click after a completed drag + grid.addEventListener('click', (e) => { + if (_suppressNextClick) { + e.stopImmediatePropagation(); + _suppressNextClick = false; + } + }, true); +} + +async function moveMeal(mealId, sourceDate, sourceType, targetDate, targetType, targetMealId) { + try { + if (targetMealId) { + // Swap: move both meals to each other's slots + await Promise.all([ + api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType }), + api.put(`/meals/${targetMealId}`, { date: sourceDate, meal_type: sourceType }), + ]); + const m1 = state.meals.find((m) => m.id === mealId); + const m2 = state.meals.find((m) => m.id === targetMealId); + if (m1) { m1.date = targetDate; m1.meal_type = targetType; } + if (m2) { m2.date = sourceDate; m2.meal_type = sourceType; } + } else { + // Move to empty slot + await api.put(`/meals/${mealId}`, { date: targetDate, meal_type: targetType }); + const m = state.meals.find((m) => m.id === mealId); + if (m) { m.date = targetDate; m.meal_type = targetType; } + } + renderWeekGrid(); + } catch { + // Re-render to restore visual state + renderWeekGrid(); + } } // -------------------------------------------------------- diff --git a/public/styles/meals.css b/public/styles/meals.css index 0cbea99..81939a8 100644 --- a/public/styles/meals.css +++ b/public/styles/meals.css @@ -416,3 +416,32 @@ .meal-slot--empty { opacity: 0.6; } + +/* -------------------------------------------------------- + * Drag & Drop + * -------------------------------------------------------- */ +.meal-card--ghost { + position: fixed; + z-index: var(--z-modal); + pointer-events: none; + opacity: 0.85; + box-shadow: var(--shadow-lg); + transform: rotate(2deg) scale(1.03); + transition: none; +} + +.meal-slot--dragging { + opacity: 0.35; +} + +.meal-slot--drop-target { + outline: 2px dashed var(--color-accent); + outline-offset: 2px; + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .meal-card--ghost { + transform: none; + } +}