diff --git a/apps/web/src/hooks/timelineAllocationFinalize.test.ts b/apps/web/src/hooks/timelineAllocationFinalize.test.ts new file mode 100644 index 0000000..4d9e186 --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationFinalize.test.ts @@ -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"), + }, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineAllocationFinalize.ts b/apps/web/src/hooks/timelineAllocationFinalize.ts new file mode 100644 index 0000000..3e567af --- /dev/null +++ b/apps/web/src/hooks/timelineAllocationFinalize.ts @@ -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, + 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, + }, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index d85ab6e..736a060 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -7,11 +7,16 @@ import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.j import { captureLivePreviewTargets, clearLivePreview, - datesMatch, preserveLivePreview, scheduleLivePreview, type LivePreviewSession, } from "./timelineLivePreview.js"; +import { + buildAllocationMovedSnapshot, + hasAllocationDateChange, + requiresAllocationFragmentExtraction, + shouldTreatAllocationDragAsClick, +} from "./timelineAllocationFinalize.js"; import { createMultiSelectState, finalizeMultiSelectDraft, @@ -729,23 +734,14 @@ export function useTimelineDrag({ updateAllocationDragPosition(ev.clientX); const alloc = allocDragRef.current; if (!alloc.isActive) return; - const pointerDelta = Math.abs(alloc.pointerDeltaX); - const hasDateChange = - Boolean(alloc.originalStartDate && alloc.currentStartDate && alloc.originalEndDate && alloc.currentEndDate) && - ( - alloc.originalStartDate!.getTime() !== alloc.currentStartDate!.getTime() || - alloc.originalEndDate!.getTime() !== alloc.currentEndDate!.getTime() - ); + const hasDateChange = hasAllocationDateChange(alloc); if (hasDateChange) { preserveLivePreview(allocPreviewRef.current); } clearLivePreview(allocPreviewRef.current); allocPreviewRef.current = null; - const shouldTreatAsClick = - alloc.mode === "move" && - alloc.daysDelta === 0 && - pointerDelta <= DRAG_CLICK_THRESHOLD_PX; + const shouldTreatAsClick = shouldTreatAllocationDragAsClick(alloc, DRAG_CLICK_THRESHOLD_PX); if (shouldTreatAsClick && alloc.allocationId) { // No movement → treat as click @@ -767,18 +763,9 @@ export function useTimelineDrag({ const currentStartDate = alloc.currentStartDate; const currentEndDate = alloc.currentEndDate; const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId; - const requiresExtraction = - alloc.scope === "segment" && - (!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) || - !datesMatch(alloc.originalEndDate, alloc.allocationEndDate)); + const requiresExtraction = requiresAllocationFragmentExtraction(alloc); - pendingSnapshotRef.current = { - allocationId: activeAllocationId, - mutationAllocationId: baseMutationAllocationId, - projectName: alloc.projectName ?? "", - before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! }, - after: { startDate: currentStartDate, endDate: currentEndDate }, - }; + pendingSnapshotRef.current = buildAllocationMovedSnapshot(alloc); pendingOptimisticAllocationIdRef.current = activeAllocationId; setOptimisticAllocations((prev) => { const next = new Map(prev);