From c32f56ba896c8712050c395f4535bc7241ed9cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 10:03:16 +0200 Subject: [PATCH] refactor(web): extract allocation multi-drag helpers --- .../hooks/timelineAllocationMultiDrag.test.ts | 58 +++++++++++++++++++ .../src/hooks/timelineAllocationMultiDrag.ts | 47 +++++++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 31 +++++----- scripts/check-architecture-guardrails.mjs | 27 +++++++++ .../check-architecture-guardrails.test.mjs | 8 +++ 5 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/hooks/timelineAllocationMultiDrag.test.ts create mode 100644 apps/web/src/hooks/timelineAllocationMultiDrag.ts diff --git a/apps/web/src/hooks/timelineAllocationMultiDrag.test.ts b/apps/web/src/hooks/timelineAllocationMultiDrag.test.ts new file mode 100644 index 0000000..b8ab34a --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationMultiDrag.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + finalizeAllocationMultiDrag, + isAllocationMultiSelected, + startAllocationMultiDrag, + updateAllocationMultiDrag, +} from "./timelineAllocationMultiDrag.js"; + +type TestMultiDragState = { + selectedAllocationIds: string[]; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: "move" | "resize-start" | "resize-end"; +}; + +const baseState: TestMultiDragState = { + selectedAllocationIds: ["alloc-1", "alloc-2"], + multiDragDaysDelta: 3, + isMultiDragging: false, + multiDragMode: "move", +}; + +describe("timelineAllocationMultiDrag", () => { + it("requires the allocation to be in a real multi-selection", () => { + expect(isAllocationMultiSelected(baseState, "alloc-1")).toBe(true); + expect(isAllocationMultiSelected({ ...baseState, selectedAllocationIds: ["alloc-1"] }, "alloc-1")).toBe(false); + expect(isAllocationMultiSelected(baseState, "alloc-9")).toBe(false); + }); + + it("starts multi-dragging by resetting stale delta state and preserving selection ids", () => { + expect(startAllocationMultiDrag(baseState, "resize-end")).toEqual({ + selectedAllocationIds: ["alloc-1", "alloc-2"], + multiDragDaysDelta: 0, + isMultiDragging: true, + multiDragMode: "resize-end", + }); + }); + + it("suppresses same-day delta updates to avoid redundant state churn", () => { + expect(updateAllocationMultiDrag({ ...baseState, multiDragDaysDelta: 2 }, 2)).toBeNull(); + }); + + it("updates the tracked delta when the drag crosses into a new day bucket", () => { + expect(updateAllocationMultiDrag({ ...baseState, multiDragDaysDelta: 1 }, -2)).toEqual({ + ...baseState, + multiDragDaysDelta: -2, + }); + }); + + it("finalizes multi-dragging by clearing transient drag state while keeping the selection", () => { + expect(finalizeAllocationMultiDrag({ ...baseState, isMultiDragging: true, multiDragMode: "resize-start" })).toEqual({ + selectedAllocationIds: ["alloc-1", "alloc-2"], + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "resize-start", + }); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationMultiDrag.ts b/apps/web/src/hooks/timelineAllocationMultiDrag.ts new file mode 100644 index 0000000..37d0526 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationMultiDrag.ts @@ -0,0 +1,47 @@ +type MultiDragStateLike = { + selectedAllocationIds: string[]; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: TMode; +}; + +export function isAllocationMultiSelected( + state: TState, + allocationId: string, +): boolean { + return state.selectedAllocationIds.length > 1 && state.selectedAllocationIds.includes(allocationId); +} + +export function startAllocationMultiDrag( + state: TState, + dragMode: TMode, +): TState { + return { + ...state, + isMultiDragging: true, + multiDragDaysDelta: 0, + multiDragMode: dragMode, + }; +} + +export function updateAllocationMultiDrag( + state: TState, + nextDaysDelta: number, +): TState | null { + if (nextDaysDelta === state.multiDragDaysDelta) { + return null; + } + + return { + ...state, + multiDragDaysDelta: nextDaysDelta, + }; +} + +export function finalizeAllocationMultiDrag(state: TState): TState { + return { + ...state, + isMultiDragging: false, + multiDragDaysDelta: 0, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 736a060..69f8339 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -17,6 +17,12 @@ import { requiresAllocationFragmentExtraction, shouldTreatAllocationDragAsClick, } from "./timelineAllocationFinalize.js"; +import { + finalizeAllocationMultiDrag, + isAllocationMultiSelected, + startAllocationMultiDrag, + updateAllocationMultiDrag, +} from "./timelineAllocationMultiDrag.js"; import { createMultiSelectState, finalizeMultiSelectDraft, @@ -648,28 +654,26 @@ export function useTimelineDrag({ // Check if this allocation is part of a multi-selection → multi-drag mode const ms = multiSelectRef.current; - const isMultiSelected = - ms.selectedAllocationIds.length > 1 && - ms.selectedAllocationIds.includes(opts.allocationId); + const isMultiSelected = isAllocationMultiSelected(ms, opts.allocationId); if (isMultiSelected) { // ── Multi-drag: move/resize all selected allocations together ── const startMouseX = e.clientX; - let currentDaysDelta = 0; const dragMode = opts.mode; + const initialMultiDragState = startAllocationMultiDrag(ms, dragMode); - setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode })); - multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }; + setMultiSelectState(initialMultiDragState); + multiSelectRef.current = initialMultiDragState; multiSelectCleanupRef.current?.(); function handleMultiMove(ev: MouseEvent) { const deltaX = ev.clientX - startMouseX; const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); - if (daysDelta === currentDaysDelta) return; - currentDaysDelta = daysDelta; + const updated = updateAllocationMultiDrag(multiSelectRef.current, daysDelta); + if (!updated) return; - setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta })); - multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta }; + setMultiSelectState(updated); + multiSelectRef.current = updated; } function handleMultiUp(ev: MouseEvent) { @@ -677,13 +681,14 @@ export function useTimelineDrag({ multiSelectCleanupRef.current = null; const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current); + const finalized = finalizeAllocationMultiDrag(multiSelectRef.current); - setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 })); - multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 }; + setMultiSelectState(finalized); + multiSelectRef.current = finalized; if (finalDelta !== 0) { // Pass IDs from ref to avoid stale closure in the callback - const ids = multiSelectRef.current.selectedAllocationIds; + const ids = finalized.selectedAllocationIds; onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids); } } diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 30212d7..8f1e193 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -198,6 +198,25 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineAllocationMultiDrag.ts", + maxLines: 90, + required: [ + { + pattern: /\bexport function isAllocationMultiSelected\b/, + message: "timeline allocation multi-drag helpers must keep multi-selection eligibility centralized", + }, + { + pattern: /\bexport function updateAllocationMultiDrag\b/, + message: "timeline allocation multi-drag helpers must keep same-day delta suppression centralized", + }, + { + pattern: /\bexport function finalizeAllocationMultiDrag\b/, + message: "timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/useTimelineDrag.ts", required: [ @@ -225,6 +244,10 @@ export const rules = [ pattern: /from "\.\/timelineAllocationFinalize\.js"/, message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineAllocationMultiDrag\.js"/, + message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", + }, ], forbidden: [ { @@ -239,6 +262,10 @@ export const rules = [ pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/, message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations", }, + { + pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/, + message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations", + }, ], }, { diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index d0b2c4f..c7fdb8f 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -76,6 +76,7 @@ describe("architecture guardrails", () => { const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts"); const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts"); const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts"); + const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts"); assert.ok(dragRule); assert.ok(livePreviewRule); @@ -84,6 +85,7 @@ describe("architecture guardrails", () => { assert.ok(rangeRule); assert.ok(optimisticRule); assert.ok(allocationFinalizeRule); + assert.ok(allocationMultiDragRule); 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", @@ -92,6 +94,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module", + "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: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations", ]); @@ -121,5 +124,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep segment extraction rules centralized", "apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep mutation snapshot creation centralized", ]); + + assert.deepEqual(evaluateRule(allocationMultiDragRule, "export function isAllocationMultiSelected() {}\n"), [ + "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep same-day delta suppression centralized", + "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", + ]); }); });