refactor(web): extract allocation drag session

This commit is contained in:
2026-04-01 11:27:03 +02:00
parent 510459fbff
commit f4e9831dea
5 changed files with 228 additions and 78 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
describe("timelineAllocationDragSession", () => {
it("starts the session, forwards movement, and finalizes on mouseup", () => {
const previousCleanup = vi.fn();
const setState = vi.fn();
const updatePosition = vi.fn();
const finalize = vi.fn();
const state = { allocationId: "alloc-1", isActive: true };
const cleanupRef = { current: previousCleanup as (() => void) | null };
const stateRef = { current: { allocationId: null, isActive: false } };
const handlers: {
move?: (event: MouseEvent) => void;
up?: (event: MouseEvent) => void;
} = {};
beginAllocationDragSession({
state,
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag: (_documentTarget, onMove, onUp) => {
handlers.move = onMove;
handlers.up = onUp;
return vi.fn();
},
updatePosition,
finalize,
});
expect(previousCleanup).toHaveBeenCalledOnce();
expect(stateRef.current).toBe(state);
expect(setState).toHaveBeenCalledWith(state);
handlers.move?.({ clientX: 44 } as MouseEvent);
expect(updatePosition).toHaveBeenCalledWith(44);
const preventDefault = vi.fn();
const attachedCleanup = cleanupRef.current;
handlers.up?.({ clientX: 65, preventDefault } as unknown as MouseEvent);
expect(attachedCleanup).toHaveBeenCalledOnce();
expect(cleanupRef.current).toBeNull();
expect(finalize).toHaveBeenCalledWith(65);
expect(preventDefault).toHaveBeenCalledOnce();
});
it("works when the session starts without a prior cleanup", () => {
const setState = vi.fn();
const updatePosition = vi.fn();
const finalize = vi.fn().mockResolvedValue(undefined);
const cleanupRef = { current: null as (() => void) | null };
const stateRef = { current: { allocationId: null, isActive: false } };
let upHandler: ((event: MouseEvent) => void) | undefined;
beginAllocationDragSession({
state: { allocationId: "alloc-2", isActive: true },
cleanupRef,
stateRef,
setState,
documentTarget: {} as Document,
attachDrag: (_documentTarget, _onMove, onUp) => {
upHandler = onUp;
return vi.fn();
},
updatePosition,
finalize,
});
upHandler?.({ clientX: 18, preventDefault() {} } as MouseEvent);
expect(updatePosition).not.toHaveBeenCalled();
expect(finalize).toHaveBeenCalledWith(18);
expect(cleanupRef.current).toBeNull();
});
});
@@ -0,0 +1,48 @@
type MutableCurrent<T> = {
current: T;
};
type AttachDocumentMouseDrag = (
documentTarget: Document,
onMove: (event: MouseEvent) => void,
onUp: (event: MouseEvent) => void,
) => () => void;
type BeginAllocationDragSessionParams<TState> = {
state: TState;
cleanupRef: MutableCurrent<(() => void) | null>;
stateRef: MutableCurrent<TState>;
setState: (state: TState) => void;
documentTarget: Document;
attachDrag: AttachDocumentMouseDrag;
updatePosition: (clientX: number) => void;
finalize: (clientX: number) => Promise<void> | void;
};
export function beginAllocationDragSession<TState>({
state,
cleanupRef,
stateRef,
setState,
documentTarget,
attachDrag,
updatePosition,
finalize,
}: BeginAllocationDragSessionParams<TState>) {
stateRef.current = state;
setState(state);
cleanupRef.current?.();
function handleMove(event: MouseEvent) {
updatePosition(event.clientX);
}
function handleUp(event: MouseEvent) {
cleanupRef.current?.();
cleanupRef.current = null;
void finalize(event.clientX);
event.preventDefault();
}
cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp);
}
+13 -16
View File
@@ -21,6 +21,7 @@ import {
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
@@ -629,20 +630,17 @@ export function useTimelineDrag({
endDate: opts.endDate,
startMouseX: e.clientX,
});
allocDragRef.current = initial;
setAllocDragState(initial);
setAllocationPreviewTarget(e.currentTarget, opts.mode);
allocDragCleanupRef.current?.();
// ── document handlers ────────────────────────────────────────────────
function handleMove(ev: MouseEvent) {
updateAllocationDragPosition(ev.clientX);
}
function handleUp(ev: MouseEvent) {
allocDragCleanupRef.current?.();
allocDragCleanupRef.current = null;
updateAllocationDragPosition(ev.clientX);
beginAllocationDragSession({
state: initial,
cleanupRef: allocDragCleanupRef,
stateRef: allocDragRef,
setState: setAllocDragState,
documentTarget: document,
attachDrag: attachDocumentMouseDrag,
updatePosition: updateAllocationDragPosition,
finalize: (clientX) => {
updateAllocationDragPosition(clientX);
const alloc = allocDragRef.current;
const release = resolveAllocationRelease(alloc, {
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
@@ -714,9 +712,8 @@ export function useTimelineDrag({
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
}
allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
},
});
},
[
clearPendingOptimisticAllocation,
+19
View File
@@ -340,6 +340,17 @@ export const rules = [
],
forbidden: [],
},
{
file: "apps/web/src/hooks/timelineAllocationDragSession.ts",
maxLines: 70,
required: [
{
pattern: /\bexport function beginAllocationDragSession\b/,
message: "timeline allocation drag session helpers must keep document drag lifecycle centralized",
},
],
forbidden: [],
},
{
file: "apps/web/src/hooks/timelineProjectDrag.ts",
maxLines: 80,
@@ -429,6 +440,10 @@ export const rules = [
pattern: /from "\.\/timelineAllocationDragState\.js"/,
message: "timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineAllocationDragSession\.js"/,
message: "timeline drag must keep allocation drag document session wiring 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",
@@ -483,6 +498,10 @@ export const rules = [
pattern: /\bfunction createAllocationDragState\b/,
message: "timeline drag must not re-inline extracted allocation drag bootstrap helpers",
},
{
pattern: /\bfunction handle(?:Move|Up)\b/,
message: "timeline drag must not re-inline extracted allocation drag session handlers",
},
{
pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/,
message: "timeline drag must not re-inline extracted project drag helper implementations",
@@ -88,6 +88,7 @@ describe("architecture guardrails", () => {
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 allocationDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragSession.ts");
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts");
@@ -108,6 +109,7 @@ describe("architecture guardrails", () => {
assert.ok(positionRule);
assert.ok(documentDragRule);
assert.ok(allocationDragStateRule);
assert.ok(allocationDragSessionRule);
assert.ok(projectDragRule);
assert.ok(projectDragSessionRule);
@@ -127,6 +129,7 @@ describe("architecture guardrails", () => {
"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 multi-drag document session wiring 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",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring 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: missing guardrail anchor: timeline drag must keep project drag document session wiring 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",
@@ -204,6 +207,10 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineAllocationDragState.ts: missing guardrail anchor: timeline allocation drag state helpers must keep drag bootstrap centralized",
]);
assert.deepEqual(evaluateRule(allocationDragSessionRule, ""), [
"apps/web/src/hooks/timelineAllocationDragSession.ts: missing guardrail anchor: timeline allocation drag session helpers must keep document drag lifecycle 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",
]);
@@ -232,6 +239,7 @@ describe("architecture guardrails", () => {
"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 multi-drag document session wiring 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",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag document session wiring 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: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",