feat: Aufgaben per Touch zwischen Zeitslots verschieben (Mobile-Drag-and-Drop)
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:
parent
fba9a5d533
commit
2042c54b2d
19
CHANGELOG.md
19
CHANGELOG.md
@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
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
|
## [1.126.0] - 2026-09-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -29,6 +29,10 @@ interface GridTaskBlockProps {
|
|||||||
setSelectedTaskForRecurrence: (task: Task) => void;
|
setSelectedTaskForRecurrence: (task: Task) => void;
|
||||||
handleDragStart: (e: React.DragEvent, task: Task) => void;
|
handleDragStart: (e: React.DragEvent, task: Task) => void;
|
||||||
handleDragEnd: (e: React.DragEvent) => 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;
|
getSlotHeight: (duration: number) => number;
|
||||||
draggedTask: Task | null;
|
draggedTask: Task | null;
|
||||||
addSubTask: (parentId: string, title: string) => void;
|
addSubTask: (parentId: string, title: string) => void;
|
||||||
@ -68,6 +72,8 @@ export function GridTaskBlock({
|
|||||||
setSelectedTaskForRecurrence,
|
setSelectedTaskForRecurrence,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
handleTaskTouchStart,
|
||||||
|
handleTaskTouchMovePending,
|
||||||
getSlotHeight,
|
getSlotHeight,
|
||||||
draggedTask,
|
draggedTask,
|
||||||
addSubTask,
|
addSubTask,
|
||||||
@ -279,6 +285,8 @@ export function GridTaskBlock({
|
|||||||
draggable={!editingTaskId && !isResizing}
|
draggable={!editingTaskId && !isResizing}
|
||||||
onDragStart={(e) => handleDragStart(e, task)}
|
onDragStart={(e) => handleDragStart(e, task)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onTouchStart={(e) => handleTaskTouchStart(e, task)}
|
||||||
|
onTouchMove={handleTaskTouchMovePending}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (showProjectPicker) {
|
if (showProjectPicker) {
|
||||||
// Don't close if mouse moved into the project picker dropdown
|
// Don't close if mouse moved into the project picker dropdown
|
||||||
|
|||||||
@ -4647,8 +4647,10 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (e: DragEvent, date: Date, slot?: string) => {
|
// e ist null bei einem Touch-Drop (kein natives DragEvent vorhanden) -- der
|
||||||
e.preventDefault();
|
// Touch-Mechanismus ruft dieselbe Funktion direkt auf, siehe handleTaskTouchEnd.
|
||||||
|
const handleDrop = async (e: DragEvent | null, date: Date, slot?: string) => {
|
||||||
|
e?.preventDefault();
|
||||||
if (draggedTask) {
|
if (draggedTask) {
|
||||||
const targetDateObj = date;
|
const targetDateObj = date;
|
||||||
|
|
||||||
@ -4789,6 +4791,127 @@ export default function WeeklyView() {
|
|||||||
setDropPreview(null);
|
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) => {
|
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -7209,6 +7332,8 @@ export default function WeeklyView() {
|
|||||||
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
|
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
|
||||||
handleDragStart={handleDragStart}
|
handleDragStart={handleDragStart}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
|
handleTaskTouchStart={handleTaskTouchStart}
|
||||||
|
handleTaskTouchMovePending={handleTaskTouchMovePending}
|
||||||
getSlotHeight={getSlotHeight}
|
getSlotHeight={getSlotHeight}
|
||||||
draggedTask={draggedTask as any}
|
draggedTask={draggedTask as any}
|
||||||
addSubTask={addSubTask}
|
addSubTask={addSubTask}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user