diff --git a/apps/web/src/hooks/timelineDragPosition.test.ts b/apps/web/src/hooks/timelineDragPosition.test.ts new file mode 100644 index 0000000..92535af --- /dev/null +++ b/apps/web/src/hooks/timelineDragPosition.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; + +describe("timelineDragPosition", () => { + it("ignores project drags that are inactive or missing their original dates", () => { + expect( + resolveProjectDragPosition( + { + isDragging: false, + originalStartDate: new Date("2026-01-10"), + originalEndDate: new Date("2026-01-12"), + currentStartDate: null, + currentEndDate: null, + startMouseX: 100, + pointerDeltaX: 0, + daysDelta: 0, + }, + 180, + 40, + ), + ).toEqual({ handled: false }); + + expect( + resolveProjectDragPosition( + { + isDragging: true, + originalStartDate: null, + originalEndDate: new Date("2026-01-12"), + currentStartDate: null, + currentEndDate: null, + startMouseX: 100, + pointerDeltaX: 0, + daysDelta: 0, + }, + 180, + 40, + ), + ).toEqual({ handled: false }); + }); + + it("updates only the pointer delta when the rounded day delta does not change", () => { + const result = resolveProjectDragPosition( + { + isDragging: true, + originalStartDate: new Date("2026-01-10"), + originalEndDate: new Date("2026-01-12"), + currentStartDate: null, + currentEndDate: null, + startMouseX: 100, + pointerDeltaX: 0, + daysDelta: 0, + }, + 118, + 40, + ); + + expect(result).toMatchObject({ + handled: true, + pointerDeltaX: 18, + daysDelta: 0, + shouldSyncState: false, + nextState: { + pointerDeltaX: 18, + daysDelta: 0, + currentStartDate: null, + currentEndDate: null, + }, + }); + }); + + it("recomputes project dates once the drag crosses into a different day bucket", () => { + const result = resolveProjectDragPosition( + { + isDragging: true, + originalStartDate: new Date("2026-01-10"), + originalEndDate: new Date("2026-01-12"), + currentStartDate: null, + currentEndDate: null, + startMouseX: 100, + pointerDeltaX: 0, + daysDelta: 0, + }, + 195, + 40, + ); + + expect(result).toMatchObject({ + handled: true, + pointerDeltaX: 95, + daysDelta: 2, + shouldSyncState: true, + nextState: { + currentStartDate: new Date("2026-01-12"), + currentEndDate: new Date("2026-01-14"), + pointerDeltaX: 95, + daysDelta: 2, + }, + }); + }); + + it("clamps allocation resize-end drags so the end date does not cross the start date", () => { + const result = resolveAllocationDragPosition( + { + isActive: true, + mode: "resize-end", + originalStartDate: new Date("2026-01-10"), + originalEndDate: new Date("2026-01-12"), + currentStartDate: null, + currentEndDate: null, + startMouseX: 100, + pointerDeltaX: 0, + daysDelta: 0, + }, + -10, + 40, + ); + + expect(result).toMatchObject({ + handled: true, + pointerDeltaX: -110, + daysDelta: -3, + shouldSyncState: true, + nextState: { + currentStartDate: new Date("2026-01-10"), + currentEndDate: new Date("2026-01-10"), + pointerDeltaX: -110, + daysDelta: -3, + }, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineDragPosition.ts b/apps/web/src/hooks/timelineDragPosition.ts new file mode 100644 index 0000000..fc809af --- /dev/null +++ b/apps/web/src/hooks/timelineDragPosition.ts @@ -0,0 +1,74 @@ +import { computeDragDates, pixelsToDays } from "~/components/timeline/dragMath.js"; + +type DragPositionLike = { + originalStartDate: Date | null; + originalEndDate: Date | null; + currentStartDate: Date | null; + currentEndDate: Date | null; + startMouseX: number; + pointerDeltaX: number; + daysDelta: number; +}; + +type DragMode = "move" | "resize-start" | "resize-end"; + +export type TimelineDragPositionResult = + | { handled: false } + | { handled: true; nextState: State; pointerDeltaX: number; daysDelta: number; shouldSyncState: boolean }; + +function resolveDragPosition( + state: State, + clientX: number, + cellWidth: number, + mode: DragMode, +): TimelineDragPositionResult { + const pointerDeltaX = clientX - state.startMouseX; + const daysDelta = pixelsToDays(pointerDeltaX, cellWidth); + + if (daysDelta === state.daysDelta) { + return { + handled: true, + nextState: pointerDeltaX === state.pointerDeltaX ? state : { ...state, pointerDeltaX }, + pointerDeltaX, + daysDelta, + shouldSyncState: false, + }; + } + + const { start, end } = computeDragDates(mode, state.originalStartDate!, state.originalEndDate!, daysDelta); + return { + handled: true, + nextState: { + ...state, + currentStartDate: start, + currentEndDate: end, + pointerDeltaX, + daysDelta, + }, + pointerDeltaX, + daysDelta, + shouldSyncState: true, + }; +} + +export function resolveProjectDragPosition( + drag: State, + clientX: number, + cellWidth: number, +): TimelineDragPositionResult { + if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) { + return { handled: false }; + } + + return resolveDragPosition(drag, clientX, cellWidth, "move"); +} + +export function resolveAllocationDragPosition< + State extends DragPositionLike & { isActive: boolean; mode: DragMode }, +>(alloc: State, clientX: number, cellWidth: number): TimelineDragPositionResult { + if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) { + return { handled: false }; + } + + return resolveDragPosition(alloc, clientX, cellWidth, alloc.mode); +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index f912231..ac4cb10 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; -import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js"; +import { pixelsToDays } from "~/components/timeline/dragMath.js"; import { captureLivePreviewTargets, clearLivePreview, @@ -21,6 +21,7 @@ import { import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; +import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { beginProjectDragSession } from "./timelineProjectDragSession.js"; @@ -343,35 +344,14 @@ export function useTimelineDrag({ const updateProjectDragPosition = useCallback( (clientX: number) => { - const drag = dragStateRef.current; - if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false; + const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current); + if (!result.handled) return false; - const deltaX = clientX - drag.startMouseX; - const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); - updateLivePreview(projectPreviewRef, deltaX, daysDelta); - - if (daysDelta === drag.daysDelta) { - if (deltaX !== drag.pointerDeltaX) { - dragStateRef.current = { ...drag, pointerDeltaX: deltaX }; - } - return true; + updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta); + dragStateRef.current = result.nextState; + if (result.shouldSyncState) { + setDragState(result.nextState); } - - const { start: newStart, end: newEnd } = computeDragDates( - "move", - drag.originalStartDate, - drag.originalEndDate, - daysDelta, - ); - const updated: DragState = { - ...drag, - currentStartDate: newStart, - currentEndDate: newEnd, - pointerDeltaX: deltaX, - daysDelta, - }; - dragStateRef.current = updated; - setDragState(updated); return true; }, [updateLivePreview], @@ -379,36 +359,14 @@ export function useTimelineDrag({ const updateAllocationDragPosition = useCallback( (clientX: number) => { - const alloc = allocDragRef.current; - if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false; + const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current); + if (!result.handled) return false; - const pointerDeltaX = clientX - alloc.startMouseX; - const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current); - updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta); - - if (daysDelta === alloc.daysDelta) { - if (pointerDeltaX !== alloc.pointerDeltaX) { - allocDragRef.current = { ...alloc, pointerDeltaX }; - } - return true; + updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta); + allocDragRef.current = result.nextState; + if (result.shouldSyncState) { + setAllocDragState(result.nextState); } - - const { start: newStart, end: newEnd } = computeDragDates( - alloc.mode, - alloc.originalStartDate, - alloc.originalEndDate, - daysDelta, - ); - - const updated: AllocDragState = { - ...alloc, - currentStartDate: newStart, - currentEndDate: newEnd, - pointerDeltaX, - daysDelta, - }; - allocDragRef.current = updated; - setAllocDragState(updated); return true; }, [updateLivePreview], diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 5a3c8f3..6bb4e99 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -292,6 +292,21 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineDragPosition.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function resolveProjectDragPosition\b/, + message: "timeline drag position helpers must keep project drag date derivation centralized", + }, + { + pattern: /\bexport function resolveAllocationDragPosition\b/, + message: "timeline drag position helpers must keep allocation drag date derivation centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineDocumentDrag.ts", maxLines: 50, @@ -383,6 +398,10 @@ export const rules = [ pattern: /from "\.\/timelineDragCleanup\.js"/, message: "timeline drag must keep unmount teardown delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineDragPosition\.js"/, + message: "timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineDocumentDrag\.js"/, message: "timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", @@ -433,6 +452,10 @@ export const rules = [ pattern: /\bfunction attachDocumentMouseDrag\b/, message: "timeline drag must not re-inline extracted document listener helper implementations", }, + { + pattern: /\bconst (?:deltaX|pointerDeltaX) = clientX - (?:drag|alloc)\.startMouseX;[\s\S]*computeDragDates\(/, + message: "timeline drag must not re-inline extracted project or allocation drag position helpers", + }, { 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 0678da6..4c420e6 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -82,6 +82,7 @@ describe("architecture guardrails", () => { const allocationActionsRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationActions.ts"); const allocationReleaseRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationRelease.ts"); const cleanupRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDragCleanup.ts"); + const positionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDragPosition.ts"); 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"); @@ -100,6 +101,7 @@ describe("architecture guardrails", () => { assert.ok(allocationActionsRule); assert.ok(allocationReleaseRule); assert.ok(cleanupRule); + assert.ok(positionRule); assert.ok(documentDragRule); assert.ok(allocationDragStateRule); assert.ok(projectDragRule); @@ -116,6 +118,7 @@ describe("architecture guardrails", () => { "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 release classification delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle 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", @@ -180,6 +183,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineDragCleanup.ts: missing guardrail anchor: timeline drag cleanup helpers must keep unmount teardown centralized", ]); + assert.deepEqual(evaluateRule(positionRule, "export function resolveProjectDragPosition() {}\n"), [ + "apps/web/src/hooks/timelineDragPosition.ts: missing guardrail anchor: timeline drag position helpers must keep allocation drag date derivation centralized", + ]); + assert.deepEqual(evaluateRule(documentDragRule, ""), [ "apps/web/src/hooks/timelineDocumentDrag.ts: missing guardrail anchor: timeline document drag helpers must keep document mouse listener wiring centralized", ]); @@ -211,6 +218,7 @@ describe("architecture guardrails", () => { "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 release classification delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle 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",