diff --git a/apps/web/src/hooks/timelineAllocationRelease.test.ts b/apps/web/src/hooks/timelineAllocationRelease.test.ts new file mode 100644 index 0000000..6430061 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationRelease.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; + +const BASE_ALLOC = { + isActive: true, + mode: "move" as const, + pointerDeltaX: 0, + daysDelta: 0, + allocationId: "alloc-1", + mutationAllocationId: null, + projectId: "project-1", + projectName: "Project One", + scope: "allocation" as const, + allocationStartDate: new Date("2025-01-01"), + allocationEndDate: new Date("2025-01-03"), + originalStartDate: new Date("2025-01-01"), + originalEndDate: new Date("2025-01-03"), + currentStartDate: new Date("2025-01-01"), + currentEndDate: new Date("2025-01-03"), +}; + +describe("timelineAllocationRelease", () => { + it("ignores inactive drags", () => { + expect(resolveAllocationRelease({ ...BASE_ALLOC, isActive: false }, { clickThresholdPx: 5, wasShift: false })).toEqual({ + kind: "ignore", + preservePreview: false, + }); + }); + + it("routes shift-click releases to multi-select toggling", () => { + expect(resolveAllocationRelease(BASE_ALLOC, { clickThresholdPx: 5, wasShift: true })).toEqual({ + kind: "shift-click", + allocationId: "alloc-1", + preservePreview: false, + }); + }); + + it("falls back to reset when click info cannot be built", () => { + expect( + resolveAllocationRelease( + { + ...BASE_ALLOC, + originalStartDate: null, + originalEndDate: null, + }, + { clickThresholdPx: 5, wasShift: false }, + ), + ).toEqual({ + kind: "reset", + preservePreview: false, + }); + }); + + it("builds a mutation outcome when the allocation dates changed", () => { + const currentStartDate = new Date("2025-01-02"); + const currentEndDate = new Date("2025-01-04"); + + expect( + resolveAllocationRelease( + { + ...BASE_ALLOC, + daysDelta: 1, + pointerDeltaX: 20, + currentStartDate, + currentEndDate, + }, + { clickThresholdPx: 5, wasShift: false }, + ), + ).toEqual({ + kind: "mutation", + preservePreview: true, + mutationPlan: { + activeAllocationId: "alloc-1", + currentStartDate, + currentEndDate, + baseMutationAllocationId: "alloc-1", + requiresExtraction: false, + pendingSnapshot: { + allocationId: "alloc-1", + mutationAllocationId: "alloc-1", + projectName: "Project One", + before: { + startDate: new Date("2025-01-01"), + endDate: new Date("2025-01-03"), + }, + after: { + startDate: currentStartDate, + endDate: currentEndDate, + }, + }, + }, + }); + }); + + it("resets changed segment drags when no allocation id remains available", () => { + expect( + resolveAllocationRelease( + { + ...BASE_ALLOC, + allocationId: null, + scope: "segment", + daysDelta: 1, + pointerDeltaX: 20, + currentStartDate: new Date("2025-01-02"), + currentEndDate: new Date("2025-01-04"), + }, + { clickThresholdPx: 5, wasShift: false }, + ), + ).toEqual({ + kind: "reset", + preservePreview: true, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationRelease.ts b/apps/web/src/hooks/timelineAllocationRelease.ts new file mode 100644 index 0000000..eed16f1 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationRelease.ts @@ -0,0 +1,62 @@ +import { buildAllocationBlockClickInfo, buildAllocationMutationPlan } from "./timelineAllocationActions.js"; +import { hasAllocationDateChange, shouldTreatAllocationDragAsClick } from "./timelineAllocationFinalize.js"; + +type AllocationReleaseLike = { + isActive: boolean; + mode: "move" | "resize-start" | "resize-end"; + pointerDeltaX: number; + daysDelta: number; + allocationId: string | null; + mutationAllocationId: string | null; + projectId: string | null; + projectName: string | null; + scope: "allocation" | "segment"; + allocationStartDate: Date | null; + allocationEndDate: Date | null; + originalStartDate: Date | null; + originalEndDate: Date | null; + currentStartDate: Date | null; + currentEndDate: Date | null; +}; + +type AllocationBlockClickInfo = ReturnType; +type AllocationMutationPlan = NonNullable>; + +export type AllocationReleaseOutcome = + | { kind: "ignore"; preservePreview: false } + | { kind: "reset"; preservePreview: boolean } + | { kind: "shift-click"; allocationId: string; preservePreview: boolean } + | { kind: "click"; clickInfo: NonNullable; preservePreview: boolean } + | { kind: "mutation"; mutationPlan: AllocationMutationPlan; preservePreview: true }; + +export function resolveAllocationRelease( + alloc: AllocationReleaseLike, + { clickThresholdPx, wasShift }: { clickThresholdPx: number; wasShift: boolean }, +): AllocationReleaseOutcome { + if (!alloc.isActive) { + return { kind: "ignore", preservePreview: false }; + } + + const preservePreview = hasAllocationDateChange(alloc); + const shouldTreatAsClick = shouldTreatAllocationDragAsClick(alloc, clickThresholdPx); + + if (shouldTreatAsClick && alloc.allocationId) { + if (wasShift) { + return { kind: "shift-click", allocationId: alloc.allocationId, preservePreview }; + } + + const clickInfo = buildAllocationBlockClickInfo(alloc); + if (clickInfo) { + return { kind: "click", clickInfo, preservePreview }; + } + } + + if (preservePreview && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) { + const mutationPlan = buildAllocationMutationPlan(alloc); + if (mutationPlan) { + return { kind: "mutation", mutationPlan, preservePreview: true }; + } + } + + return { kind: "reset", preservePreview }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 8db5c32..123f18b 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -11,18 +11,14 @@ import { scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; -import { - buildAllocationMovedSnapshot, - hasAllocationDateChange, - shouldTreatAllocationDragAsClick, -} from "./timelineAllocationFinalize.js"; -import { buildAllocationBlockClickInfo, buildAllocationMutationPlan } from "./timelineAllocationActions.js"; +import { buildAllocationMovedSnapshot } from "./timelineAllocationFinalize.js"; import { finalizeAllocationMultiDrag, isAllocationMultiSelected, startAllocationMultiDrag, updateAllocationMultiDrag, } from "./timelineAllocationMultiDrag.js"; +import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; @@ -703,36 +699,24 @@ export function useTimelineDrag({ allocDragCleanupRef.current = null; updateAllocationDragPosition(ev.clientX); const alloc = allocDragRef.current; - if (!alloc.isActive) return; - const hasDateChange = hasAllocationDateChange(alloc); + const release = resolveAllocationRelease(alloc, { + clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, + wasShift, + }); + if (release.kind === "ignore") return; - if (hasDateChange) { + if (release.preservePreview) { preserveLivePreview(allocPreviewRef.current); } clearLivePreview(allocPreviewRef.current); allocPreviewRef.current = null; - const shouldTreatAsClick = shouldTreatAllocationDragAsClick(alloc, DRAG_CLICK_THRESHOLD_PX); - - if (shouldTreatAsClick && alloc.allocationId) { - // No movement → treat as click - if (wasShift) { - // Shift+Click → toggle multi-selection for this allocation - onShiftClickAllocRef.current?.(alloc.allocationId); - } else { - // Normal click → open alloc popover - const clickInfo = buildAllocationBlockClickInfo(alloc); - if (clickInfo) { - onBlockClickRef.current?.(clickInfo); - } - } - } else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) { - const mutationPlan = buildAllocationMutationPlan(alloc); - if (!mutationPlan) { - allocDragRef.current = INITIAL_ALLOC_DRAG; - setAllocDragState(INITIAL_ALLOC_DRAG); - return; - } + if (release.kind === "shift-click") { + onShiftClickAllocRef.current?.(release.allocationId); + } else if (release.kind === "click") { + onBlockClickRef.current?.(release.clickInfo); + } else if (release.kind === "mutation") { + const { mutationPlan } = release; const { activeAllocationId, currentStartDate, diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 460775f..a77f0f7 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -240,6 +240,21 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineAllocationRelease.ts", + maxLines: 90, + required: [ + { + pattern: /\bexport function resolveAllocationRelease\b/, + message: "timeline allocation release helpers must keep release classification centralized", + }, + { + pattern: /from "\.\/timelineAllocationActions\.js"/, + message: "timeline allocation release helpers must keep click and mutation plan derivation delegated to allocation action helpers", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineDocumentDrag.ts", maxLines: 50, @@ -305,8 +320,8 @@ export const rules = [ message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module", }, { - pattern: /from "\.\/timelineAllocationActions\.js"/, - message: "timeline drag must keep allocation click and mutation plan derivation delegated to the extracted helper module", + pattern: /from "\.\/timelineAllocationRelease\.js"/, + message: "timeline drag must keep allocation release classification delegated to the extracted helper module", }, { pattern: /from "\.\/timelineDocumentDrag\.js"/, @@ -346,6 +361,10 @@ export const rules = [ pattern: /\bfunction (?:buildAllocationBlockClickInfo|buildAllocationMutationPlan)\b/, message: "timeline drag must not re-inline extracted allocation action helper implementations", }, + { + pattern: /\bfunction resolveAllocationRelease\b/, + message: "timeline drag must not re-inline extracted allocation release helper implementations", + }, { pattern: /\bfunction attachDocumentMouseDrag\b/, message: "timeline drag must not re-inline extracted document listener helper implementations", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 24ec800..2f4f4b6 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -78,6 +78,7 @@ describe("architecture guardrails", () => { 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 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 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"); @@ -91,6 +92,7 @@ describe("architecture guardrails", () => { assert.ok(allocationFinalizeRule); assert.ok(allocationMultiDragRule); assert.ok(allocationActionsRule); + assert.ok(allocationReleaseRule); assert.ok(documentDragRule); assert.ok(allocationDragStateRule); assert.ok(projectDragRule); @@ -102,7 +104,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 click and mutation plan derivation 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 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", @@ -148,6 +150,11 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationActions.ts: missing guardrail anchor: timeline allocation action helpers must keep mutation plan derivation centralized", ]); + assert.deepEqual(evaluateRule(allocationReleaseRule, ""), [ + "apps/web/src/hooks/timelineAllocationRelease.ts: missing guardrail anchor: timeline allocation release helpers must keep release classification centralized", + "apps/web/src/hooks/timelineAllocationRelease.ts: missing guardrail anchor: timeline allocation release helpers must keep click and mutation plan derivation delegated to allocation action helpers", + ]); + assert.deepEqual(evaluateRule(documentDragRule, ""), [ "apps/web/src/hooks/timelineDocumentDrag.ts: missing guardrail anchor: timeline document drag helpers must keep document mouse listener wiring centralized", ]); @@ -171,7 +178,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 click and mutation plan derivation 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 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",