From 3fe3a5fb2a9735b291e45aaf25ca70ba84798fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 11:16:15 +0200 Subject: [PATCH] refactor(web): extract project drag session --- .../hooks/timelineProjectDragSession.test.ts | 80 +++++++++++++++++++ .../src/hooks/timelineProjectDragSession.ts | 48 +++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 30 +++---- scripts/check-architecture-guardrails.mjs | 15 ++++ .../check-architecture-guardrails.test.mjs | 8 ++ 5 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/hooks/timelineProjectDragSession.test.ts create mode 100644 apps/web/src/hooks/timelineProjectDragSession.ts diff --git a/apps/web/src/hooks/timelineProjectDragSession.test.ts b/apps/web/src/hooks/timelineProjectDragSession.test.ts new file mode 100644 index 0000000..a6d970c --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDragSession.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { beginProjectDragSession } from "./timelineProjectDragSession.js"; + +describe("timelineProjectDragSession", () => { + 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 = { projectId: "project-1", isDragging: true }; + const cleanupRef = { current: previousCleanup as (() => void) | null }; + const stateRef = { current: { projectId: null, isDragging: false } }; + const handlers: { + move?: (event: MouseEvent) => void; + up?: (event: MouseEvent) => void; + } = {}; + + const attachDrag = vi.fn((_: Document, onMove: (event: MouseEvent) => void, onUp: (event: MouseEvent) => void) => { + handlers.move = onMove; + handlers.up = onUp; + return vi.fn(); + }); + + beginProjectDragSession({ + state, + cleanupRef, + stateRef, + setState, + documentTarget: {} as Document, + attachDrag, + updatePosition, + finalize, + }); + + expect(previousCleanup).toHaveBeenCalledOnce(); + expect(stateRef.current).toBe(state); + expect(setState).toHaveBeenCalledWith(state); + + handlers.move?.({ clientX: 42 } as MouseEvent); + expect(updatePosition).toHaveBeenCalledWith(42); + + const preventDefault = vi.fn(); + const attachedCleanup = cleanupRef.current; + handlers.up?.({ clientX: 54, preventDefault } as unknown as MouseEvent); + + expect(attachedCleanup).toHaveBeenCalledOnce(); + expect(cleanupRef.current).toBeNull(); + expect(finalize).toHaveBeenCalledWith(54); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("works when no prior cleanup is registered", () => { + const setState = vi.fn(); + const updatePosition = vi.fn(); + const finalize = vi.fn().mockResolvedValue(undefined); + const cleanupRef = { current: null as (() => void) | null }; + const stateRef = { current: { projectId: null, isDragging: false } }; + let upHandler: ((event: MouseEvent) => void) | undefined; + + beginProjectDragSession({ + state: { projectId: "project-2", isDragging: true }, + cleanupRef, + stateRef, + setState, + documentTarget: {} as Document, + attachDrag: (_documentTarget, _onMove, onUp) => { + upHandler = onUp; + return vi.fn(); + }, + updatePosition, + finalize, + }); + + upHandler?.({ clientX: 12, preventDefault() {} } as MouseEvent); + + expect(updatePosition).not.toHaveBeenCalled(); + expect(finalize).toHaveBeenCalledWith(12); + expect(cleanupRef.current).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/timelineProjectDragSession.ts b/apps/web/src/hooks/timelineProjectDragSession.ts new file mode 100644 index 0000000..e088c0d --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDragSession.ts @@ -0,0 +1,48 @@ +type MutableCurrent = { + current: T; +}; + +type AttachDocumentMouseDrag = ( + documentTarget: Document, + onMove: (event: MouseEvent) => void, + onUp: (event: MouseEvent) => void, +) => () => void; + +type BeginProjectDragSessionParams = { + 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 beginProjectDragSession({ + state, + cleanupRef, + stateRef, + setState, + documentTarget, + attachDrag, + updatePosition, + finalize, +}: BeginProjectDragSessionParams) { + 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 9d89274..f912231 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -23,6 +23,7 @@ import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; +import { beginProjectDragSession } from "./timelineProjectDragSession.js"; import { completeMultiSelectDraft, createMultiSelectState, @@ -550,24 +551,19 @@ export function useTimelineDrag({ endDate: opts.endDate, startMouseX: e.clientX, }); - dragStateRef.current = state; - setDragState(state); - setProjectPreviewTargets(opts.projectId, e.currentTarget); - projectDragCleanupRef.current?.(); - - function handleMove(ev: MouseEvent) { - updateProjectDragPosition(ev.clientX); - } - - function handleUp(ev: MouseEvent) { - projectDragCleanupRef.current?.(); - projectDragCleanupRef.current = null; - void finalizeProjectDrag(ev.clientX); - ev.preventDefault(); - } - - projectDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); + beginProjectDragSession({ + state, + cleanupRef: projectDragCleanupRef, + stateRef: dragStateRef, + setState: setDragState, + documentTarget: document, + attachDrag: attachDocumentMouseDrag, + updatePosition: updateProjectDragPosition, + finalize: (clientX) => { + void finalizeProjectDrag(clientX); + }, + }); }, [finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition], ); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 9060e20..5a3c8f3 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -329,6 +329,17 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineProjectDragSession.ts", + maxLines: 70, + required: [ + { + pattern: /\bexport function beginProjectDragSession\b/, + message: "timeline project drag session helpers must keep document drag lifecycle centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/useTimelineDrag.ts", required: [ @@ -388,6 +399,10 @@ export const rules = [ pattern: /from "\.\/timelineProjectDrag\.js"/, message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineProjectDragSession\.js"/, + message: "timeline drag must keep project drag document session wiring delegated to the extracted helper module", + }, ], forbidden: [ { diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 126e58c..0678da6 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -85,6 +85,7 @@ describe("architecture guardrails", () => { 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 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"); assert.ok(dragRule); assert.ok(livePreviewRule); @@ -102,6 +103,7 @@ describe("architecture guardrails", () => { assert.ok(documentDragRule); assert.ok(allocationDragStateRule); assert.ok(projectDragRule); + assert.ok(projectDragSessionRule); assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [ "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module", @@ -118,6 +120,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 drag bootstrap 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", ]); @@ -189,6 +192,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized", ]); + assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [ + "apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized", + ]); + assert.deepEqual( evaluateRule( dragRule, @@ -208,6 +215,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 drag bootstrap 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", ], );