refactor(web): extract project drag helpers

This commit is contained in:
2026-04-01 10:06:32 +02:00
parent c32f56ba89
commit 892a9c5ccf
5 changed files with 200 additions and 32 deletions
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
type TestProjectDragState = {
isDragging: boolean;
projectId: string | null;
projectName: string | null;
allocationId: string | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
originalLeft: number;
blockWidth: number;
daysDelta: number;
};
describe("timelineProjectDrag", () => {
it("creates project-bar drag state with default legacy metrics cleared", () => {
expect(
createProjectDragState<TestProjectDragState>({
projectId: "project-1",
projectName: "Alpha",
startDate: new Date("2025-01-10T00:00:00.000Z"),
endDate: new Date("2025-01-20T00:00:00.000Z"),
startMouseX: 240,
}),
).toEqual({
isDragging: true,
projectId: "project-1",
projectName: "Alpha",
allocationId: null,
originalStartDate: new Date("2025-01-10T00:00:00.000Z"),
originalEndDate: new Date("2025-01-20T00:00:00.000Z"),
currentStartDate: new Date("2025-01-10T00:00:00.000Z"),
currentEndDate: new Date("2025-01-20T00:00:00.000Z"),
startMouseX: 240,
pointerDeltaX: 0,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
});
});
it("preserves allocation metadata and block geometry for legacy allocation-block drags", () => {
expect(
createProjectDragState<TestProjectDragState>({
projectId: "project-1",
projectName: "Alpha",
allocationId: "alloc-7",
startDate: new Date("2025-01-10T00:00:00.000Z"),
endDate: new Date("2025-01-20T00:00:00.000Z"),
startMouseX: 320,
originalLeft: 144,
blockWidth: 96,
}),
).toMatchObject({
allocationId: "alloc-7",
originalLeft: 144,
blockWidth: 96,
});
});
it("refuses to build mutation input for no-op drags or incomplete state", () => {
expect(
buildProjectShiftMutationInput({
projectId: "project-1",
currentStartDate: new Date("2025-01-10T00:00:00.000Z"),
currentEndDate: new Date("2025-01-20T00:00:00.000Z"),
daysDelta: 0,
}),
).toBeNull();
expect(
buildProjectShiftMutationInput({
projectId: null,
currentStartDate: new Date("2025-01-10T00:00:00.000Z"),
currentEndDate: new Date("2025-01-20T00:00:00.000Z"),
daysDelta: 2,
}),
).toBeNull();
});
it("builds mutation input only when a real drag produced complete dates", () => {
expect(
buildProjectShiftMutationInput({
projectId: "project-1",
currentStartDate: new Date("2025-01-12T00:00:00.000Z"),
currentEndDate: new Date("2025-01-22T00:00:00.000Z"),
daysDelta: 2,
}),
).toEqual({
projectId: "project-1",
newStartDate: new Date("2025-01-12T00:00:00.000Z"),
newEndDate: new Date("2025-01-22T00:00:00.000Z"),
});
});
});
+60
View File
@@ -0,0 +1,60 @@
type ProjectDragStateLike = {
isDragging: boolean;
projectId: string | null;
projectName: string | null;
allocationId: string | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
pointerDeltaX: number;
originalLeft: number;
blockWidth: number;
daysDelta: number;
};
type CreateProjectDragStateInput = {
projectId: string;
projectName: string;
allocationId?: string | null;
startDate: Date;
endDate: Date;
startMouseX: number;
originalLeft?: number;
blockWidth?: number;
};
export function createProjectDragState<TState extends ProjectDragStateLike>(
input: CreateProjectDragStateInput,
): TState {
return {
isDragging: true,
projectId: input.projectId,
projectName: input.projectName,
allocationId: input.allocationId ?? null,
originalStartDate: input.startDate,
originalEndDate: input.endDate,
currentStartDate: input.startDate,
currentEndDate: input.endDate,
startMouseX: input.startMouseX,
pointerDeltaX: 0,
originalLeft: input.originalLeft ?? 0,
blockWidth: input.blockWidth ?? 0,
daysDelta: 0,
} as TState;
}
export function buildProjectShiftMutationInput(
drag: Pick<ProjectDragStateLike, "daysDelta" | "projectId" | "currentStartDate" | "currentEndDate">,
): { projectId: string; newStartDate: Date; newEndDate: Date } | null {
if (drag.daysDelta === 0 || !drag.projectId || !drag.currentStartDate || !drag.currentEndDate) {
return null;
}
return {
projectId: drag.projectId,
newStartDate: drag.currentStartDate,
newEndDate: drag.currentEndDate,
};
}
+10 -32
View File
@@ -23,6 +23,7 @@ import {
startAllocationMultiDrag, startAllocationMultiDrag,
updateAllocationMultiDrag, updateAllocationMultiDrag,
} from "./timelineAllocationMultiDrag.js"; } from "./timelineAllocationMultiDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { import {
createMultiSelectState, createMultiSelectState,
finalizeMultiSelectDraft, finalizeMultiSelectDraft,
@@ -448,17 +449,7 @@ export function useTimelineDrag({
const finalDrag = dragStateRef.current; const finalDrag = dragStateRef.current;
if (!finalDrag.isDragging) return null; if (!finalDrag.isDragging) return null;
const mutationInput = const mutationInput = buildProjectShiftMutationInput(finalDrag);
finalDrag.daysDelta !== 0 &&
finalDrag.projectId &&
finalDrag.currentStartDate &&
finalDrag.currentEndDate
? {
projectId: finalDrag.projectId,
newStartDate: finalDrag.currentStartDate,
newEndDate: finalDrag.currentEndDate,
}
: null;
if (finalDrag.daysDelta !== 0) { if (finalDrag.daysDelta !== 0) {
preserveLivePreview(projectPreviewRef.current); preserveLivePreview(projectPreviewRef.current);
@@ -543,21 +534,13 @@ export function useTimelineDrag({
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const state: DragState = { const state = createProjectDragState<DragState>({
isDragging: true,
projectId: opts.projectId, projectId: opts.projectId,
projectName: opts.projectName, projectName: opts.projectName,
allocationId: null, startDate: opts.startDate,
originalStartDate: opts.startDate, endDate: opts.endDate,
originalEndDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX, startMouseX: e.clientX,
pointerDeltaX: 0, });
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
};
dragStateRef.current = state; dragStateRef.current = state;
setDragState(state); setDragState(state);
@@ -602,21 +585,16 @@ export function useTimelineDrag({
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const state: DragState = { const state = createProjectDragState<DragState>({
isDragging: true,
projectId: opts.projectId, projectId: opts.projectId,
projectName: opts.projectName, projectName: opts.projectName,
allocationId: opts.allocationId ?? null, allocationId: opts.allocationId ?? null,
originalStartDate: opts.startDate, startDate: opts.startDate,
originalEndDate: opts.endDate, endDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX, startMouseX: e.clientX,
pointerDeltaX: 0,
originalLeft: opts.blockLeft, originalLeft: opts.blockLeft,
blockWidth: opts.blockWidth, blockWidth: opts.blockWidth,
daysDelta: 0, });
};
dragStateRef.current = state; dragStateRef.current = state;
setDragState(state); setDragState(state);
}, },
+23
View File
@@ -217,6 +217,21 @@ export const rules = [
], ],
forbidden: [], forbidden: [],
}, },
{
file: "apps/web/src/hooks/timelineProjectDrag.ts",
maxLines: 80,
required: [
{
pattern: /\bexport function createProjectDragState\b/,
message: "timeline project drag helpers must keep drag-state bootstrap centralized",
},
{
pattern: /\bexport function buildProjectShiftMutationInput\b/,
message: "timeline project drag helpers must keep no-op project-shift mutation gating centralized",
},
],
forbidden: [],
},
{ {
file: "apps/web/src/hooks/useTimelineDrag.ts", file: "apps/web/src/hooks/useTimelineDrag.ts",
required: [ required: [
@@ -248,6 +263,10 @@ export const rules = [
pattern: /from "\.\/timelineAllocationMultiDrag\.js"/, pattern: /from "\.\/timelineAllocationMultiDrag\.js"/,
message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", message: "timeline drag must keep allocation multi-drag rules 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",
},
], ],
forbidden: [ forbidden: [
{ {
@@ -266,6 +285,10 @@ export const rules = [
pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/, pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/,
message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations", message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations",
}, },
{
pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/,
message: "timeline drag must not re-inline extracted project drag helper implementations",
},
], ],
}, },
{ {
@@ -77,6 +77,7 @@ describe("architecture guardrails", () => {
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");
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
assert.ok(dragRule); assert.ok(dragRule);
assert.ok(livePreviewRule); assert.ok(livePreviewRule);
@@ -86,6 +87,7 @@ describe("architecture guardrails", () => {
assert.ok(optimisticRule); assert.ok(optimisticRule);
assert.ok(allocationFinalizeRule); assert.ok(allocationFinalizeRule);
assert.ok(allocationMultiDragRule); assert.ok(allocationMultiDragRule);
assert.ok(projectDragRule);
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [ assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
@@ -95,6 +97,7 @@ describe("architecture guardrails", () => {
"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 allocation drag completion rules 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 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 project drag bootstrap and mutation gating 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",
]); ]);
@@ -129,5 +132,9 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep same-day delta suppression centralized", "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep same-day delta suppression centralized",
"apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", "apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior 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",
]);
}); });
}); });