diff --git a/apps/web/src/hooks/timelineProjectDrag.test.ts b/apps/web/src/hooks/timelineProjectDrag.test.ts new file mode 100644 index 0000000..4b6f823 --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDrag.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; + +type TestProjectDragState = { + isDragging: boolean; + projectId: string | null; + projectName: string | null; + allocationId: string | null; + originalStartDate: Date | null; + originalEndDate: Date | null; + currentStartDate: Date | null; + currentEndDate: Date | null; + startMouseX: number; + pointerDeltaX: number; + originalLeft: number; + blockWidth: number; + daysDelta: number; +}; + +describe("timelineProjectDrag", () => { + it("creates project-bar drag state with default legacy metrics cleared", () => { + expect( + createProjectDragState({ + projectId: "project-1", + projectName: "Alpha", + startDate: new Date("2025-01-10T00:00:00.000Z"), + endDate: new Date("2025-01-20T00:00:00.000Z"), + startMouseX: 240, + }), + ).toEqual({ + isDragging: true, + projectId: "project-1", + projectName: "Alpha", + allocationId: null, + originalStartDate: new Date("2025-01-10T00:00:00.000Z"), + originalEndDate: new Date("2025-01-20T00:00:00.000Z"), + currentStartDate: new Date("2025-01-10T00:00:00.000Z"), + currentEndDate: new Date("2025-01-20T00:00:00.000Z"), + startMouseX: 240, + pointerDeltaX: 0, + originalLeft: 0, + blockWidth: 0, + daysDelta: 0, + }); + }); + + it("preserves allocation metadata and block geometry for legacy allocation-block drags", () => { + expect( + createProjectDragState({ + projectId: "project-1", + projectName: "Alpha", + allocationId: "alloc-7", + startDate: new Date("2025-01-10T00:00:00.000Z"), + endDate: new Date("2025-01-20T00:00:00.000Z"), + startMouseX: 320, + originalLeft: 144, + blockWidth: 96, + }), + ).toMatchObject({ + allocationId: "alloc-7", + originalLeft: 144, + blockWidth: 96, + }); + }); + + it("refuses to build mutation input for no-op drags or incomplete state", () => { + expect( + buildProjectShiftMutationInput({ + projectId: "project-1", + currentStartDate: new Date("2025-01-10T00:00:00.000Z"), + currentEndDate: new Date("2025-01-20T00:00:00.000Z"), + daysDelta: 0, + }), + ).toBeNull(); + + expect( + buildProjectShiftMutationInput({ + projectId: null, + currentStartDate: new Date("2025-01-10T00:00:00.000Z"), + currentEndDate: new Date("2025-01-20T00:00:00.000Z"), + daysDelta: 2, + }), + ).toBeNull(); + }); + + it("builds mutation input only when a real drag produced complete dates", () => { + expect( + buildProjectShiftMutationInput({ + projectId: "project-1", + currentStartDate: new Date("2025-01-12T00:00:00.000Z"), + currentEndDate: new Date("2025-01-22T00:00:00.000Z"), + daysDelta: 2, + }), + ).toEqual({ + 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/timelineProjectDrag.ts b/apps/web/src/hooks/timelineProjectDrag.ts new file mode 100644 index 0000000..3b68324 --- /dev/null +++ b/apps/web/src/hooks/timelineProjectDrag.ts @@ -0,0 +1,60 @@ +type ProjectDragStateLike = { + isDragging: boolean; + projectId: string | null; + projectName: string | null; + allocationId: string | null; + originalStartDate: Date | null; + originalEndDate: Date | null; + currentStartDate: Date | null; + currentEndDate: Date | null; + startMouseX: number; + pointerDeltaX: number; + originalLeft: number; + blockWidth: number; + daysDelta: number; +}; + +type CreateProjectDragStateInput = { + projectId: string; + projectName: string; + allocationId?: string | null; + startDate: Date; + endDate: Date; + startMouseX: number; + originalLeft?: number; + blockWidth?: number; +}; + +export function createProjectDragState( + input: CreateProjectDragStateInput, +): TState { + return { + isDragging: true, + projectId: input.projectId, + projectName: input.projectName, + allocationId: input.allocationId ?? null, + originalStartDate: input.startDate, + originalEndDate: input.endDate, + currentStartDate: input.startDate, + currentEndDate: input.endDate, + startMouseX: input.startMouseX, + pointerDeltaX: 0, + originalLeft: input.originalLeft ?? 0, + blockWidth: input.blockWidth ?? 0, + daysDelta: 0, + } as TState; +} + +export function buildProjectShiftMutationInput( + drag: Pick, +): { projectId: string; newStartDate: Date; newEndDate: Date } | null { + if (drag.daysDelta === 0 || !drag.projectId || !drag.currentStartDate || !drag.currentEndDate) { + return null; + } + + return { + projectId: drag.projectId, + newStartDate: drag.currentStartDate, + newEndDate: drag.currentEndDate, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 69f8339..ebbc965 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 { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { createMultiSelectState, finalizeMultiSelectDraft, @@ -448,17 +449,7 @@ export function useTimelineDrag({ const finalDrag = dragStateRef.current; if (!finalDrag.isDragging) return null; - const mutationInput = - finalDrag.daysDelta !== 0 && - finalDrag.projectId && - finalDrag.currentStartDate && - finalDrag.currentEndDate - ? { - projectId: finalDrag.projectId, - newStartDate: finalDrag.currentStartDate, - newEndDate: finalDrag.currentEndDate, - } - : null; + const mutationInput = buildProjectShiftMutationInput(finalDrag); if (finalDrag.daysDelta !== 0) { preserveLivePreview(projectPreviewRef.current); @@ -543,21 +534,13 @@ export function useTimelineDrag({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - const state: DragState = { - isDragging: true, + const state = createProjectDragState({ projectId: opts.projectId, projectName: opts.projectName, - allocationId: null, - originalStartDate: opts.startDate, - originalEndDate: opts.endDate, - currentStartDate: opts.startDate, - currentEndDate: opts.endDate, + startDate: opts.startDate, + endDate: opts.endDate, startMouseX: e.clientX, - pointerDeltaX: 0, - originalLeft: 0, - blockWidth: 0, - daysDelta: 0, - }; + }); dragStateRef.current = state; setDragState(state); @@ -602,21 +585,16 @@ export function useTimelineDrag({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - const state: DragState = { - isDragging: true, + const state = createProjectDragState({ projectId: opts.projectId, projectName: opts.projectName, allocationId: opts.allocationId ?? null, - originalStartDate: opts.startDate, - originalEndDate: opts.endDate, - currentStartDate: opts.startDate, - currentEndDate: opts.endDate, + startDate: opts.startDate, + endDate: opts.endDate, startMouseX: e.clientX, - pointerDeltaX: 0, originalLeft: opts.blockLeft, blockWidth: opts.blockWidth, - daysDelta: 0, - }; + }); dragStateRef.current = state; setDragState(state); }, diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 8f1e193..4eec4e3 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -217,6 +217,21 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineProjectDrag.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function createProjectDragState\b/, + message: "timeline project drag helpers must keep drag-state bootstrap centralized", + }, + { + pattern: /\bexport function buildProjectShiftMutationInput\b/, + message: "timeline project drag helpers must keep no-op project-shift mutation gating centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/useTimelineDrag.ts", required: [ @@ -248,6 +263,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 "\.\/timelineProjectDrag\.js"/, + message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", + }, ], forbidden: [ { @@ -266,6 +285,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 (?: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 c7fdb8f..ab1dd58 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 projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); assert.ok(dragRule); assert.ok(livePreviewRule); @@ -86,6 +87,7 @@ describe("architecture guardrails", () => { assert.ok(optimisticRule); assert.ok(allocationFinalizeRule); assert.ok(allocationMultiDragRule); + assert.ok(projectDragRule); 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", @@ -95,6 +97,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 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", ]); @@ -129,5 +132,9 @@ describe("architecture guardrails", () => { "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", ]); + + 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", + ]); }); });