refactor: Drop-Entscheidung aus handleDrop in reine, getestete Funktion ausgelagert
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

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:
mARTin-B78 2026-09-13 13:54:28 +02:00
parent 4e3c6087de
commit fba9a5d533
5 changed files with 573 additions and 127 deletions

View File

@ -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

View File

@ -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": {

View File

@ -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,142 +4651,125 @@ export default function WeeklyView() {
e.preventDefault();
if (draggedTask) {
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
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,
switch (operation.kind) {
case "promoteSubtask": {
// 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
)
);
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);
// 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,
// Add as standalone task in UI
setTasks((prev) => [
...prev,
{
...draggedTask,
parentTaskId: null,
scheduledDate: newScheduledDate,
dayOfWeek,
startTime: targetSlot || "",
}),
});
} catch (error) {
console.error("Error promoting subtask:", error);
}
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", {
scheduledDate: operation.scheduledDate,
dayOfWeek: operation.dayOfWeek,
startTime: operation.startTime,
} as Task,
]);
// Persist
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId: draggedTask.id,
scheduledDate: newScheduledDate,
id: operation.taskId,
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) {
console.error("Error moving task from someday to calendar:", error);
setDraggedTask(null);
setDropPreview(null);
return;
}
} else {
moveTaskToSlot(
draggedTask.id,
dayOfWeek,
targetSlot || "",
targetDateObj,
);
case "moveFromSomeday": {
// 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: 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);
}

135
src/lib/weekly/drag-drop.ts Normal file
View 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,
};
}

View 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");
});
});