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.
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -109,6 +109,7 @@ import {
|
||||
} from "@/lib/weekly/format";
|
||||
import { isAllDayEvent } from "@/lib/weekly/date-utils";
|
||||
import { computeEventOverlapLayout } from "@/lib/weekly/event-layout";
|
||||
import { decideTaskDrop } from "@/lib/weekly/drag-drop";
|
||||
|
||||
// Types
|
||||
import UserMenu from "./UserMenu";
|
||||
@ -4650,39 +4651,22 @@ export default function WeeklyView() {
|
||||
e.preventDefault();
|
||||
if (draggedTask) {
|
||||
const targetDateObj = date;
|
||||
const dayOfWeek = targetDateObj.getDay();
|
||||
|
||||
let targetSlot = slot;
|
||||
|
||||
// If no slot provided (dropped on header/background), try to keep original time
|
||||
if (!targetSlot && draggedTask.startTime) {
|
||||
targetSlot = draggedTask.startTime;
|
||||
}
|
||||
|
||||
// Collision detection / Find next free slot
|
||||
if (targetSlot) {
|
||||
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
|
||||
const allSlots = getTimeSlots(
|
||||
effectiveCellDuration,
|
||||
0,
|
||||
24,
|
||||
// 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,
|
||||
);
|
||||
const startIndex = allSlots.indexOf(targetSlot);
|
||||
if (startIndex !== -1) {
|
||||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||||
const nextSlot = allSlots[i];
|
||||
if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) {
|
||||
targetSlot = nextSlot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the task is a subtask, promote it to a standalone task
|
||||
if (draggedTask.parentTaskId) {
|
||||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||||
switch (operation.kind) {
|
||||
case "promoteSubtask": {
|
||||
// Remove subtask from parent in UI
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
@ -4697,9 +4681,9 @@ export default function WeeklyView() {
|
||||
{
|
||||
...draggedTask,
|
||||
parentTaskId: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
scheduledDate: operation.scheduledDate,
|
||||
dayOfWeek: operation.dayOfWeek,
|
||||
startTime: operation.startTime,
|
||||
} as Task,
|
||||
]);
|
||||
// Persist
|
||||
@ -4708,11 +4692,11 @@ export default function WeeklyView() {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: draggedTask.id,
|
||||
id: operation.taskId,
|
||||
parentTaskId: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
scheduledDate: operation.scheduledDate,
|
||||
dayOfWeek: operation.dayOfWeek,
|
||||
startTime: operation.startTime,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
@ -4723,12 +4707,7 @@ export default function WeeklyView() {
|
||||
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;
|
||||
case "moveFromSomeday": {
|
||||
// Remove from someday list UI
|
||||
setSomedayLists((prev) =>
|
||||
prev.map((l) => ({
|
||||
@ -4743,10 +4722,10 @@ export default function WeeklyView() {
|
||||
...draggedTask,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
externalProvider: taskProvider,
|
||||
scheduledDate: operation.scheduledDate,
|
||||
dayOfWeek: operation.dayOfWeek,
|
||||
startTime: operation.startTime,
|
||||
externalProvider: operation.externalProvider,
|
||||
},
|
||||
]);
|
||||
// Persist
|
||||
@ -4755,37 +4734,42 @@ export default function WeeklyView() {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: draggedTask.id,
|
||||
id: operation.taskId,
|
||||
somedayListId: null,
|
||||
somedaySlotIndex: null,
|
||||
scheduledDate: newScheduledDate,
|
||||
dayOfWeek,
|
||||
startTime: targetSlot || "",
|
||||
...(taskProvider && { externalProvider: taskProvider }),
|
||||
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 (draggedTask.externalId && draggedTask.externalProvider) {
|
||||
if (operation.syncExternal) {
|
||||
fetch("/api/tasks/sync", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
taskId: draggedTask.id,
|
||||
scheduledDate: newScheduledDate,
|
||||
}),
|
||||
body: JSON.stringify(operation.syncExternal),
|
||||
}).catch((e) => console.error("Sync error:", e));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error moving task from someday to calendar:", error);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
case "moveToSlot": {
|
||||
moveTaskToSlot(
|
||||
draggedTask.id,
|
||||
dayOfWeek,
|
||||
targetSlot || "",
|
||||
operation.taskId,
|
||||
operation.dayOfWeek,
|
||||
operation.startTime,
|
||||
targetDateObj,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "noop":
|
||||
break;
|
||||
}
|
||||
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