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
|
### BL-03 — Essensplan: Drag & Drop zwischen Slots und Tagen
|
||||||
|
|
||||||
**Status:** Offen
|
**Status:** Erledigt (v0.3.0)
|
||||||
**Aufwand:** M (2–4 Tage)
|
**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.
|
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
|
- 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: 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
|
- 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
|
## [0.2.1] - 2026-03-30
|
||||||
|
|
||||||
|
|||||||
+134
-1
@@ -197,7 +197,7 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
|
|
||||||
return `
|
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-slot__type-label">${type.label}</div>
|
||||||
<div class="meal-card"
|
<div class="meal-card"
|
||||||
data-action="edit-meal"
|
data-action="edit-meal"
|
||||||
@@ -282,6 +282,139 @@ function wireGrid(grid) {
|
|||||||
if (card) { e.preventDefault(); card.click(); }
|
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 {
|
.meal-slot--empty {
|
||||||
opacity: 0.6;
|
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