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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user