refactor: Drop-Entscheidung aus handleDrop in reine, getestete Funktion ausgelagert
Erster Schnitt aus TODO-CODESCENE-FORTSETZUNG-2026-09-12.md, nach Owner-Freigabe. handleDrop (WeeklyView.tsx) vermischte fachliche Drop-Entscheidung (Zielslot bei Belegung finden, Subtask-Befoerderung vs. Someday-Verschieben vs. normales Verschieben) mit React-State-Updates und Netzwerkaufrufen. Die Entscheidung ist jetzt eine reine Funktion decideTaskDrop in src/lib/weekly/drag-drop.ts: - kennt weder DOM noch React noch Netzwerk, nur Task/Zieltag/Slot als Daten - kein Import von date-utils.ts: der Aufrufer liefert dayOfWeek/scheduledDate/ allSlots fertig, damit die Datei nur einen Typ-Import hat (wie date-utils.ts/ format.ts/event-layout.ts in diesem Ordner) und ohne Next.js-Bundler in node:test laeuft - 15 Charakterisierungstests: freier/belegter/geschuetzter Slot, Drop ohne Slot mit/ohne vorhandene Startzeit, Subtask-Befoerderung, Someday-Provider- Vererbung (eigener vs. geerbter Provider), externe Sync-Bedingung, virtuelle wiederkehrende Instanzen (Server materialisiert sie weiterhin unveraendert ueber VIRTUAL_ID_REGEX in api/tasks/route.ts) handleDrop selbst liest jetzt nur noch Eingaben, ruft decideTaskDrop auf und fuehrt in einem switch exakt die bisherigen Seiteneffekte aus -- keine geaenderten fetch-Aufrufe, kein geaendertes optimistisches UI-Update, keine geaenderte API-Payload. Wichtiger Nebenbefund, kein Teil dieses Refactorings: Task-Drag-and-Drop funktioniert nur am Desktop. WeeklyView.tsx nutzt ausschliesslich natives HTML5-Drag (draggable/onDragStart/onDrop), das auf Touch-Geraeten nicht feuert. Kein separater Touch-Mechanismus vorhanden; react-dnd/ react-dnd-html5-backend sind installiert, aber nirgends importiert. decideTaskDrop ist bewusst so geschnitten, dass ein kuenftiger Touch-Mechanismus sie unveraendert wiederverwenden koennte -- das Bauen dieses Mechanismus ist ein eigenes Feature-Vorhaben (siehe TODO.md Block M3). Verifiziert: Typecheck, Lint (0 Fehler), 100 Unit-Tests (85 -> 100, 15 neu), Produktions-Build -- alle gruen. Kein Playwright-E2E fuer Drag-and-Drop (existiert im Repo bislang nicht). v1.126.0 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e3c6087de
commit
fba9a5d533
29
CHANGELOG.md
29
CHANGELOG.md
@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
## [1.126.0] - 2026-09-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Erster Refactoring-Schnitt aus `TODO-CODESCENE-FORTSETZUNG-2026-09-12.md`: die fachliche
|
||||||
|
Drop-Entscheidung aus `WeeklyView.tsx`s `handleDrop` (Ziel-Slot-Suche bei Belegung,
|
||||||
|
Unterschied Subtask-Beförderung/Someday-Verschieben/normales Verschieben) ist jetzt eine
|
||||||
|
reine, isoliert getestete Funktion `decideTaskDrop` in `src/lib/weekly/drag-drop.ts`.
|
||||||
|
`handleDrop` liest nur noch Eingaben, ruft die Entscheidung auf und führt exakt die
|
||||||
|
bisherigen Seiteneffekte aus (kein geändertes `fetch`, kein geändertes optimistisches
|
||||||
|
UI-Update, keine geänderte API-Payload).
|
||||||
|
- 15 neue Unit-Tests decken die Entscheidung ab: freier/belegter/geschützter Slot, Drop ohne
|
||||||
|
Slot mit/ohne vorhandene Startzeit, Subtask-Beförderung, Someday-Provider-Vererbung,
|
||||||
|
externe Sync-Bedingung, virtuelle wiederkehrende Instanzen (Server materialisiert sie
|
||||||
|
weiterhin unverändert).
|
||||||
|
|
||||||
|
### Bekannter, nicht behobener Befund
|
||||||
|
- Aufgaben lassen sich per Drag-and-Drop zwischen Zeitslots **nur auf dem Desktop**
|
||||||
|
verschieben — `WeeklyView.tsx` nutzt dafür ausschließlich natives HTML5-Drag (`draggable`/
|
||||||
|
`onDragStart`/`onDrop`), das auf Touch-Geräten nicht feuert. Kein separater
|
||||||
|
Touch-Mechanismus existiert; `react-dnd`/`react-dnd-html5-backend` sind installiert, aber
|
||||||
|
nirgends importiert. `decideTaskDrop` kennt "Drag" gar nicht (nur Quelle/Ziel als Daten)
|
||||||
|
und könnte von einem künftigen Touch-Mechanismus unverändert wiederverwendet werden — das
|
||||||
|
Bauen dieses Mechanismus selbst ist ein eigenes Feature-Vorhaben, nicht Teil dieses
|
||||||
|
Refactorings.
|
||||||
|
|
||||||
|
Verifiziert: Typecheck, Lint (0 Fehler), 100 Unit-Tests (85 → 100, 15 neu),
|
||||||
|
Produktions-Build — alle grün. Kein Playwright-E2E-Lauf für Drag-and-Drop (existiert im
|
||||||
|
Repo bislang nicht, siehe `TODO-CODESCENE-FORTSETZUNG-2026-09-12.md`).
|
||||||
|
|
||||||
## [1.125.4] - 2026-09-13
|
## [1.125.4] - 2026-09-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.125.4",
|
"version": "1.126.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": {
|
||||||
|
|||||||
@ -109,6 +109,7 @@ import {
|
|||||||
} from "@/lib/weekly/format";
|
} from "@/lib/weekly/format";
|
||||||
import { isAllDayEvent } from "@/lib/weekly/date-utils";
|
import { isAllDayEvent } from "@/lib/weekly/date-utils";
|
||||||
import { computeEventOverlapLayout } from "@/lib/weekly/event-layout";
|
import { computeEventOverlapLayout } from "@/lib/weekly/event-layout";
|
||||||
|
import { decideTaskDrop } from "@/lib/weekly/drag-drop";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import UserMenu from "./UserMenu";
|
import UserMenu from "./UserMenu";
|
||||||
@ -4650,142 +4651,125 @@ export default function WeeklyView() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedTask) {
|
if (draggedTask) {
|
||||||
const targetDateObj = date;
|
const targetDateObj = date;
|
||||||
const dayOfWeek = targetDateObj.getDay();
|
|
||||||
|
|
||||||
let targetSlot = slot;
|
// Fachliche Entscheidung (Ziel-Slot, resultierende Operation) ausgelagert nach
|
||||||
|
// src/lib/weekly/drag-drop.ts -- reine Funktion, isoliert testbar, siehe dort.
|
||||||
|
const operation = decideTaskDrop(
|
||||||
|
draggedTask,
|
||||||
|
targetDateObj.getDay(),
|
||||||
|
formatDateToISO(targetDateObj),
|
||||||
|
slot,
|
||||||
|
getTimeSlots(effectiveCellDuration, 0, 24),
|
||||||
|
(s, excludeId) => isSlotOccupiedByTask(targetDateObj, s, excludeId),
|
||||||
|
(s) => isSlotProtected(targetDateObj, s),
|
||||||
|
somedayLists,
|
||||||
|
);
|
||||||
|
|
||||||
// If no slot provided (dropped on header/background), try to keep original time
|
switch (operation.kind) {
|
||||||
if (!targetSlot && draggedTask.startTime) {
|
case "promoteSubtask": {
|
||||||
targetSlot = draggedTask.startTime;
|
// Remove subtask from parent in UI
|
||||||
}
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
// Collision detection / Find next free slot
|
t.id === draggedTask.parentTaskId
|
||||||
if (targetSlot) {
|
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
||||||
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
|
: t
|
||||||
const allSlots = getTimeSlots(
|
)
|
||||||
effectiveCellDuration,
|
|
||||||
0,
|
|
||||||
24,
|
|
||||||
);
|
);
|
||||||
const startIndex = allSlots.indexOf(targetSlot);
|
// Add as standalone task in UI
|
||||||
if (startIndex !== -1) {
|
setTasks((prev) => [
|
||||||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
...prev,
|
||||||
const nextSlot = allSlots[i];
|
{
|
||||||
if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) {
|
...draggedTask,
|
||||||
targetSlot = nextSlot;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the task is a subtask, promote it to a standalone task
|
|
||||||
if (draggedTask.parentTaskId) {
|
|
||||||
const newScheduledDate = formatDateToISO(targetDateObj);
|
|
||||||
// Remove subtask from parent in UI
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.id === draggedTask.parentTaskId
|
|
||||||
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
|
|
||||||
: t
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// Add as standalone task in UI
|
|
||||||
setTasks((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
...draggedTask,
|
|
||||||
parentTaskId: null,
|
|
||||||
scheduledDate: newScheduledDate,
|
|
||||||
dayOfWeek,
|
|
||||||
startTime: targetSlot || "",
|
|
||||||
} as Task,
|
|
||||||
]);
|
|
||||||
// Persist
|
|
||||||
try {
|
|
||||||
await fetch("/api/tasks", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: draggedTask.id,
|
|
||||||
parentTaskId: null,
|
parentTaskId: null,
|
||||||
scheduledDate: newScheduledDate,
|
scheduledDate: operation.scheduledDate,
|
||||||
dayOfWeek,
|
dayOfWeek: operation.dayOfWeek,
|
||||||
startTime: targetSlot || "",
|
startTime: operation.startTime,
|
||||||
}),
|
} as Task,
|
||||||
});
|
]);
|
||||||
} catch (error) {
|
// Persist
|
||||||
console.error("Error promoting subtask:", error);
|
try {
|
||||||
}
|
await fetch("/api/tasks", {
|
||||||
setDraggedTask(null);
|
|
||||||
setDropPreview(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the task was from a someday list, move it to the calendar
|
|
||||||
if (draggedTask.somedayListId) {
|
|
||||||
const newScheduledDate = formatDateToISO(targetDateObj);
|
|
||||||
// Inherit provider from someday list if task doesn't have one
|
|
||||||
const sourceList = somedayLists.find(l => l.id === draggedTask.somedayListId);
|
|
||||||
const taskProvider = draggedTask.externalProvider || sourceList?.externalProvider || null;
|
|
||||||
// Remove from someday list UI
|
|
||||||
setSomedayLists((prev) =>
|
|
||||||
prev.map((l) => ({
|
|
||||||
...l,
|
|
||||||
tasks: l.tasks.filter((t) => t.id !== draggedTask.id),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
// Add to calendar tasks
|
|
||||||
setTasks((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
...draggedTask,
|
|
||||||
somedayListId: null,
|
|
||||||
somedaySlotIndex: null,
|
|
||||||
scheduledDate: newScheduledDate,
|
|
||||||
dayOfWeek,
|
|
||||||
startTime: targetSlot || "",
|
|
||||||
externalProvider: taskProvider,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
// Persist
|
|
||||||
try {
|
|
||||||
await fetch("/api/tasks", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: draggedTask.id,
|
|
||||||
somedayListId: null,
|
|
||||||
somedaySlotIndex: null,
|
|
||||||
scheduledDate: newScheduledDate,
|
|
||||||
dayOfWeek,
|
|
||||||
startTime: targetSlot || "",
|
|
||||||
...(taskProvider && { externalProvider: taskProvider }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync due date to external provider when moving from someday to calendar
|
|
||||||
if (draggedTask.externalId && draggedTask.externalProvider) {
|
|
||||||
fetch("/api/tasks/sync", {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
taskId: draggedTask.id,
|
id: operation.taskId,
|
||||||
scheduledDate: newScheduledDate,
|
parentTaskId: null,
|
||||||
|
scheduledDate: operation.scheduledDate,
|
||||||
|
dayOfWeek: operation.dayOfWeek,
|
||||||
|
startTime: operation.startTime,
|
||||||
}),
|
}),
|
||||||
}).catch((e) => console.error("Sync error:", e));
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error promoting subtask:", error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
setDraggedTask(null);
|
||||||
console.error("Error moving task from someday to calendar:", error);
|
setDropPreview(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
moveTaskToSlot(
|
case "moveFromSomeday": {
|
||||||
draggedTask.id,
|
// Remove from someday list UI
|
||||||
dayOfWeek,
|
setSomedayLists((prev) =>
|
||||||
targetSlot || "",
|
prev.map((l) => ({
|
||||||
targetDateObj,
|
...l,
|
||||||
);
|
tasks: l.tasks.filter((t) => t.id !== draggedTask.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
// Add to calendar tasks
|
||||||
|
setTasks((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...draggedTask,
|
||||||
|
somedayListId: null,
|
||||||
|
somedaySlotIndex: null,
|
||||||
|
scheduledDate: operation.scheduledDate,
|
||||||
|
dayOfWeek: operation.dayOfWeek,
|
||||||
|
startTime: operation.startTime,
|
||||||
|
externalProvider: operation.externalProvider,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Persist
|
||||||
|
try {
|
||||||
|
await fetch("/api/tasks", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: operation.taskId,
|
||||||
|
somedayListId: null,
|
||||||
|
somedaySlotIndex: null,
|
||||||
|
scheduledDate: operation.scheduledDate,
|
||||||
|
dayOfWeek: operation.dayOfWeek,
|
||||||
|
startTime: operation.startTime,
|
||||||
|
...(operation.externalProvider && { externalProvider: operation.externalProvider }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync due date to external provider when moving from someday to calendar
|
||||||
|
if (operation.syncExternal) {
|
||||||
|
fetch("/api/tasks/sync", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(operation.syncExternal),
|
||||||
|
}).catch((e) => console.error("Sync error:", e));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error moving task from someday to calendar:", error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "moveToSlot": {
|
||||||
|
moveTaskToSlot(
|
||||||
|
operation.taskId,
|
||||||
|
operation.dayOfWeek,
|
||||||
|
operation.startTime,
|
||||||
|
targetDateObj,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "noop":
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
setDraggedTask(null);
|
setDraggedTask(null);
|
||||||
}
|
}
|
||||||
|
|||||||
135
src/lib/weekly/drag-drop.ts
Normal file
135
src/lib/weekly/drag-drop.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import type { Task, SomedayList } from "@/lib/weekly/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fachliche Drop-Entscheidung fuer Aufgaben im Tagesraster.
|
||||||
|
*
|
||||||
|
* Reine Funktion, extrahiert aus WeeklyView.tsx' handleDrop (bis v1.125.4 dort inline).
|
||||||
|
* Trifft nur die Entscheidung -- welche Aktion mit welchen Daten -- und fuehrt keine
|
||||||
|
* Seiteneffekte (kein setState, kein fetch) aus. Das erlaubt es, sie ohne Browser, DOM oder
|
||||||
|
* Netzwerk zu testen, und macht sie unabhaengig davon, wie der Drop technisch ausgeloest
|
||||||
|
* wurde (Maus/HTML5-DnD oder ein kuenftiger Touch-Mechanismus fuer Mobilgeraete -- Aufgaben
|
||||||
|
* lassen sich per Drag-and-Drop zwischen Zeitslots bislang NUR auf dem Desktop verschieben,
|
||||||
|
* da WeeklyView.tsx dafuer ausschliesslich das native HTML5-Drag-Event nutzt, das auf
|
||||||
|
* Touch-Geraeten nicht feuert. Diese Funktion selbst kennt "Drag" gar nicht, nur Quelle und
|
||||||
|
* Ziel -- ein spaeterer Touch-Mechanismus koennte sie unveraendert wiederverwenden).
|
||||||
|
*
|
||||||
|
* Deckt nur den Drop auf dem Zeitraster/der Tagesspalte ab (die beiden handleDrop-Aufrufe
|
||||||
|
* in WeeklyView.tsx). Kanban-Spalten und Someday-Listen als Ziel sind eigene Funktionen
|
||||||
|
* (kanbanColumnDrop/kanbanProjectDrop, handleSomedayDrop) und nicht Teil dieser Entscheidung.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Import von date-utils.ts: Datum->String und die Liste aller Zeitslots bringt
|
||||||
|
* der Aufrufer mit (der sie ohnehin schon hat), damit diese Datei ausschliesslich einen
|
||||||
|
* Typ-Import besitzt. Das haelt sie unabhaengig von Node-Modulaufloesung in Tests -- derselbe
|
||||||
|
* Grund, aus dem date-utils.ts/format.ts/event-layout.ts in diesem Ordner ebenfalls nur
|
||||||
|
* `import type` aus types.ts verwenden, nie Laufzeit-Importe voneinander.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TaskDropOperation =
|
||||||
|
| {
|
||||||
|
kind: "promoteSubtask";
|
||||||
|
taskId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
startTime: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "moveFromSomeday";
|
||||||
|
taskId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
startTime: string;
|
||||||
|
externalProvider: string | null;
|
||||||
|
syncExternal: { taskId: string; scheduledDate: string } | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "moveToSlot";
|
||||||
|
taskId: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
startTime: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
}
|
||||||
|
| { kind: "noop" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht ausgehend von `requestedSlot` den ersten freien, nicht geschuetzten Folge-Slot in
|
||||||
|
* `allSlots` fuer `excludeTaskId`. Faellt auf `requestedSlot` selbst zurueck, wenn kein Slot
|
||||||
|
* frei ist -- exakt das bisherige Verhalten von handleDrop, kein neues "bester Slot"-Konzept.
|
||||||
|
*/
|
||||||
|
function resolveTargetSlot(
|
||||||
|
requestedSlot: string,
|
||||||
|
allSlots: string[],
|
||||||
|
excludeTaskId: string,
|
||||||
|
isSlotOccupied: (slot: string, excludeTaskId?: string) => boolean,
|
||||||
|
isSlotProtected: (slot: string) => boolean,
|
||||||
|
): string {
|
||||||
|
if (!isSlotOccupied(requestedSlot, excludeTaskId)) return requestedSlot;
|
||||||
|
|
||||||
|
const startIndex = allSlots.indexOf(requestedSlot);
|
||||||
|
if (startIndex === -1) return requestedSlot;
|
||||||
|
|
||||||
|
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||||||
|
const candidate = allSlots[i];
|
||||||
|
if (!isSlotOccupied(candidate, excludeTaskId) && !isSlotProtected(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requestedSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideTaskDrop(
|
||||||
|
draggedTask: Task | null,
|
||||||
|
dayOfWeek: number,
|
||||||
|
scheduledDate: string,
|
||||||
|
requestedSlot: string | undefined,
|
||||||
|
allSlots: string[],
|
||||||
|
isSlotOccupied: (slot: string, excludeTaskId?: string) => boolean,
|
||||||
|
isSlotProtected: (slot: string) => boolean,
|
||||||
|
somedayLists: SomedayList[],
|
||||||
|
): TaskDropOperation {
|
||||||
|
if (!draggedTask) return { kind: "noop" };
|
||||||
|
|
||||||
|
// Kein Slot mitgegeben (Drop auf Kopf-/Hintergrundbereich): bisherige Startzeit behalten,
|
||||||
|
// falls vorhanden -- exakt das bisherige Verhalten.
|
||||||
|
let targetSlot = requestedSlot ?? (draggedTask.startTime || undefined);
|
||||||
|
if (targetSlot) {
|
||||||
|
targetSlot = resolveTargetSlot(targetSlot, allSlots, draggedTask.id, isSlotOccupied, isSlotProtected);
|
||||||
|
}
|
||||||
|
const startTime = targetSlot || "";
|
||||||
|
|
||||||
|
if (draggedTask.parentTaskId) {
|
||||||
|
return {
|
||||||
|
kind: "promoteSubtask",
|
||||||
|
taskId: draggedTask.id,
|
||||||
|
scheduledDate,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedTask.somedayListId) {
|
||||||
|
const sourceList = somedayLists.find((l) => l.id === draggedTask.somedayListId);
|
||||||
|
const externalProvider = draggedTask.externalProvider || sourceList?.externalProvider || null;
|
||||||
|
const syncExternal =
|
||||||
|
draggedTask.externalId && draggedTask.externalProvider
|
||||||
|
? { taskId: draggedTask.id, scheduledDate }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "moveFromSomeday",
|
||||||
|
taskId: draggedTask.id,
|
||||||
|
scheduledDate,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime,
|
||||||
|
externalProvider,
|
||||||
|
syncExternal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "moveToSlot",
|
||||||
|
taskId: draggedTask.id,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime,
|
||||||
|
scheduledDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
298
tests/unit/drag-drop.test.ts
Normal file
298
tests/unit/drag-drop.test.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { test, describe } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { decideTaskDrop } from "../../src/lib/weekly/drag-drop.ts";
|
||||||
|
import type { Task, SomedayList } from "../../src/lib/weekly/types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charakterisierungstests fuer die Drop-Entscheidung im Tagesraster (WeeklyView.tsx'
|
||||||
|
* handleDrop). Beobachtbares Verhalten aus dem bestehenden Code, nicht neu erfundenes
|
||||||
|
* Wunschverhalten -- siehe die Analyse in TODO-CODESCENE-FORTSETZUNG-2026-09-12.md.
|
||||||
|
*
|
||||||
|
* decideTaskDrop kennt weder Maus noch Touch: sie bekommt Quelle (Task), Ziel (Tag/Slot)
|
||||||
|
* und die bereits vorhandenen Kollisions-Pruefungen als Parameter. Das ist bewusst so, damit
|
||||||
|
* dieselbe Entscheidung spaeter auch von einem Touch-basierten Drag-Mechanismus fuer
|
||||||
|
* Mobilgeraete genutzt werden koennte -- Aufgaben lassen sich heute NUR am Desktop per
|
||||||
|
* Drag-and-Drop verschieben (natives HTML5-Drag-Event, feuert auf Touch-Geraeten nicht).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const baseTask: Task = {
|
||||||
|
id: "t1",
|
||||||
|
title: "Test task",
|
||||||
|
completed: false,
|
||||||
|
order: 0,
|
||||||
|
updatedAt: new Date("2026-09-01T00:00:00Z"),
|
||||||
|
userId: "u1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayOfWeek = 2; // Dienstag
|
||||||
|
const scheduledDate = "2026-09-15";
|
||||||
|
const allSlots = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00"];
|
||||||
|
|
||||||
|
const neverOccupied = () => false;
|
||||||
|
const neverProtected = () => false;
|
||||||
|
|
||||||
|
describe("decideTaskDrop", () => {
|
||||||
|
test("kein gezogener Task -> noop", () => {
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
null,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(op, { kind: "noop" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normaler Task auf freien Slot -> moveToSlot mit genau diesem Slot", () => {
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
baseTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(op, {
|
||||||
|
kind: "moveToSlot",
|
||||||
|
taskId: "t1",
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: "09:00",
|
||||||
|
scheduledDate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normaler Task auf belegten Slot -> erster freier, ungeschuetzter Folge-Slot", () => {
|
||||||
|
const isOccupied = (slot: string) => slot === "09:00" || slot === "09:30";
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
baseTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
isOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { startTime: string }).startTime, "10:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("alle Folge-Slots belegt/geschuetzt -> faellt auf den urspruenglich angeforderten Slot zurueck", () => {
|
||||||
|
const isOccupied = () => true;
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
baseTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
isOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { startTime: string }).startTime, "09:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("geschuetzter Folge-Slot wird uebersprungen, naechster freier genommen", () => {
|
||||||
|
const isOccupied = (slot: string) => slot === "09:00";
|
||||||
|
const isProtected = (slot: string) => slot === "09:30";
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
baseTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
isOccupied,
|
||||||
|
isProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { startTime: string }).startTime, "10:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Drop auf Tag ohne Slot, Task hat bereits eine Startzeit -> behaelt sie", () => {
|
||||||
|
const task = { ...baseTask, startTime: "08:30" };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
task,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
undefined,
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { startTime: string }).startTime, "08:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Drop auf Tag ohne Slot, Task hat keine Startzeit -> leere Startzeit", () => {
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
baseTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
undefined,
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { startTime: string }).startTime, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Drop auf denselben Slot, den der Task schon hat -> heutiges Verhalten: erneutes moveToSlot, kein noop", () => {
|
||||||
|
// Bewusst dokumentiertes Ist-Verhalten (kein Persistenz-Kurzschluss heute) --
|
||||||
|
// nicht Wunschverhalten. Eine Aenderung hier ist ein eigener, spaeterer Schritt.
|
||||||
|
const task = { ...baseTask, startTime: "09:00", scheduledDate };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
task,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Subtask wird auf jedes Ziel gedroppt -> promoteSubtask, vom Elternteil geloest", () => {
|
||||||
|
const subtask = { ...baseTask, id: "sub1", parentTaskId: "parent1" };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
subtask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(op, {
|
||||||
|
kind: "promoteSubtask",
|
||||||
|
taskId: "sub1",
|
||||||
|
scheduledDate,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: "09:00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Someday-Task ohne eigenen Provider erbt den Provider der Liste", () => {
|
||||||
|
const somedayTask = { ...baseTask, id: "sd1", somedayListId: "list1" };
|
||||||
|
const lists: SomedayList[] = [
|
||||||
|
{ id: "list1", title: "Someday", tasks: [], externalProvider: "google" },
|
||||||
|
];
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
somedayTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
lists,
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveFromSomeday");
|
||||||
|
assert.equal((op as { externalProvider: string | null }).externalProvider, "google");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Someday-Task mit eigenem Provider behaelt ihn, ignoriert den der Liste", () => {
|
||||||
|
const somedayTask = { ...baseTask, id: "sd2", somedayListId: "list1", externalProvider: "outlook" };
|
||||||
|
const lists: SomedayList[] = [
|
||||||
|
{ id: "list1", title: "Someday", tasks: [], externalProvider: "google" },
|
||||||
|
];
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
somedayTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
lists,
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveFromSomeday");
|
||||||
|
assert.equal((op as { externalProvider: string | null }).externalProvider, "outlook");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Someday-Task mit externalId+Provider -> syncExternal gesetzt", () => {
|
||||||
|
const somedayTask = {
|
||||||
|
...baseTask,
|
||||||
|
id: "sd3",
|
||||||
|
somedayListId: "list1",
|
||||||
|
externalId: "ext-1",
|
||||||
|
externalProvider: "google",
|
||||||
|
};
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
somedayTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveFromSomeday");
|
||||||
|
assert.deepEqual((op as { syncExternal: unknown }).syncExternal, {
|
||||||
|
taskId: "sd3",
|
||||||
|
scheduledDate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Someday-Task ohne externalId -> kein syncExternal, obwohl Provider vorhanden", () => {
|
||||||
|
const somedayTask = { ...baseTask, id: "sd4", somedayListId: "list1", externalProvider: "google" };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
somedayTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveFromSomeday");
|
||||||
|
assert.equal((op as { syncExternal: unknown }).syncExternal, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("virtuelle wiederkehrende Instanz behaelt ihre virtuelle Id unveraendert", () => {
|
||||||
|
// Der Server (VIRTUAL_ID_REGEX in api/tasks/route.ts) materialisiert daraus bei
|
||||||
|
// PATCH eine echte Instanz. Die Entscheidung darf die Id nicht selbst aufloesen.
|
||||||
|
const virtualTask = { ...baseTask, id: "virtual-t1-2026-09-20" };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
virtualTask,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "moveToSlot");
|
||||||
|
assert.equal((op as { taskId: string }).taskId, "virtual-t1-2026-09-20");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Subtask hat Vorrang vor Someday, falls (praktisch unmoeglich) beides gesetzt waere", () => {
|
||||||
|
const both = { ...baseTask, parentTaskId: "parent1", somedayListId: "list1" };
|
||||||
|
const op = decideTaskDrop(
|
||||||
|
both,
|
||||||
|
dayOfWeek,
|
||||||
|
scheduledDate,
|
||||||
|
"09:00",
|
||||||
|
allSlots,
|
||||||
|
neverOccupied,
|
||||||
|
neverProtected,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(op.kind, "promoteSubtask");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user