From f4e9831dea7ff29e35cb5925440ee01a02b43108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 11:27:03 +0200 Subject: [PATCH] refactor(web): extract allocation drag session --- .../timelineAllocationDragSession.test.ts | 78 +++++++++ .../hooks/timelineAllocationDragSession.ts | 48 ++++++ apps/web/src/hooks/useTimelineDrag.ts | 153 +++++++++--------- scripts/check-architecture-guardrails.mjs | 19 +++ .../check-architecture-guardrails.test.mjs | 8 + 5 files changed, 228 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/hooks/timelineAllocationDragSession.test.ts create mode 100644 apps/web/src/hooks/timelineAllocationDragSession.ts diff --git a/apps/web/src/hooks/timelineAllocationDragSession.test.ts b/apps/web/src/hooks/timelineAllocationDragSession.test.ts new file mode 100644 index 0000000..64e384c --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationDragSession.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; + +describe("timelineAllocationDragSession", () => { + it("starts the session, forwards movement, and finalizes on mouseup", () => { + const previousCleanup = vi.fn(); + const setState = vi.fn(); + const updatePosition = vi.fn(); + const finalize = vi.fn(); + const state = { allocationId: "alloc-1", isActive: true }; + const cleanupRef = { current: previousCleanup as (() => void) | null }; + const stateRef = { current: { allocationId: null, isActive: false } }; + const handlers: { + move?: (event: MouseEvent) => void; + up?: (event: MouseEvent) => void; + } = {}; + + beginAllocationDragSession({ + state, + cleanupRef, + stateRef, + setState, + documentTarget: {} as Document, + attachDrag: (_documentTarget, onMove, onUp) => { + handlers.move = onMove; + handlers.up = onUp; + return vi.fn(); + }, + updatePosition, + finalize, + }); + + expect(previousCleanup).toHaveBeenCalledOnce(); + expect(stateRef.current).toBe(state); + expect(setState).toHaveBeenCalledWith(state); + + handlers.move?.({ clientX: 44 } as MouseEvent); + expect(updatePosition).toHaveBeenCalledWith(44); + + const preventDefault = vi.fn(); + const attachedCleanup = cleanupRef.current; + handlers.up?.({ clientX: 65, preventDefault } as unknown as MouseEvent); + + expect(attachedCleanup).toHaveBeenCalledOnce(); + expect(cleanupRef.current).toBeNull(); + expect(finalize).toHaveBeenCalledWith(65); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("works when the session starts without a prior cleanup", () => { + const setState = vi.fn(); + const updatePosition = vi.fn(); + const finalize = vi.fn().mockResolvedValue(undefined); + const cleanupRef = { current: null as (() => void) | null }; + const stateRef = { current: { allocationId: null, isActive: false } }; + let upHandler: ((event: MouseEvent) => void) | undefined; + + beginAllocationDragSession({ + state: { allocationId: "alloc-2", isActive: true }, + cleanupRef, + stateRef, + setState, + documentTarget: {} as Document, + attachDrag: (_documentTarget, _onMove, onUp) => { + upHandler = onUp; + return vi.fn(); + }, + updatePosition, + finalize, + }); + + upHandler?.({ clientX: 18, preventDefault() {} } as MouseEvent); + + expect(updatePosition).not.toHaveBeenCalled(); + expect(finalize).toHaveBeenCalledWith(18); + expect(cleanupRef.current).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationDragSession.ts b/apps/web/src/hooks/timelineAllocationDragSession.ts new file mode 100644 index 0000000..c8104fa --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationDragSession.ts @@ -0,0 +1,48 @@ +type MutableCurrent = { + current: T; +}; + +type AttachDocumentMouseDrag = ( + documentTarget: Document, + onMove: (event: MouseEvent) => void, + onUp: (event: MouseEvent) => void, +) => () => void; + +type BeginAllocationDragSessionParams = { + state: TState; + cleanupRef: MutableCurrent<(() => void) | null>; + stateRef: MutableCurrent; + setState: (state: TState) => void; + documentTarget: Document; + attachDrag: AttachDocumentMouseDrag; + updatePosition: (clientX: number) => void; + finalize: (clientX: number) => Promise | void; +}; + +export function beginAllocationDragSession({ + state, + cleanupRef, + stateRef, + setState, + documentTarget, + attachDrag, + updatePosition, + finalize, +}: BeginAllocationDragSessionParams) { + stateRef.current = state; + setState(state); + cleanupRef.current?.(); + + function handleMove(event: MouseEvent) { + updatePosition(event.clientX); + } + + function handleUp(event: MouseEvent) { + cleanupRef.current?.(); + cleanupRef.current = null; + void finalize(event.clientX); + event.preventDefault(); + } + + cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp); +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 23eb722..5b750f5 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -21,6 +21,7 @@ import { import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js"; import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; +import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; @@ -629,94 +630,90 @@ export function useTimelineDrag({ endDate: opts.endDate, startMouseX: e.clientX, }); - allocDragRef.current = initial; - setAllocDragState(initial); setAllocationPreviewTarget(e.currentTarget, opts.mode); - allocDragCleanupRef.current?.(); - - // ── document handlers ──────────────────────────────────────────────── - function handleMove(ev: MouseEvent) { - updateAllocationDragPosition(ev.clientX); - } - - function handleUp(ev: MouseEvent) { - allocDragCleanupRef.current?.(); - allocDragCleanupRef.current = null; - updateAllocationDragPosition(ev.clientX); - const alloc = allocDragRef.current; - const release = resolveAllocationRelease(alloc, { - clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, - wasShift, - }); - if (release.kind === "ignore") return; - - if (release.preservePreview) { - preserveLivePreview(allocPreviewRef.current); - } - clearLivePreview(allocPreviewRef.current); - allocPreviewRef.current = null; - - if (release.kind === "shift-click") { - onShiftClickAllocRef.current?.(release.allocationId); - } else if (release.kind === "click") { - onBlockClickRef.current?.(release.clickInfo); - } else if (release.kind === "mutation") { - const { mutationPlan } = release; - const { - activeAllocationId, - currentStartDate, - currentEndDate, - baseMutationAllocationId, - requiresExtraction, - pendingSnapshot, - } = mutationPlan; - - pendingSnapshotRef.current = pendingSnapshot; - pendingOptimisticAllocationIdRef.current = activeAllocationId; - setOptimisticAllocations((prev) => { - const next = new Map(prev); - next.set(activeAllocationId, { - startDate: currentStartDate, - endDate: currentEndDate, - }); - return next; + beginAllocationDragSession({ + state: initial, + cleanupRef: allocDragCleanupRef, + stateRef: allocDragRef, + setState: setAllocDragState, + documentTarget: document, + attachDrag: attachDocumentMouseDrag, + updatePosition: updateAllocationDragPosition, + finalize: (clientX) => { + updateAllocationDragPosition(clientX); + const alloc = allocDragRef.current; + const release = resolveAllocationRelease(alloc, { + clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, + wasShift, }); - void (async () => { - try { - let mutationAllocationId = baseMutationAllocationId; + if (release.kind === "ignore") return; - if (requiresExtraction) { - const extracted = await extractAllocFragmentMutation.mutateAsync({ - allocationId: mutationAllocationId, - startDate: alloc.originalStartDate!, - endDate: alloc.originalEndDate!, - }); - mutationAllocationId = extracted.extractedAllocationId; - } + if (release.preservePreview) { + preserveLivePreview(allocPreviewRef.current); + } + clearLivePreview(allocPreviewRef.current); + allocPreviewRef.current = null; - pendingSnapshotRef.current = pendingSnapshotRef.current - ? { - ...pendingSnapshotRef.current, - mutationAllocationId, - } - : null; + if (release.kind === "shift-click") { + onShiftClickAllocRef.current?.(release.allocationId); + } else if (release.kind === "click") { + onBlockClickRef.current?.(release.clickInfo); + } else if (release.kind === "mutation") { + const { mutationPlan } = release; + const { + activeAllocationId, + currentStartDate, + currentEndDate, + baseMutationAllocationId, + requiresExtraction, + pendingSnapshot, + } = mutationPlan; - updateAllocMutation.mutate({ - allocationId: mutationAllocationId, + pendingSnapshotRef.current = pendingSnapshot; + pendingOptimisticAllocationIdRef.current = activeAllocationId; + setOptimisticAllocations((prev) => { + const next = new Map(prev); + next.set(activeAllocationId, { startDate: currentStartDate, endDate: currentEndDate, }); - } catch { - clearPendingOptimisticAllocation(activeAllocationId); - } - })(); - } + return next; + }); + void (async () => { + try { + let mutationAllocationId = baseMutationAllocationId; - allocDragRef.current = INITIAL_ALLOC_DRAG; - setAllocDragState(INITIAL_ALLOC_DRAG); - } + if (requiresExtraction) { + const extracted = await extractAllocFragmentMutation.mutateAsync({ + allocationId: mutationAllocationId, + startDate: alloc.originalStartDate!, + endDate: alloc.originalEndDate!, + }); + mutationAllocationId = extracted.extractedAllocationId; + } - allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); + pendingSnapshotRef.current = pendingSnapshotRef.current + ? { + ...pendingSnapshotRef.current, + mutationAllocationId, + } + : null; + + updateAllocMutation.mutate({ + allocationId: mutationAllocationId, + startDate: currentStartDate, + endDate: currentEndDate, + }); + } catch { + clearPendingOptimisticAllocation(activeAllocationId); + } + })(); + } + + allocDragRef.current = INITIAL_ALLOC_DRAG; + setAllocDragState(INITIAL_ALLOC_DRAG); + }, + }); }, [ clearPendingOptimisticAllocation, diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index fbdab10..1c03248 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -340,6 +340,17 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineAllocationDragSession.ts", + maxLines: 70, + required: [ + { + pattern: /\bexport function beginAllocationDragSession\b/, + message: "timeline allocation drag session helpers must keep document drag lifecycle centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineProjectDrag.ts", maxLines: 80, @@ -429,6 +440,10 @@ export const rules = [ pattern: /from "\.\/timelineAllocationDragState\.js"/, message: "timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineAllocationDragSession\.js"/, + message: "timeline drag must keep allocation drag document session wiring delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineProjectDrag\.js"/, message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", @@ -483,6 +498,10 @@ export const rules = [ pattern: /\bfunction createAllocationDragState\b/, message: "timeline drag must not re-inline extracted allocation drag bootstrap helpers", }, + { + pattern: /\bfunction handle(?:Move|Up)\b/, + message: "timeline drag must not re-inline extracted allocation drag session handlers", + }, { pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/, message: "timeline drag must not re-inline extracted project drag helper implementations", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index f262592..d2a2d14 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -88,6 +88,7 @@ describe("architecture guardrails", () => { const positionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDragPosition.ts"); const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts"); const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts"); + const allocationDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragSession.ts"); const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts"); @@ -108,6 +109,7 @@ describe("architecture guardrails", () => { assert.ok(positionRule); assert.ok(documentDragRule); assert.ok(allocationDragStateRule); + assert.ok(allocationDragSessionRule); assert.ok(projectDragRule); assert.ok(projectDragSessionRule); @@ -127,6 +129,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations", @@ -204,6 +207,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap centralized", ]); + assert.deepEqual(evaluateRule(allocationDragSessionRule, ""), [ + "apps/web/src/hooks/timelineAllocationDragSession.ts: missing guardrail anchor: timeline allocation drag session helpers must keep document drag lifecycle centralized", + ]); + assert.deepEqual(evaluateRule(projectDragRule, "export function createProjectDragState() {}\n"), [ "apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized", ]); @@ -232,6 +239,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",