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 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-31 10:23:39 +02:00
parent 6a860f2c13
commit 6fd209ba5e
4 changed files with 165 additions and 2 deletions
+1 -1
View File
@@ -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 (24 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.
+1
View File
@@ -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
+134 -1
View File
@@ -197,7 +197,7 @@ function renderSlot(date, type, mealsForDay) {
const canTransfer = ingCount > 0 && ingDone < ingCount;
return `
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-type="${type.key}">
<div class="meal-slot meal-slot--has-meal" data-meal-id="${meal.id}" data-date="${meal.date}" data-type="${type.key}">
<div class="meal-slot__type-label">${type.label}</div>
<div class="meal-card"
data-action="edit-meal"
@@ -282,6 +282,139 @@ function wireGrid(grid) {
if (card) { e.preventDefault(); card.click(); }
}
});
wireDragDrop(grid);
}
// --------------------------------------------------------
// Drag & Drop
// --------------------------------------------------------
let _suppressNextClick = false;
function wireDragDrop(grid) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let dragging = null; // { mealId, sourceDate, sourceType, ghost, startX, startY }
grid.addEventListener('pointerdown', (e) => {
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();
}
}
// --------------------------------------------------------
+29
View File
@@ -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;
}
}