refactor(web): extract range release resolution
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TState> =
|
||||||
|
| { kind: "noop"; nextState: TState; selection: null }
|
||||||
|
| { kind: "complete"; nextState: TState; selection: RangeSelectionResult };
|
||||||
|
|
||||||
|
export function resolveRangeSelectionRelease<TState extends RangeStateLike>(
|
||||||
|
state: TState,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
initialState: TState,
|
||||||
|
): RangeReleaseResolution<TState> {
|
||||||
|
const selection = finalizeRangeSelection(state, anchorX, anchorY);
|
||||||
|
if (!selection) {
|
||||||
|
return {
|
||||||
|
kind: "noop",
|
||||||
|
nextState: state,
|
||||||
|
selection: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "complete",
|
||||||
|
nextState: initialState,
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRangeSelectionCancel<TState extends RangeStateLike>(
|
||||||
|
state: TState,
|
||||||
|
initialState: TState,
|
||||||
|
): { didReset: boolean; nextState: TState } {
|
||||||
|
if (!state.isSelecting) {
|
||||||
|
return {
|
||||||
|
didReset: false,
|
||||||
|
nextState: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
didReset: true,
|
||||||
|
nextState: initialState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ import {
|
|||||||
} from "./timelineMultiSelect.js";
|
} from "./timelineMultiSelect.js";
|
||||||
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
||||||
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.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";
|
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
|
||||||
|
|
||||||
const DRAG_CLICK_THRESHOLD_PX = 5;
|
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||||
@@ -716,14 +717,12 @@ export function useTimelineDrag({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Range select
|
// Range select
|
||||||
const range = rangeStateRef.current;
|
const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
|
||||||
const selection = finalizeRangeSelection(range, e.clientX, e.clientY);
|
if (release.kind !== "complete") return;
|
||||||
if (selection) {
|
|
||||||
onRangeSelected?.(selection);
|
|
||||||
|
|
||||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
onRangeSelected?.(release.selection);
|
||||||
setRangeState(INITIAL_RANGE_STATE);
|
rangeStateRef.current = release.nextState;
|
||||||
}
|
setRangeState(release.nextState);
|
||||||
},
|
},
|
||||||
[finalizeActiveProjectDrag, onRangeSelected],
|
[finalizeActiveProjectDrag, onRangeSelected],
|
||||||
);
|
);
|
||||||
@@ -731,10 +730,11 @@ export function useTimelineDrag({
|
|||||||
const onCanvasMouseLeave = useCallback(() => {
|
const onCanvasMouseLeave = useCallback(() => {
|
||||||
// Only cancel project-shift and range-select on canvas leave.
|
// Only cancel project-shift and range-select on canvas leave.
|
||||||
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
||||||
if (rangeStateRef.current.isSelecting) {
|
const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE);
|
||||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
if (!cancellation.didReset) return;
|
||||||
setRangeState(INITIAL_RANGE_STATE);
|
|
||||||
}
|
rangeStateRef.current = cancellation.nextState;
|
||||||
|
setRangeState(cancellation.nextState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Multi-select (right-click drag) ─────────────────────────────────────────
|
// ── Multi-select (right-click drag) ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -225,6 +225,25 @@ export const rules = [
|
|||||||
],
|
],
|
||||||
forbidden: [],
|
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",
|
file: "apps/web/src/hooks/timelineOptimisticAllocations.ts",
|
||||||
maxLines: 80,
|
maxLines: 80,
|
||||||
@@ -465,6 +484,10 @@ export const rules = [
|
|||||||
pattern: /from "\.\/timelineRangeSelection\.js"/,
|
pattern: /from "\.\/timelineRangeSelection\.js"/,
|
||||||
message: "timeline drag must keep range preview and finalization delegated to the extracted helper module",
|
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"/,
|
pattern: /from "\.\/timelineOptimisticAllocations\.js"/,
|
||||||
message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
|
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)\(/,
|
pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/,
|
||||||
message: "timeline drag must not re-inline extracted project drag finalize flow",
|
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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe("architecture guardrails", () => {
|
|||||||
const multiSelectRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelect.ts");
|
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 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 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 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 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 allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts");
|
||||||
@@ -107,6 +108,7 @@ describe("architecture guardrails", () => {
|
|||||||
assert.ok(multiSelectRule);
|
assert.ok(multiSelectRule);
|
||||||
assert.ok(multiSelectSessionRule);
|
assert.ok(multiSelectSessionRule);
|
||||||
assert.ok(rangeRule);
|
assert.ok(rangeRule);
|
||||||
|
assert.ok(rangeReleaseRule);
|
||||||
assert.ok(optimisticRule);
|
assert.ok(optimisticRule);
|
||||||
assert.ok(allocationFinalizeRule);
|
assert.ok(allocationFinalizeRule);
|
||||||
assert.ok(allocationMultiDragRule);
|
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 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 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 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 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 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 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",
|
"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, ""), [
|
assert.deepEqual(evaluateRule(optimisticRule, ""), [
|
||||||
"apps/web/src/hooks/timelineOptimisticAllocations.ts: missing guardrail anchor: timeline optimistic helpers must keep server-reconciliation logic centralized",
|
"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 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 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 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 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 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 project and allocation drag position derivation delegated to the extracted helper module",
|
||||||
|
|||||||
Reference in New Issue
Block a user