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);
}
+75 -78
View File
@@ -21,6 +21,7 @@ import {
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js"; import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
@@ -629,94 +630,90 @@ export function useTimelineDrag({
endDate: opts.endDate, endDate: opts.endDate,
startMouseX: e.clientX, startMouseX: e.clientX,
}); });
allocDragRef.current = initial;
setAllocDragState(initial);
setAllocationPreviewTarget(e.currentTarget, opts.mode); setAllocationPreviewTarget(e.currentTarget, opts.mode);
allocDragCleanupRef.current?.(); beginAllocationDragSession({
state: initial,
// ── document handlers ──────────────────────────────────────────────── cleanupRef: allocDragCleanupRef,
function handleMove(ev: MouseEvent) { stateRef: allocDragRef,
updateAllocationDragPosition(ev.clientX); setState: setAllocDragState,
} documentTarget: document,
attachDrag: attachDocumentMouseDrag,
function handleUp(ev: MouseEvent) { updatePosition: updateAllocationDragPosition,
allocDragCleanupRef.current?.(); finalize: (clientX) => {
allocDragCleanupRef.current = null; updateAllocationDragPosition(clientX);
updateAllocationDragPosition(ev.clientX); const alloc = allocDragRef.current;
const alloc = allocDragRef.current; const release = resolveAllocationRelease(alloc, {
const release = resolveAllocationRelease(alloc, { clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX, wasShift,
wasShift,
});
if (release.kind === "ignore") return;
if (release.preservePreview) {
preserveLivePreview(allocPreviewRef.current);
}
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = null;
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,
currentEndDate,
baseMutationAllocationId,
requiresExtraction,
pendingSnapshot,
} = mutationPlan;
pendingSnapshotRef.current = pendingSnapshot;
pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => {
const next = new Map(prev);
next.set(activeAllocationId, {
startDate: currentStartDate,
endDate: currentEndDate,
});
return next;
}); });
void (async () => { if (release.kind === "ignore") return;
try {
let mutationAllocationId = baseMutationAllocationId;
if (requiresExtraction) { if (release.preservePreview) {
const extracted = await extractAllocFragmentMutation.mutateAsync({ preserveLivePreview(allocPreviewRef.current);
allocationId: mutationAllocationId, }
startDate: alloc.originalStartDate!, clearLivePreview(allocPreviewRef.current);
endDate: alloc.originalEndDate!, allocPreviewRef.current = null;
});
mutationAllocationId = extracted.extractedAllocationId;
}
pendingSnapshotRef.current = pendingSnapshotRef.current if (release.kind === "shift-click") {
? { onShiftClickAllocRef.current?.(release.allocationId);
...pendingSnapshotRef.current, } else if (release.kind === "click") {
mutationAllocationId, onBlockClickRef.current?.(release.clickInfo);
} } else if (release.kind === "mutation") {
: null; const { mutationPlan } = release;
const {
activeAllocationId,
currentStartDate,
currentEndDate,
baseMutationAllocationId,
requiresExtraction,
pendingSnapshot,
} = mutationPlan;
updateAllocMutation.mutate({ pendingSnapshotRef.current = pendingSnapshot;
allocationId: mutationAllocationId, pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => {
const next = new Map(prev);
next.set(activeAllocationId, {
startDate: currentStartDate, startDate: currentStartDate,
endDate: currentEndDate, endDate: currentEndDate,
}); });
} catch { return next;
clearPendingOptimisticAllocation(activeAllocationId); });
} void (async () => {
})(); try {
} let mutationAllocationId = baseMutationAllocationId;
allocDragRef.current = INITIAL_ALLOC_DRAG; if (requiresExtraction) {
setAllocDragState(INITIAL_ALLOC_DRAG); const extracted = await extractAllocFragmentMutation.mutateAsync({
} allocationId: mutationAllocationId,
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
mutationAllocationId = extracted.extractedAllocationId;
}
allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp); pendingSnapshotRef.current = pendingSnapshotRef.current
? {
...pendingSnapshotRef.current,
mutationAllocationId,
}
: null;
updateAllocMutation.mutate({
allocationId: mutationAllocationId,
startDate: currentStartDate,
endDate: currentEndDate,
});
} catch {
clearPendingOptimisticAllocation(activeAllocationId);
}
})();
}
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
},
});
}, },
[ [
clearPendingOptimisticAllocation, clearPendingOptimisticAllocation,
+19
View File
@@ -340,6 +340,17 @@ export const rules = [
], ],
forbidden: [], 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", file: "apps/web/src/hooks/timelineProjectDrag.ts",
maxLines: 80, maxLines: 80,
@@ -429,6 +440,10 @@ export const rules = [
pattern: /from "\.\/timelineAllocationDragState\.js"/, pattern: /from "\.\/timelineAllocationDragState\.js"/,
message: "timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", 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"/, pattern: /from "\.\/timelineProjectDrag\.js"/,
message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", 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/, pattern: /\bfunction createAllocationDragState\b/,
message: "timeline drag must not re-inline extracted allocation drag bootstrap helpers", 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/, pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/,
message: "timeline drag must not re-inline extracted project drag helper implementations", 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 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 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 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 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"); 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(positionRule);
assert.ok(documentDragRule); assert.ok(documentDragRule);
assert.ok(allocationDragStateRule); assert.ok(allocationDragStateRule);
assert.ok(allocationDragSessionRule);
assert.ok(projectDragRule); assert.ok(projectDragRule);
assert.ok(projectDragSessionRule); 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 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 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 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 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: 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", "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", "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"), [ 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", "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 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 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 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 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: 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", "apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",