diff --git a/apps/web/src/hooks/timelineRangeRelease.test.ts b/apps/web/src/hooks/timelineRangeRelease.test.ts new file mode 100644 index 0000000..1ff5afd --- /dev/null +++ b/apps/web/src/hooks/timelineRangeRelease.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js"; + +type TestRangeState = { + isSelecting: boolean; + resourceId: string | null; + startDate: Date | null; + currentDate: Date | null; + suggestedProjectId: string | null; + startClientX: number; +}; + +const INITIAL_RANGE_STATE: TestRangeState = { + isSelecting: false, + resourceId: null, + startDate: null, + currentDate: null, + suggestedProjectId: null, + startClientX: 0, +}; + +describe("timelineRangeRelease", () => { + it("keeps state unchanged when release happens outside an active range selection", () => { + const result = resolveRangeSelectionRelease(INITIAL_RANGE_STATE, 12, 34, INITIAL_RANGE_STATE); + + expect(result).toEqual({ + kind: "noop", + nextState: INITIAL_RANGE_STATE, + selection: null, + }); + }); + + it("keeps state unchanged when the active range is structurally invalid on release", () => { + const invalidState: TestRangeState = { + ...INITIAL_RANGE_STATE, + isSelecting: true, + resourceId: null, + startDate: new Date("2026-04-01T00:00:00.000Z"), + currentDate: new Date("2026-04-03T00:00:00.000Z"), + }; + + const result = resolveRangeSelectionRelease(invalidState, 40, 50, INITIAL_RANGE_STATE); + + expect(result).toEqual({ + kind: "noop", + nextState: invalidState, + selection: null, + }); + }); + + it("resets to the provided initial state when release completes a backwards drag", () => { + const selectingState: TestRangeState = { + isSelecting: true, + resourceId: "res_1", + startDate: new Date("2026-04-05T00:00:00.000Z"), + currentDate: new Date("2026-04-02T00:00:00.000Z"), + suggestedProjectId: "proj_1", + startClientX: 120, + }; + + const result = resolveRangeSelectionRelease(selectingState, 200, 300, INITIAL_RANGE_STATE); + + expect(result).toEqual({ + kind: "complete", + nextState: INITIAL_RANGE_STATE, + selection: { + resourceId: "res_1", + startDate: new Date("2026-04-02T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + suggestedProjectId: "proj_1", + anchorX: 200, + anchorY: 300, + }, + }); + }); + + it("does not reset on cancel when there is no active range selection", () => { + const result = resolveRangeSelectionCancel(INITIAL_RANGE_STATE, INITIAL_RANGE_STATE); + + expect(result).toEqual({ + didReset: false, + nextState: INITIAL_RANGE_STATE, + }); + }); + + it("resets on cancel when a range selection is active", () => { + const selectingState: TestRangeState = { + isSelecting: true, + resourceId: "res_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + currentDate: new Date("2026-04-03T00:00:00.000Z"), + suggestedProjectId: null, + startClientX: 100, + }; + + const result = resolveRangeSelectionCancel(selectingState, INITIAL_RANGE_STATE); + + expect(result).toEqual({ + didReset: true, + nextState: INITIAL_RANGE_STATE, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineRangeRelease.ts b/apps/web/src/hooks/timelineRangeRelease.ts new file mode 100644 index 0000000..1d6e715 --- /dev/null +++ b/apps/web/src/hooks/timelineRangeRelease.ts @@ -0,0 +1,53 @@ +import { finalizeRangeSelection, type RangeSelectionResult } from "./timelineRangeSelection.js"; + +type RangeStateLike = { + isSelecting: boolean; + resourceId: string | null; + startDate: Date | null; + currentDate: Date | null; + suggestedProjectId: string | null; + startClientX: number; +}; + +export type RangeReleaseResolution = + | { kind: "noop"; nextState: TState; selection: null } + | { kind: "complete"; nextState: TState; selection: RangeSelectionResult }; + +export function resolveRangeSelectionRelease( + state: TState, + anchorX: number, + anchorY: number, + initialState: TState, +): RangeReleaseResolution { + const selection = finalizeRangeSelection(state, anchorX, anchorY); + if (!selection) { + return { + kind: "noop", + nextState: state, + selection: null, + }; + } + + return { + kind: "complete", + nextState: initialState, + selection, + }; +} + +export function resolveRangeSelectionCancel( + state: TState, + initialState: TState, +): { didReset: boolean; nextState: TState } { + if (!state.isSelecting) { + return { + didReset: false, + nextState: state, + }; + } + + return { + didReset: true, + nextState: initialState, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 76a6dea..a7367c4 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -39,7 +39,8 @@ import { } from "./timelineMultiSelect.js"; import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js"; import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js"; -import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; +import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js"; +import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -716,14 +717,12 @@ export function useTimelineDrag({ } // Range select - const range = rangeStateRef.current; - const selection = finalizeRangeSelection(range, e.clientX, e.clientY); - if (selection) { - onRangeSelected?.(selection); + const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE); + if (release.kind !== "complete") return; - rangeStateRef.current = INITIAL_RANGE_STATE; - setRangeState(INITIAL_RANGE_STATE); - } + onRangeSelected?.(release.selection); + rangeStateRef.current = release.nextState; + setRangeState(release.nextState); }, [finalizeActiveProjectDrag, onRangeSelected], ); @@ -731,10 +730,11 @@ export function useTimelineDrag({ const onCanvasMouseLeave = useCallback(() => { // Only cancel project-shift and range-select on canvas leave. // Alloc drag is managed by document-level listeners and must NOT be cancelled here. - if (rangeStateRef.current.isSelecting) { - rangeStateRef.current = INITIAL_RANGE_STATE; - setRangeState(INITIAL_RANGE_STATE); - } + const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE); + if (!cancellation.didReset) return; + + rangeStateRef.current = cancellation.nextState; + setRangeState(cancellation.nextState); }, []); // ── Multi-select (right-click drag) ───────────────────────────────────────── diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 5eb9df2..de2b19e 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -225,6 +225,25 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineRangeRelease.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function resolveRangeSelectionRelease\b/, + message: "timeline range release helpers must keep canvas release resolution centralized", + }, + { + pattern: /\bexport function resolveRangeSelectionCancel\b/, + message: "timeline range release helpers must keep canvas leave cancellation centralized", + }, + { + pattern: /from "\.\/timelineRangeSelection\.js"/, + message: "timeline range release helpers must keep selection finalization delegated to the range helper module", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineOptimisticAllocations.ts", maxLines: 80, @@ -465,6 +484,10 @@ export const rules = [ pattern: /from "\.\/timelineRangeSelection\.js"/, message: "timeline drag must keep range preview and finalization delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineRangeRelease\.js"/, + message: "timeline drag must keep range release and cancel delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineOptimisticAllocations\.js"/, message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", @@ -571,6 +594,14 @@ export const rules = [ pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/, message: "timeline drag must not re-inline extracted project drag finalize flow", }, + { + pattern: /\bconst selection = finalizeRangeSelection\(/, + message: "timeline drag must not re-inline extracted range release resolution", + }, + { + pattern: /\bif \(rangeStateRef\.current\.isSelecting\)\s*\{[\s\S]*setRangeState\(INITIAL_RANGE_STATE\);[\s\S]*\}/, + message: "timeline drag must not re-inline extracted range cancel reset flow", + }, ], }, { diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index de7b19f..770eb53 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -77,6 +77,7 @@ describe("architecture guardrails", () => { const multiSelectRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelect.ts"); const multiSelectSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelectSession.ts"); const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts"); + const rangeReleaseRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeRelease.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"); @@ -107,6 +108,7 @@ describe("architecture guardrails", () => { assert.ok(multiSelectRule); assert.ok(multiSelectSessionRule); assert.ok(rangeRule); + assert.ok(rangeReleaseRule); assert.ok(optimisticRule); assert.ok(allocationFinalizeRule); assert.ok(allocationMultiDragRule); @@ -129,6 +131,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module", "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 range release and cancel 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 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", @@ -180,6 +183,11 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep ordered range finalization centralized", ]); + assert.deepEqual(evaluateRule(rangeReleaseRule, "export function resolveRangeSelectionRelease() {}\n"), [ + "apps/web/src/hooks/timelineRangeRelease.ts: missing guardrail anchor: timeline range release helpers must keep canvas leave cancellation centralized", + "apps/web/src/hooks/timelineRangeRelease.ts: missing guardrail anchor: timeline range release helpers must keep selection finalization delegated to the range helper module", + ]); + assert.deepEqual(evaluateRule(optimisticRule, ""), [ "apps/web/src/hooks/timelineOptimisticAllocations.ts: missing guardrail anchor: timeline optimistic helpers must keep server-reconciliation logic centralized", ]); @@ -259,6 +267,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module", "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 range release and cancel 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 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",