diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9641f..33ef821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 2fb46ac..b2ecb6d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index 9c53367..d975fc2 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -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 diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index eac700c..7e4a3c6 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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; + 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}