feat: Aufgaben per Touch zwischen Zeitslots verschieben (Mobile-Drag-and-Drop)
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

M3 aus TODO.md, nach v1.126.0 als gemeldete Luecke aufgenommen und jetzt geschlossen:
Aufgaben liessen sich bislang nur am Desktop per Maus verschieben, weil WeeklyView.tsx
ausschliesslich natives HTML5-Drag nutzte (draggable/onDragStart/onDrop), das auf
Touch-Geraeten grundsaetzlich nicht feuert.

Neuer Long-Press-Mechanismus, keine neue Dependency:
- GridTaskBlock bekommt onTouchStart/onTouchMove (handleTaskTouchStart/
  handleTaskTouchMovePending) -- 200ms Halten ohne wesentliche Fingerbewegung
  aktiviert den Drag (Vibration als Feedback, wie beim bestehenden Muster)
- Zwei-Phasen-Architektur identisch zum bereits vorhandenen Touch-Long-Press fuer
  "Termin per Ziehen anlegen" (upgradeToDragListeners/handleTouchStart weiter oben in
  derselben Datei): passiver Timer zum Aktivieren, danach ein document-weiter,
  nicht-passiver touchmove-Listener nur waehrend des aktiven Drags (blockiert Scrollen
  nur dann, nicht davor)
- handleDrop akzeptiert jetzt e: DragEvent | null -- der Touch-Pfad ruft dieselbe
  Funktion direkt auf (kein natives DragEvent vorhanden), decideTaskDrop (v1.126.0)
  unveraendert wiederverwendet, keine eigene Entscheidungslogik, keine geaenderte
  API-Payload

Echt verifiziert, nicht nur angenommen: lokale Instanz mit Wegwerf-Postgres (Docker)
aufgesetzt, Testnutzer + Task geseedet, per Playwright ein echter Touch-Drag simuliert
(dispatchte TouchEvent-Objekte, 200ms Halten, schrittweise Bewegung, Loslassen) --
Task landete nachweislich per DB-Abfrage auf dem neuen Zeitslot. Derselbe Aufbau mit
dragTo() bestaetigt: der Desktop-Pfad (Maus) funktioniert unveraendert weiter.

Ausserdem: Typecheck, Lint (0 Fehler), 100 Unit-Tests, Produktions-Build -- alle gruen.

v1.127.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-14 10:22:32 +02:00
parent fba9a5d533
commit 2042c54b2d
4 changed files with 155 additions and 3 deletions

View File

