From d4652b7a42f193c76168fc51818e1a2b1b76a126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 14:57:56 +0200 Subject: [PATCH] fix(timeline): cancel stranded drag interactions --- apps/web/e2e/timeline.spec.ts | 55 +++++++++++ .../web/src/hooks/timelineDragCleanup.test.ts | 93 ++++++++++++++++++- apps/web/src/hooks/timelineDragCleanup.ts | 38 ++++++++ apps/web/src/hooks/useTimelineDrag.ts | 56 ++++++++++- docs/showcase-execution-batches.md | 1 + 5 files changed, 240 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index a77d1a1..04c12f2 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -1303,6 +1303,61 @@ test.describe("Timeline", () => { expect(result.rightEdgeGain).toBeGreaterThan(48); }); + test("allocation resize cancels cleanly when the window loses focus mid-drag", async ({ + page, + }) => { + await page.goto("/timeline?startDate=2026-04-01&days=31", { + waitUntil: "domcontentloaded", + }); + await expect( + page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), + ).toBeVisible(); + + const allocationSegment = await findVisibleAllocationSegmentForResize( + page, + "[data-timeline-entry-type='allocation'][data-allocation-id]", + ); + expect(allocationSegment).toBeTruthy(); + + const allocation = page.locator(allocationSegmentSelector(allocationSegment!)); + await allocation.scrollIntoViewIfNeeded(); + + const initialBox = await readBoundingBox(allocation); + const pointerY = initialBox.y + initialBox.height / 2; + const startX = initialBox.x + initialBox.width - 3; + + await page.mouse.move(startX, pointerY); + await page.mouse.down(); + await page.mouse.move(startX + 72, pointerY, { steps: 6 }); + + await expect + .poll(async () => { + const box = await allocation.boundingBox(); + return box ? Math.round(box.width) : null; + }) + .toBeGreaterThan(Math.round(initialBox.width + 36)); + + await page.evaluate(() => { + window.dispatchEvent(new Event("blur")); + }); + + await expect + .poll(async () => { + const box = await allocation.boundingBox(); + return box ? Math.round(box.width) : null; + }) + .toBe(Math.round(initialBox.width)); + + await page.mouse.up(); + + const secondResize = await measureAllocationResizeGap( + page, + allocationSegmentSelector(allocationSegment!), + ); + expect(secondResize.widthGain).toBeGreaterThan(64); + expect(secondResize.rightEdgeGain).toBeGreaterThan(48); + }); + test("allocation start resize shows a live preview before mouseup", async ({ page, }) => { diff --git a/apps/web/src/hooks/timelineDragCleanup.test.ts b/apps/web/src/hooks/timelineDragCleanup.test.ts index 9aa0cf9..3c2aba1 100644 --- a/apps/web/src/hooks/timelineDragCleanup.test.ts +++ b/apps/web/src/hooks/timelineDragCleanup.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it, vi } from "vitest"; -import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; +import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js"; + +type MultiSelectStateFixture = { + isSelecting: boolean; + startX: number; + startY: number; + currentX: number; + currentY: number; + selectedAllocationIds: string[]; + selectedResourceIds: string[]; + dateRange: { start: Date; end: Date } | null; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: string; +}; describe("timelineDragCleanup", () => { it("runs registered cleanup callbacks, clears previews, and resets refs", () => { @@ -82,4 +96,81 @@ describe("timelineDragCleanup", () => { expect(clearPreview).toHaveBeenNthCalledWith(1, null); expect(clearPreview).toHaveBeenNthCalledWith(2, null); }); + + it("keeps completed multi-selection while cancelling transient drag state", () => { + const initialState: MultiSelectStateFixture = { + isSelecting: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }; + + const nextState = cancelTransientMultiSelectState( + { + isSelecting: false, + startX: 140, + startY: 32, + currentX: 244, + currentY: 96, + selectedAllocationIds: ["alloc-1", "alloc-2"], + selectedResourceIds: ["res-1"], + dateRange: { start: new Date("2026-04-02"), end: new Date("2026-04-05") }, + multiDragDaysDelta: 3, + isMultiDragging: true, + multiDragMode: "resize-end", + }, + initialState, + ); + + expect(nextState).toEqual({ + isSelecting: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + selectedAllocationIds: ["alloc-1", "alloc-2"], + selectedResourceIds: ["res-1"], + dateRange: { start: new Date("2026-04-02"), end: new Date("2026-04-05") }, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }); + }); + + it("drops incomplete marquee state when nothing was selected yet", () => { + const initialState: MultiSelectStateFixture = { + isSelecting: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + selectedAllocationIds: [], + selectedResourceIds: [], + dateRange: null, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: "move", + }; + + const nextState = cancelTransientMultiSelectState( + { + ...initialState, + isSelecting: true, + startX: 180, + startY: 24, + currentX: 220, + currentY: 68, + }, + initialState, + ); + + expect(nextState).toBe(initialState); + }); }); diff --git a/apps/web/src/hooks/timelineDragCleanup.ts b/apps/web/src/hooks/timelineDragCleanup.ts index ed08d87..4a1a287 100644 --- a/apps/web/src/hooks/timelineDragCleanup.ts +++ b/apps/web/src/hooks/timelineDragCleanup.ts @@ -2,6 +2,20 @@ type MutableCurrent = { current: T; }; +type MultiSelectInteractionStateLike = { + isSelecting: boolean; + startX: number; + startY: number; + currentX: number; + currentY: number; + selectedAllocationIds: string[]; + selectedResourceIds: string[]; + dateRange: { start: Date; end: Date } | null; + multiDragDaysDelta: number; + isMultiDragging: boolean; + multiDragMode: string; +}; + type TimelineDragCleanupParams< DragState, AllocDragState, @@ -72,3 +86,27 @@ export function cleanupTimelineDragState< rangeStateRef.current = initialRangeState; multiSelectRef.current = initialMultiSelectState; } + +export function cancelTransientMultiSelectState( + state: TState, + initialState: TState, +): TState { + const hasCommittedSelection = + state.selectedAllocationIds.length > 0 || state.selectedResourceIds.length > 0 || state.dateRange !== null; + + if (!hasCommittedSelection) { + return initialState; + } + + return { + ...state, + isSelecting: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + multiDragDaysDelta: 0, + isMultiDragging: false, + multiDragMode: initialState.multiDragMode, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index a0db405..d7524f9 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -20,7 +20,7 @@ import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSe import { createAllocationDragState } from "./timelineAllocationDragState.js"; import { beginAllocationDragSession } from "./timelineAllocationDragSession.js"; import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js"; -import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; +import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js"; @@ -350,6 +350,43 @@ export function useTimelineDrag({ setDragState(INITIAL_DRAG_STATE); }, []); + const cancelActiveInteractions = useCallback(() => { + const hasActiveInteraction = + dragStateRef.current.isDragging || + allocDragRef.current.isActive || + rangeStateRef.current.isSelecting || + multiSelectRef.current.isSelecting || + multiSelectRef.current.isMultiDragging; + if (!hasActiveInteraction) return; + + const nextMultiSelectState = cancelTransientMultiSelectState( + multiSelectRef.current, + INITIAL_MULTI_SELECT, + ); + + cleanupTimelineDragState({ + projectDragCleanupRef, + allocDragCleanupRef, + multiSelectCleanupRef, + projectPreviewRef, + allocPreviewRef, + dragStateRef, + allocDragRef, + rangeStateRef, + multiSelectRef, + initialDragState: INITIAL_DRAG_STATE, + initialAllocDragState: INITIAL_ALLOC_DRAG, + initialRangeState: INITIAL_RANGE_STATE, + initialMultiSelectState: nextMultiSelectState, + clearPreview: clearLivePreview, + }); + + setDragState(INITIAL_DRAG_STATE); + setAllocDragState(INITIAL_ALLOC_DRAG); + setRangeState(INITIAL_RANGE_STATE); + setMultiSelectState(nextMultiSelectState); + }, []); + // Project-shift preview const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery( { @@ -831,7 +868,22 @@ export function useTimelineDrag({ ); useEffect(() => { + function handleWindowBlur() { + cancelActiveInteractions(); + } + + function handleVisibilityChange() { + if (document.visibilityState === "hidden") { + cancelActiveInteractions(); + } + } + + window.addEventListener("blur", handleWindowBlur); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("blur", handleWindowBlur); + document.removeEventListener("visibilitychange", handleVisibilityChange); cleanupTimelineDragState({ projectDragCleanupRef, allocDragCleanupRef, @@ -849,7 +901,7 @@ export function useTimelineDrag({ clearPreview: clearLivePreview, }); }; - }, []); + }, [cancelActiveInteractions]); // ── Derived ───────────────────────────────────────────────────────────────── diff --git a/docs/showcase-execution-batches.md b/docs/showcase-execution-batches.md index f76c66f..13235f6 100644 --- a/docs/showcase-execution-batches.md +++ b/docs/showcase-execution-batches.md @@ -27,6 +27,7 @@ the timeline is the highest-risk UX surface and still not boringly reliable enou Progress note: - 2026-04-01: overlay cleanup on timeline `viewMode` changes and initial-loading transitions landed with targeted e2e regression coverage for allocation popovers across view switches. - 2026-04-01: viewport-change behavior was tightened so point-anchored timeline popovers close on scroll/resize, while element-anchored hover cards remain repositionable; the viewport regression now passes with a non-happy-path e2e. +- 2026-04-01: active timeline gestures now cancel on window blur / hidden-tab transitions instead of leaving drag or resize state stranded; regression coverage verifies a mid-resize focus loss reverts the preview and allows the next interaction to proceed cleanly. Slices: