From 1e2bd3d4eb72cfdfe5a93bad3d1da1c7244b4b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 11:49:14 +0200 Subject: [PATCH] refactor(web): extract project drag finalize --- .../hooks/timelineProjectDragFinalize.test.ts | 120 ++++++++++++++++++ .../src/hooks/timelineProjectDragFinalize.ts | 38 ++++++ apps/web/src/hooks/useTimelineDrag.ts | 45 +++---- scripts/check-architecture-guardrails.mjs | 29 ++++- .../check-architecture-guardrails.test.mjs | 16 ++- 5 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/hooks/timelineProjectDragFinalize.test.ts create mode 100644 apps/web/src/hooks/timelineProjectDragFinalize.ts diff --git a/apps/web/src/hooks/timelineProjectDragFinalize.test.ts b/apps/web/src/hooks/timelineProjectDragFinalize.test.ts new file mode 100644 index 0000000..2f12458 --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDragFinalize.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js"; + +const BASE_DRAG = { + isDragging: true, + projectId: "project-1", + currentStartDate: new Date("2025-01-12T00:00:00.000Z"), + currentEndDate: new Date("2025-01-22T00:00:00.000Z"), + daysDelta: 2, +}; + +describe("timelineProjectDragFinalize", () => { + it("returns null without clearing when the drag is already inactive", () => { + const updatePosition = vi.fn(); + const clearSession = vi.fn(); + + expect( + finalizeProjectDrag({ + clientX: 20, + dragRef: { current: { ...BASE_DRAG, isDragging: false } }, + previewRef: { current: null }, + updatePosition, + clearSession, + mutate: vi.fn(), + mutateAsync: vi.fn(), + }), + ).toBeNull(); + + expect(updatePosition).toHaveBeenCalledWith(20); + expect(clearSession).not.toHaveBeenCalled(); + }); + + it("preserves preview, clears the session, and fires mutate for real drags", () => { + const clearSession = vi.fn(); + const mutate = vi.fn(); + const cancelAnimationFrameSpy = vi.fn(); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy); + const previewRef = { + current: { + mode: "move" as const, + cellWidth: 48, + targets: [], + pointerDeltaX: 0, + daysDelta: 0, + frame: 7, + }, + }; + + try { + expect( + finalizeProjectDrag({ + clientX: 24, + dragRef: { current: BASE_DRAG }, + previewRef, + updatePosition: vi.fn(), + clearSession, + mutate, + mutateAsync: vi.fn(), + }), + ).toBeNull(); + + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7); + expect(previewRef.current.frame).toBeNull(); + expect(clearSession).toHaveBeenCalledOnce(); + expect(mutate).toHaveBeenCalledWith({ + projectId: "project-1", + newStartDate: new Date("2025-01-12T00:00:00.000Z"), + newEndDate: new Date("2025-01-22T00:00:00.000Z"), + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("clears but suppresses mutation for no-op drags", () => { + const clearSession = vi.fn(); + const mutate = vi.fn(); + const mutateAsync = vi.fn(); + + expect( + finalizeProjectDrag({ + clientX: 30, + dragRef: { current: { ...BASE_DRAG, daysDelta: 0 } }, + previewRef: { current: null }, + updatePosition: vi.fn(), + clearSession, + mutate, + mutateAsync, + }), + ).toBeNull(); + + expect(clearSession).toHaveBeenCalledOnce(); + expect(mutate).not.toHaveBeenCalled(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("returns the async mutation promise when requested", async () => { + const result = { ok: true }; + const mutateAsync = vi.fn().mockResolvedValue(result); + + await expect( + finalizeProjectDrag({ + clientX: 36, + mode: "mutateAsync", + dragRef: { current: BASE_DRAG }, + previewRef: { current: null }, + updatePosition: vi.fn(), + clearSession: vi.fn(), + mutate: vi.fn(), + mutateAsync, + }), + ).resolves.toEqual(result); + + expect(mutateAsync).toHaveBeenCalledWith({ + projectId: "project-1", + newStartDate: new Date("2025-01-12T00:00:00.000Z"), + newEndDate: new Date("2025-01-22T00:00:00.000Z"), + }); + }); +}); diff --git a/apps/web/src/hooks/timelineProjectDragFinalize.ts b/apps/web/src/hooks/timelineProjectDragFinalize.ts new file mode 100644 index 0000000..f577ec1 --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDragFinalize.ts @@ -0,0 +1,38 @@ +import { buildProjectShiftMutationInput } from "./timelineProjectDrag.js"; +import { preserveLivePreview, type LivePreviewSession } from "./timelineLivePreview.js"; + +type MutableCurrent = { current: T }; +type ProjectDragFinalizeLike = Parameters[0] & { isDragging: boolean }; + +export function finalizeProjectDrag({ + clientX, + mode = "mutate", + dragRef, + previewRef, + updatePosition, + clearSession, + mutate, + mutateAsync, +}: { + clientX: number; + mode?: "mutate" | "mutateAsync"; + dragRef: MutableCurrent; + previewRef: MutableCurrent; + updatePosition: (clientX: number) => void; + clearSession: () => void; + mutate: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => void; + mutateAsync: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => Promise; +}) { + updatePosition(clientX); + const finalDrag = dragRef.current; + if (!finalDrag.isDragging) return null; + + const mutationInput = buildProjectShiftMutationInput(finalDrag); + if (finalDrag.daysDelta !== 0) preserveLivePreview(previewRef.current); + clearSession(); + + if (!mutationInput) return null; + if (mode === "mutateAsync") return mutateAsync(mutationInput); + mutate(mutationInput); + return null; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index bb04861..76a6dea 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -24,7 +24,8 @@ import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEff import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; -import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; +import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js"; +import { createProjectDragState } from "./timelineProjectDrag.js"; import { beginProjectDragSession } from "./timelineProjectDragSession.js"; import { forwardCanvasTouchEnd, @@ -408,28 +409,18 @@ export function useTimelineDrag({ mutateAsync: (...args: unknown[]) => Promise; }; - const finalizeProjectDrag = useCallback( - (clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => { - updateProjectDragPosition(clientX); - const finalDrag = dragStateRef.current; - if (!finalDrag.isDragging) return null; - - const mutationInput = buildProjectShiftMutationInput(finalDrag); - - if (finalDrag.daysDelta !== 0) { - preserveLivePreview(projectPreviewRef.current); - } - - clearProjectDragSession(); - - if (!mutationInput) return null; - if (mode === "mutateAsync") { - return applyShiftMutation.mutateAsync(mutationInput); - } - - applyShiftMutation.mutate(mutationInput); - return null; - }, + const finalizeActiveProjectDrag = useCallback( + (clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => + finalizeProjectDrag({ + clientX, + mode, + dragRef: dragStateRef, + previewRef: projectPreviewRef, + updatePosition: updateProjectDragPosition, + clearSession: clearProjectDragSession, + mutate: applyShiftMutation.mutate, + mutateAsync: applyShiftMutation.mutateAsync, + }), [applyShiftMutation, clearProjectDragSession, updateProjectDragPosition], ); @@ -516,11 +507,11 @@ export function useTimelineDrag({ attachDrag: attachDocumentMouseDrag, updatePosition: updateProjectDragPosition, finalize: (clientX) => { - void finalizeProjectDrag(clientX); + void finalizeActiveProjectDrag(clientX); }, }); }, - [finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition], + [finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition], ); // Legacy — kept for backward compat (triggers project shift from allocation block) @@ -717,7 +708,7 @@ export function useTimelineDrag({ const drag = dragStateRef.current; if (drag.isDragging) { try { - await finalizeProjectDrag(e.clientX, "mutateAsync"); + await finalizeActiveProjectDrag(e.clientX, "mutateAsync"); } catch { // Validation error — revert visually } @@ -734,7 +725,7 @@ export function useTimelineDrag({ setRangeState(INITIAL_RANGE_STATE); } }, - [finalizeProjectDrag, onRangeSelected], + [finalizeActiveProjectDrag, onRangeSelected], ); const onCanvasMouseLeave = useCallback(() => { diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 3ad3bfa..5eb9df2 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -412,6 +412,25 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineProjectDragFinalize.ts", + maxLines: 60, + required: [ + { + pattern: /\bexport function finalizeProjectDrag\b/, + message: "timeline project drag finalize helpers must keep completion flow centralized", + }, + { + pattern: /from "\.\/timelineProjectDrag\.js"/, + message: "timeline project drag finalize helpers must keep mutation gating delegated to the project drag helper module", + }, + { + pattern: /from "\.\/timelineLivePreview\.js"/, + message: "timeline project drag finalize helpers must keep preview preservation delegated to the live preview helper module", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineProjectDragSession.ts", maxLines: 70, @@ -484,7 +503,11 @@ export const rules = [ }, { pattern: /from "\.\/timelineProjectDrag\.js"/, - message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", + message: "timeline drag must keep project drag bootstrap delegated to the extracted helper module", + }, + { + pattern: /from "\.\/timelineProjectDragFinalize\.js"/, + message: "timeline drag must keep project drag completion delegated to the extracted helper module", }, { pattern: /from "\.\/timelineProjectDragSession\.js"/, @@ -544,6 +567,10 @@ export const rules = [ pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/, message: "timeline drag must not re-inline extracted project drag helper implementations", }, + { + pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/, + message: "timeline drag must not re-inline extracted project drag finalize flow", + }, ], }, { diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 1cfdfb4..de7b19f 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -94,6 +94,9 @@ describe("architecture guardrails", () => { (rule) => rule.file === "apps/web/src/hooks/timelineAllocationReleaseEffects.ts", ); const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); + const projectDragFinalizeRule = rules.find( + (rule) => rule.file === "apps/web/src/hooks/timelineProjectDragFinalize.ts", + ); const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts"); assert.ok(dragRule); @@ -117,6 +120,7 @@ describe("architecture guardrails", () => { assert.ok(allocationDragSessionRule); assert.ok(allocationReleaseEffectsRule); assert.ok(projectDragRule); + assert.ok(projectDragFinalizeRule); assert.ok(projectDragSessionRule); assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [ @@ -134,7 +138,8 @@ describe("architecture guardrails", () => { "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 allocation release side effects 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 bootstrap delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag completion 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", ]); @@ -233,6 +238,12 @@ 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(projectDragFinalizeRule, ""), [ + "apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep completion flow centralized", + "apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep mutation gating delegated to the project drag helper module", + "apps/web/src/hooks/timelineProjectDragFinalize.ts: missing guardrail anchor: timeline project drag finalize helpers must keep preview preservation delegated to the live preview helper module", + ]); + assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [ "apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized", ]); @@ -257,7 +268,8 @@ describe("architecture guardrails", () => { "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 allocation release side effects 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 bootstrap delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag completion 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 extracted touch event forwarding dependencies", ],