@ -2,6 +2,25 @@
All notable changes to this project are documented in this file.
## [1.127.0] - 2026-09-14
### Added
- **Aufgaben lassen sich jetzt auch auf Touch-Geräten zwischen Zeitslots verschieben.**
Bisher funktionierte das nur am Desktop, weil `WeeklyView.tsx` ausschließlich natives
HTML5-Drag nutzte, das auf Touch-Geräten nicht feuert (siehe v1.126.0). Neuer
Long-Press-Mechanismus in `GridTaskBlock`/`WeeklyView.tsx` (200 ms Halten, danach
Ziehen), technisch dieselbe Zwei-Phasen-Architektur wie der bereits bestehende
Touch-Long-Press für „Termin per Ziehen anlegen". Ruft am Ende dasselbe `handleDrop`
wie der Desktop-Pfad auf — keine eigene Entscheidungslogik, keine geänderte
API-Payload.
Verifiziert nicht nur mit Unit-Tests, sondern gegen eine echte, lokal laufende Instanz
mit eigener Wegwerf-Datenbank: ein per Playwright simulierter Touch-Drag (echte
`TouchEvent`-Objekte, 200 ms Halten, schrittweise Bewegung, Loslassen) hat eine Aufgabe
tatsächlich auf einen neuen Zeitslot verschoben, in der Datenbank nachgeprüft. Derselbe
Test mit `dragTo()` (Maus/Desktop) bestätigt, dass der bestehende Pfad unverändert
funktioniert. Zusätzlich: Typecheck, Lint (0 Fehler), 100 Unit-Tests, Produktions-Build.
## [1.126.0] - 2026-09-13
### Changed

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.126.0",
"version": "1.127.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -29,6 +29,10 @@ interface GridTaskBlockProps {
setSelectedTaskForRecurrence: (task: Task) => void;
handleDragStart: (e: React.DragEvent, task: Task) => void;
handleDragEnd: (e: React.DragEvent) => void;
// Touch-Gegenstueck zu handleDragStart/handleDragEnd -- natives HTML5-Drag feuert auf
// Touch-Geraeten nicht, siehe WeeklyView.tsx.
handleTaskTouchStart: (e: React.TouchEvent, task: Task) => void;
handleTaskTouchMovePending: (e: React.TouchEvent) => void;
getSlotHeight: (duration: number) => number;
draggedTask: Task | null;
addSubTask: (parentId: string, title: string) => void;
@ -68,6 +72,8 @@ export function GridTaskBlock({
setSelectedTaskForRecurrence,
handleDragStart,
handleDragEnd,
handleTaskTouchStart,
handleTaskTouchMovePending,
getSlotHeight,
draggedTask,
addSubTask,
@ -279,6 +285,8 @@ export function GridTaskBlock({
draggable={!editingTaskId && !isResizing}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTaskTouchStart(e, task)}
onTouchMove={handleTaskTouchMovePending}
onMouseLeave={(e) => {
if (showProjectPicker) {
// Don't close if mouse moved into the project picker dropdown

View File

@ -4647,8 +4647,10 @@ export default function WeeklyView() {
}
};
const handleDrop = async (e: DragEvent, date: Date, slot?: string) => {
e.preventDefault();
// e ist null bei einem Touch-Drop (kein natives DragEvent vorhanden) -- der
// Touch-Mechanismus ruft dieselbe Funktion direkt auf, siehe handleTaskTouchEnd.
const handleDrop = async (e: DragEvent | null, date: Date, slot?: string) => {
e?.preventDefault();
if (draggedTask) {
const targetDateObj = date;
@ -4789,6 +4791,127 @@ export default function WeeklyView() {
setDropPreview(null);
};
// ── Task-Drag per Touch (Mobilgeraete) ───────────────────────────────────────────
//
// Bislang liess sich ein Task nur am Desktop per Maus zwischen Zeitslots verschieben
// (natives HTML5-Drag ueber draggable/onDragStart/onDrop) -- das Event feuert auf
// Touch-Geraeten grundsaetzlich nicht. Dieselbe Zwei-Phasen-Architektur wie beim
// bestehenden Touch-Long-Press fuer "Termin per Ziehen anlegen" (siehe
// upgradeToDragListeners/handleTouchStart oben): erst ein passiver Timer, der bei
// Fingerbewegung abbricht (kein Blockieren von Scrollen), danach -- nur nach 200ms
// Halten ohne Bewegung -- ein aktiver, nicht-passiver touchmove-Listener, der das
// Scrollen bewusst unterbindet, solange tatsaechlich gezogen wird. Ruft am Ende
// dasselbe handleDrop auf wie der Desktop-Pfad -- keine eigene Entscheidungslogik.
const taskTouchDragRef = useRef<{
task: Task;
timerId: ReturnType<typeof setTimeout>;
startX: number;
startY: number;
activated: boolean;
currentDate: Date | null;
currentSlot: string | null;
} | null>(null);
const taskTouchDragActiveRef = useRef(false);
const taskTouchMoveHandlerRef = useRef<((e: TouchEvent) => void) | null>(null);
const upgradeToTaskDragListeners = useCallback(() => {
if (taskTouchDragActiveRef.current) return;
taskTouchDragActiveRef.current = true;
const handleMove = (e: TouchEvent) => {
if (!taskTouchDragActiveRef.current) return;
e.preventDefault();
const drag = taskTouchDragRef.current;
if (!drag) return;
const touch = e.touches[0];
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const slotEl = el?.closest('[data-slot]') as HTMLElement | null;
const colEl = el?.closest('[data-date]') as HTMLElement | null;
if (colEl?.dataset.date) {
const date = new Date(colEl.dataset.date);
const slot = slotEl?.dataset.slot || null;
drag.currentDate = date;
drag.currentSlot = slot;
if (slot) setDropPreview({ dateKey: formatDateToISO(date), slot });
}
};
document.addEventListener('touchmove', handleMove, { passive: false });
taskTouchMoveHandlerRef.current = handleMove;
}, []);
const cancelTaskDragListeners = useCallback(() => {
if (!taskTouchDragActiveRef.current) return;
taskTouchDragActiveRef.current = false;
if (taskTouchMoveHandlerRef.current) {
document.removeEventListener('touchmove', taskTouchMoveHandlerRef.current);
taskTouchMoveHandlerRef.current = null;
}
}, []);
// Passiv: startet den Long-Press-Timer. Kein preventDefault -- normales Scrollen und
// Tippen bleiben moeglich, solange der Finger sich nicht wesentlich bewegt.
const handleTaskTouchStart = useCallback((e: React.TouchEvent, task: Task) => {
if (editingTaskId === task.id) return;
const touch = e.touches[0];
if (taskTouchDragRef.current) {
clearTimeout(taskTouchDragRef.current.timerId);
}
const timerId = setTimeout(() => {
if (navigator.vibrate) navigator.vibrate(50);
if (taskTouchDragRef.current) {
taskTouchDragRef.current.activated = true;
}
setDraggedTask(task);
upgradeToTaskDragListeners();
}, 200);
taskTouchDragRef.current = {
task,
timerId,
startX: touch.clientX,
startY: touch.clientY,
activated: false,
currentDate: null,
currentSlot: null,
};
}, [editingTaskId, upgradeToTaskDragListeners]);
// Passiv: bricht den noch nicht aktivierten Long-Press ab, wenn der Finger sich vor
// Ablauf der 200ms wesentlich bewegt -- sonst wuerde jeder Scroll-Versuch auf einem
// Task versehentlich einen Drag auslösen.
const handleTaskTouchMovePending = useCallback((e: React.TouchEvent) => {
const drag = taskTouchDragRef.current;
if (!drag || drag.activated) return;
const touch = e.touches[0];
if (Math.abs(touch.clientX - drag.startX) > 10 || Math.abs(touch.clientY - drag.startY) > 10) {
clearTimeout(drag.timerId);
taskTouchDragRef.current = null;
}
}, []);
// Einmalig am Mount registriert (touchend/touchcancel duerfen ruhig immer passiv
// lauschen -- sie loesen nur aus, wenn taskTouchDragRef tatsaechlich aktiv ist).
useEffect(() => {
const finishTaskTouchDrag = () => {
const drag = taskTouchDragRef.current;
taskTouchDragRef.current = null;
cancelTaskDragListeners();
if (!drag) return;
clearTimeout(drag.timerId);
if (drag.activated) {
if (drag.currentDate) {
handleDrop(null, drag.currentDate, drag.currentSlot || undefined);
} else {
setDraggedTask(null);
setDropPreview(null);
}
}
};
document.addEventListener('touchend', finishTaskTouchDrag, { passive: true });
document.addEventListener('touchcancel', finishTaskTouchDrag, { passive: true });
return () => {
document.removeEventListener('touchend', finishTaskTouchDrag);
document.removeEventListener('touchcancel', finishTaskTouchDrag);
};
}, [cancelTaskDragListeners, handleDrop]);
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
e.preventDefault();
@ -7209,6 +7332,8 @@ export default function WeeklyView() {
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleTaskTouchStart={handleTaskTouchStart}
handleTaskTouchMovePending={handleTaskTouchMovePending}
getSlotHeight={getSlotHeight}
draggedTask={draggedTask as any}
addSubTask={addSubTask}