diff --git a/CHANGELOG.md b/CHANGELOG.md index 4704ada..5c9641f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/package.json b/package.json index d7d0240..2fb46ac 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 93cb7f6..eac700c 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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); } diff --git a/src/lib/weekly/drag-drop.ts b/src/lib/weekly/drag-drop.ts new file mode 100644 index 0000000..704ff53 --- /dev/null +++ b/src/lib/weekly/drag-drop.ts @@ -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, + }; +} diff --git a/tests/unit/drag-drop.test.ts b/tests/unit/drag-drop.test.ts new file mode 100644 index 0000000..1b783b8 --- /dev/null +++ b/tests/unit/drag-drop.test.ts @@ -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"); + }); +});