refactor(web): extract allocation drag finalize helpers

This commit is contained in:
2026-04-01 09:57:29 +02:00
parent 54c6cf2e2d
commit 6dac993521
3 changed files with 237 additions and 23 deletions
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
buildAllocationMovedSnapshot,
hasAllocationDateChange,
requiresAllocationFragmentExtraction,
shouldTreatAllocationDragAsClick,
} from "./timelineAllocationFinalize.js";
const baseDates = {
allocationStartDate: new Date("2025-01-01T00:00:00.000Z"),
allocationEndDate: new Date("2025-01-05T00:00:00.000Z"),
originalStartDate: new Date("2025-01-01T00:00:00.000Z"),
originalEndDate: new Date("2025-01-05T00:00:00.000Z"),
currentStartDate: new Date("2025-01-01T00:00:00.000Z"),
currentEndDate: new Date("2025-01-05T00:00:00.000Z"),
} as const;
describe("timelineAllocationFinalize", () => {
it("treats missing dates as no change", () => {
expect(
hasAllocationDateChange({
mode: "move",
scope: "allocation",
allocationId: "alloc-1",
mutationAllocationId: null,
projectName: "Alpha",
allocationStartDate: null,
allocationEndDate: null,
originalStartDate: null,
originalEndDate: baseDates.originalEndDate,
currentStartDate: baseDates.currentStartDate,
currentEndDate: baseDates.currentEndDate,
pointerDeltaX: 0,
daysDelta: 0,
}),
).toBe(false);
});
it("detects start or end date changes", () => {
expect(
hasAllocationDateChange({
mode: "move",
scope: "allocation",
allocationId: "alloc-1",
mutationAllocationId: null,
projectName: "Alpha",
...baseDates,
currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
pointerDeltaX: 0,
daysDelta: 1,
}),
).toBe(true);
});
it("treats only small move drags without day changes as clicks", () => {
expect(
shouldTreatAllocationDragAsClick(
{ mode: "move", daysDelta: 0, pointerDeltaX: -5 },
5,
),
).toBe(true);
expect(
shouldTreatAllocationDragAsClick(
{ mode: "resize-end", daysDelta: 0, pointerDeltaX: 0 },
5,
),
).toBe(false);
expect(
shouldTreatAllocationDragAsClick(
{ mode: "move", daysDelta: 1, pointerDeltaX: 2 },
5,
),
).toBe(false);
});
it("requires extraction only for segment drags that no longer match allocation boundaries", () => {
expect(
requiresAllocationFragmentExtraction({
mode: "move",
scope: "segment",
allocationId: "alloc-1",
mutationAllocationId: null,
projectName: "Alpha",
...baseDates,
allocationStartDate: new Date("2024-12-31T00:00:00.000Z"),
pointerDeltaX: 0,
daysDelta: 1,
}),
).toBe(true);
expect(
requiresAllocationFragmentExtraction({
mode: "move",
scope: "allocation",
allocationId: "alloc-1",
mutationAllocationId: null,
projectName: "Alpha",
...baseDates,
pointerDeltaX: 0,
daysDelta: 1,
}),
).toBe(false);
});
it("returns null snapshots when required dates or ids are missing", () => {
expect(
buildAllocationMovedSnapshot({
mode: "move",
scope: "allocation",
allocationId: null,
mutationAllocationId: null,
projectName: "Alpha",
...baseDates,
pointerDeltaX: 0,
daysDelta: 1,
}),
).toBeNull();
});
it("builds a mutation snapshot with fallback mutation allocation ids", () => {
expect(
buildAllocationMovedSnapshot({
mode: "move",
scope: "allocation",
allocationId: "alloc-1",
mutationAllocationId: null,
projectName: null,
...baseDates,
currentStartDate: new Date("2025-01-02T00:00:00.000Z"),
currentEndDate: new Date("2025-01-06T00:00:00.000Z"),
pointerDeltaX: 32,
daysDelta: 1,
}),
).toEqual({
allocationId: "alloc-1",
mutationAllocationId: "alloc-1",
projectName: "",
before: {
startDate: new Date("2025-01-01T00:00:00.000Z"),
endDate: new Date("2025-01-05T00:00:00.000Z"),
},
after: {
startDate: new Date("2025-01-02T00:00:00.000Z"),
endDate: new Date("2025-01-06T00:00:00.000Z"),
},
});
});
});
@@ -0,0 +1,79 @@
import { datesMatch } from "./timelineLivePreview.js";
export type AllocationMovedSnapshotLike = {
allocationId: string;
mutationAllocationId: string;
projectName: string;
before: { startDate: Date; endDate: Date };
after: { startDate: Date; endDate: Date };
};
type AllocDragFinalizeLike = {
mode: "move" | "resize-start" | "resize-end";
scope: "allocation" | "segment";
allocationId: string | null;
mutationAllocationId: string | null;
projectName: string | null;
allocationStartDate: Date | null;
allocationEndDate: Date | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
pointerDeltaX: number;
daysDelta: number;
};
export function hasAllocationDateChange(alloc: AllocDragFinalizeLike): boolean {
if (!alloc.originalStartDate || !alloc.currentStartDate || !alloc.originalEndDate || !alloc.currentEndDate) {
return false;
}
return (
alloc.originalStartDate.getTime() !== alloc.currentStartDate.getTime() ||
alloc.originalEndDate.getTime() !== alloc.currentEndDate.getTime()
);
}
export function shouldTreatAllocationDragAsClick(
alloc: Pick<AllocDragFinalizeLike, "mode" | "daysDelta" | "pointerDeltaX">,
clickThresholdPx: number,
): boolean {
return alloc.mode === "move" && alloc.daysDelta === 0 && Math.abs(alloc.pointerDeltaX) <= clickThresholdPx;
}
export function requiresAllocationFragmentExtraction(alloc: AllocDragFinalizeLike): boolean {
return (
alloc.scope === "segment" &&
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
!datesMatch(alloc.originalEndDate, alloc.allocationEndDate))
);
}
export function buildAllocationMovedSnapshot(
alloc: AllocDragFinalizeLike,
): AllocationMovedSnapshotLike | null {
if (
!alloc.allocationId ||
!alloc.originalStartDate ||
!alloc.originalEndDate ||
!alloc.currentStartDate ||
!alloc.currentEndDate
) {
return null;
}
return {
allocationId: alloc.allocationId,
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
projectName: alloc.projectName ?? "",
before: {
startDate: alloc.originalStartDate,
endDate: alloc.originalEndDate,
},
after: {
startDate: alloc.currentStartDate,
endDate: alloc.currentEndDate,
},
};
}
+10 -23
View File
@@ -7,11 +7,16 @@ import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.j
import { import {
captureLivePreviewTargets, captureLivePreviewTargets,
clearLivePreview, clearLivePreview,
datesMatch,
preserveLivePreview, preserveLivePreview,
scheduleLivePreview, scheduleLivePreview,
type LivePreviewSession, type LivePreviewSession,
} from "./timelineLivePreview.js"; } from "./timelineLivePreview.js";
import {
buildAllocationMovedSnapshot,
hasAllocationDateChange,
requiresAllocationFragmentExtraction,
shouldTreatAllocationDragAsClick,
} from "./timelineAllocationFinalize.js";
import { import {
createMultiSelectState, createMultiSelectState,
finalizeMultiSelectDraft, finalizeMultiSelectDraft,
@@ -729,23 +734,14 @@ export function useTimelineDrag({
updateAllocationDragPosition(ev.clientX); updateAllocationDragPosition(ev.clientX);
const alloc = allocDragRef.current; const alloc = allocDragRef.current;
if (!alloc.isActive) return; if (!alloc.isActive) return;
const pointerDelta = Math.abs(alloc.pointerDeltaX); const hasDateChange = hasAllocationDateChange(alloc);
const hasDateChange =
Boolean(alloc.originalStartDate && alloc.currentStartDate && alloc.originalEndDate && alloc.currentEndDate) &&
(
alloc.originalStartDate!.getTime() !== alloc.currentStartDate!.getTime() ||
alloc.originalEndDate!.getTime() !== alloc.currentEndDate!.getTime()
);
if (hasDateChange) { if (hasDateChange) {
preserveLivePreview(allocPreviewRef.current); preserveLivePreview(allocPreviewRef.current);
} }
clearLivePreview(allocPreviewRef.current); clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = null; allocPreviewRef.current = null;
const shouldTreatAsClick = const shouldTreatAsClick = shouldTreatAllocationDragAsClick(alloc, DRAG_CLICK_THRESHOLD_PX);
alloc.mode === "move" &&
alloc.daysDelta === 0 &&
pointerDelta <= DRAG_CLICK_THRESHOLD_PX;
if (shouldTreatAsClick && alloc.allocationId) { if (shouldTreatAsClick && alloc.allocationId) {
// No movement → treat as click // No movement → treat as click
@@ -767,18 +763,9 @@ export function useTimelineDrag({
const currentStartDate = alloc.currentStartDate; const currentStartDate = alloc.currentStartDate;
const currentEndDate = alloc.currentEndDate; const currentEndDate = alloc.currentEndDate;
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId; const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId;
const requiresExtraction = const requiresExtraction = requiresAllocationFragmentExtraction(alloc);
alloc.scope === "segment" &&
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
!datesMatch(alloc.originalEndDate, alloc.allocationEndDate));
pendingSnapshotRef.current = { pendingSnapshotRef.current = buildAllocationMovedSnapshot(alloc);
allocationId: activeAllocationId,
mutationAllocationId: baseMutationAllocationId,
projectName: alloc.projectName ?? "",
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
after: { startDate: currentStartDate, endDate: currentEndDate },
};
pendingOptimisticAllocationIdRef.current = activeAllocationId; pendingOptimisticAllocationIdRef.current = activeAllocationId;
setOptimisticAllocations((prev) => { setOptimisticAllocations((prev) => {
const next = new Map(prev); const next = new Map(prev);