From 203bb8751d691b3f771fd97765eae928652c3832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 10:10:06 +0200 Subject: [PATCH] refactor(web): extract allocation drag bootstrap --- .../hooks/timelineAllocationDragState.test.ts | 67 +++++++++++++++++++ .../src/hooks/timelineAllocationDragState.ts | 60 +++++++++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 22 +++--- scripts/check-architecture-guardrails.mjs | 19 ++++++ .../check-architecture-guardrails.test.mjs | 7 ++ 5 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/hooks/timelineAllocationDragState.test.ts create mode 100644 apps/web/src/hooks/timelineAllocationDragState.ts diff --git a/apps/web/src/hooks/timelineAllocationDragState.test.ts b/apps/web/src/hooks/timelineAllocationDragState.test.ts new file mode 100644 index 0000000..fe54f12 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationDragState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { createAllocationDragState, type AllocationDragStateLike } from "./timelineAllocationDragState.js"; + +describe("timelineAllocationDragState", () => { + it("defaults scope and mutation ids for whole-allocation drags", () => { + expect( + createAllocationDragState, "move", "allocation" | "segment">({ + mode: "move", + allocationId: "alloc-1", + projectId: "project-1", + projectName: "Alpha", + resourceId: null, + startDate: new Date("2025-01-01T00:00:00.000Z"), + endDate: new Date("2025-01-05T00:00:00.000Z"), + startMouseX: 320, + }), + ).toEqual({ + isActive: true, + mode: "move", + scope: "allocation", + allocationId: "alloc-1", + mutationAllocationId: "alloc-1", + projectId: "project-1", + projectName: "Alpha", + resourceId: null, + allocationStartDate: new Date("2025-01-01T00:00:00.000Z"), + allocationEndDate: new Date("2025-01-05T00:00:00.000Z"), + originalStartDate: new Date("2025-01-01T00:00:00.000Z"), + originalEndDate: new Date("2025-01-05T00:00:00.000Z"), + currentStartDate: new Date("2025-01-01T00:00:00.000Z"), + currentEndDate: new Date("2025-01-05T00:00:00.000Z"), + startMouseX: 320, + pointerDeltaX: 0, + daysDelta: 0, + }); + }); + + it("preserves explicit mutation ids and segment boundaries for fragment drags", () => { + expect( + createAllocationDragState< + AllocationDragStateLike<"resize-end", "allocation" | "segment">, + "resize-end", + "allocation" | "segment" + >({ + mode: "resize-end", + scope: "segment", + allocationId: "alloc-1", + mutationAllocationId: "alloc-fragment-2", + projectId: "project-1", + projectName: "Alpha", + resourceId: "resource-4", + startDate: new Date("2025-01-03T00:00:00.000Z"), + endDate: new Date("2025-01-04T00:00:00.000Z"), + allocationStartDate: new Date("2025-01-01T00:00:00.000Z"), + allocationEndDate: new Date("2025-01-07T00:00:00.000Z"), + startMouseX: 144, + }), + ).toMatchObject({ + mode: "resize-end", + scope: "segment", + mutationAllocationId: "alloc-fragment-2", + resourceId: "resource-4", + allocationStartDate: new Date("2025-01-01T00:00:00.000Z"), + allocationEndDate: new Date("2025-01-07T00:00:00.000Z"), + }); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationDragState.ts b/apps/web/src/hooks/timelineAllocationDragState.ts new file mode 100644 index 0000000..7b723ec --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationDragState.ts @@ -0,0 +1,60 @@ +export type AllocationDragStateLike = { + isActive: boolean; + mode: TMode; + scope: TScope; + allocationId: string | null; + mutationAllocationId: string | null; + projectId: string | null; + projectName: string | null; + resourceId: string | null; + allocationStartDate: Date | null; + allocationEndDate: Date | null; + originalStartDate: Date | null; + originalEndDate: Date | null; + currentStartDate: Date | null; + currentEndDate: Date | null; + startMouseX: number; + pointerDeltaX: number; + daysDelta: number; +}; + +type CreateAllocationDragStateInput = { + mode: TMode; + scope?: TScope | undefined; + allocationId: string; + mutationAllocationId?: string | undefined; + projectId: string; + projectName: string; + resourceId: string | null; + startDate: Date; + endDate: Date; + allocationStartDate?: Date | undefined; + allocationEndDate?: Date | undefined; + startMouseX: number; +}; + +export function createAllocationDragState< + TState extends AllocationDragStateLike, + TMode extends string, + TScope extends string, +>(input: CreateAllocationDragStateInput): TState { + return { + isActive: true, + mode: input.mode, + scope: input.scope ?? ("allocation" as TScope), + allocationId: input.allocationId, + mutationAllocationId: input.mutationAllocationId ?? input.allocationId, + projectId: input.projectId, + projectName: input.projectName, + resourceId: input.resourceId, + allocationStartDate: input.allocationStartDate ?? input.startDate, + allocationEndDate: input.allocationEndDate ?? input.endDate, + originalStartDate: input.startDate, + originalEndDate: input.endDate, + currentStartDate: input.startDate, + currentEndDate: input.endDate, + startMouseX: input.startMouseX, + pointerDeltaX: 0, + daysDelta: 0, + } as TState; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index ebbc965..acf36f6 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -23,6 +23,7 @@ import { startAllocationMultiDrag, updateAllocationMultiDrag, } from "./timelineAllocationMultiDrag.js"; +import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { createMultiSelectState, @@ -682,25 +683,20 @@ export function useTimelineDrag({ // ── Single allocation drag ──────────────────────────────────────────── - const initial: AllocDragState = { - isActive: true, + const initial = createAllocationDragState({ mode: opts.mode, - scope: opts.scope ?? "allocation", + scope: opts.scope, allocationId: opts.allocationId, - mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId, + mutationAllocationId: opts.mutationAllocationId, projectId: opts.projectId, projectName: opts.projectName, resourceId: opts.resourceId, - allocationStartDate: opts.allocationStartDate ?? opts.startDate, - allocationEndDate: opts.allocationEndDate ?? opts.endDate, - originalStartDate: opts.startDate, - originalEndDate: opts.endDate, - currentStartDate: opts.startDate, - currentEndDate: opts.endDate, + allocationStartDate: opts.allocationStartDate, + allocationEndDate: opts.allocationEndDate, + startDate: opts.startDate, + endDate: opts.endDate, startMouseX: e.clientX, - pointerDeltaX: 0, - daysDelta: 0, - }; + }); allocDragRef.current = initial; setAllocDragState(initial); setAllocationPreviewTarget(e.currentTarget, opts.mode); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 4eec4e3..16b65f3 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -217,6 +217,17 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineAllocationDragState.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function createAllocationDragState\b/, + message: "timeline allocation drag state helpers must keep drag bootstrap centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineProjectDrag.ts", maxLines: 80, @@ -263,6 +274,10 @@ export const rules = [ pattern: /from "\.\/timelineAllocationMultiDrag\.js"/, message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineAllocationDragState\.js"/, + message: "timeline drag must keep allocation drag bootstrap 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", @@ -285,6 +300,10 @@ export const rules = [ pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/, message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations", }, + { + pattern: /\bfunction createAllocationDragState\b/, + message: "timeline drag must not re-inline extracted allocation drag bootstrap helpers", + }, { 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 ab1dd58..7bf8aa7 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -77,6 +77,7 @@ describe("architecture guardrails", () => { 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"); + 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"); assert.ok(dragRule); @@ -87,6 +88,7 @@ describe("architecture guardrails", () => { assert.ok(optimisticRule); assert.ok(allocationFinalizeRule); assert.ok(allocationMultiDragRule); + assert.ok(allocationDragStateRule); assert.ok(projectDragRule); assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [ @@ -97,6 +99,7 @@ describe("architecture guardrails", () => { "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: 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: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations", ]); @@ -133,6 +136,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", ]); + assert.deepEqual(evaluateRule(allocationDragStateRule, ""), [ + "apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap 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", ]);