diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts index ad9629a86..5c65ef79b 100644 --- a/hooks/__tests__/use-time-grid-interactions.test.ts +++ b/hooks/__tests__/use-time-grid-interactions.test.ts @@ -82,6 +82,8 @@ describe("useTimeGridInteractions slot clicks (#435)", () => { act(() => result.current.handleGridPointerDown(pointer(160), "2026-09-03", day)); act(() => result.current.handleGridPointerMove(pointer(400))); expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 780 }); + act(() => result.current.handleGridPointerMove(pointer(2000))); + expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 1200 }); act(() => result.current.handleGridPointerUp(pointer(2000))); const [start, end] = onCreateRange.mock.calls[0]; @@ -89,6 +91,61 @@ describe("useTimeGridInteractions slot clicks (#435)", () => { expect(end).toEqual(new Date(2026, 8, 3, 20, 0, 0, 0)); }); + // The selection said 11:15 – 16:15 and the event came out shorter: the + // range drawn, its label and the event created must be the same. + describe("drag-to-create creates what the selection shows", () => { + const day = new Date(2026, 10, 2); + let top = 0; + const column = { + getBoundingClientRect: () => ({ top }), + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn(), + }; + const pointer = (clientY: number) => ({ + pointerType: "mouse", button: 0, pointerId: 1, clientY, + target: { closest: () => null }, currentTarget: column, + }) as unknown as React.PointerEvent; + beforeEach(() => { top = 0; }); + + it("commits the range last drawn, not one measured again on release", () => { + const { result, onCreateRange } = renderInteractions(); + act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day)); + act(() => result.current.handleGridPointerMove(pointer(975))); + expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 975 }); + // The release reports a slightly different spot (a trackpad lifting). + act(() => result.current.handleGridPointerUp(pointer(948))); + + const [start, end] = onCreateRange.mock.calls[0]; + expect(start).toEqual(new Date(2026, 10, 2, 11, 15)); + expect(end).toEqual(new Date(2026, 10, 2, 16, 15)); + }); + + it("follows the grid when it scrolls during the drag, and creates what it then shows", () => { + const { result, onCreateRange } = renderInteractions(); + act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day)); + act(() => result.current.handleGridPointerMove(pointer(975))); + // The wheel scrolls the grid up by an hour under a still pointer. + top = -60; + act(() => { window.dispatchEvent(new Event("scroll")); }); + expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 1035 }); + act(() => result.current.handleGridPointerUp(pointer(975))); + + const [start, end] = onCreateRange.mock.calls[0]; + expect(start).toEqual(new Date(2026, 10, 2, 11, 15)); + expect(end).toEqual(new Date(2026, 10, 2, 17, 15)); + }); + + it("creates nothing when the browser takes the pointer over", () => { + const { result, onCreateRange } = renderInteractions(); + act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day)); + act(() => result.current.handleGridPointerMove(pointer(975))); + act(() => result.current.handleGridPointerCancel()); + expect(result.current.dragCreate).toBeNull(); + act(() => result.current.handleGridPointerUp(pointer(975))); + expect(onCreateRange).not.toHaveBeenCalled(); + }); + }); + it("a 23:00 click ends at midnight of the next day", () => { const { result, onCreateRange } = renderInteractions(); const day = new Date(2026, 8, 3); diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index 9c48fad53..8e7acca45 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react"; +import { useState, useCallback, useEffect, useRef, type PointerEvent, type DragEvent } from "react"; import { format } from "date-fns"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -72,6 +72,17 @@ interface UseTimeGridInteractionsOptions { onMoveEvent?: MoveEventHandler; } +/** + * The range a drag from `anchorMinutes` to `currentMinutes` (both snapped) + * selects, or null when it is shorter than a quarter of an hour. The + * drawn selection, its label and the event it creates all come from this. + */ +export function dragCreateRange(anchorMinutes: number, currentMinutes: number): { startMinutes: number; endMinutes: number } | null { + const startMinutes = Math.min(anchorMinutes, currentMinutes); + const endMinutes = Math.max(anchorMinutes, currentMinutes); + return endMinutes - startMinutes >= 15 ? { startMinutes, endMinutes } : null; +} + /** `date` moved by `minutes` on the local clock, not by elapsed time. */ function addWallClockMinutes(date: Date, minutes: number): Date { const shifted = new Date(date); @@ -101,6 +112,9 @@ export function useTimeGridInteractions({ const wasDragging = useRef(false); // --- Drag-to-create --- + // The range on screen is the range created: the release commits the + // selection last drawn rather than measuring the pointer again, and a + // scroll during the drag redraws it under the pointer. const dragRef = useRef<{ dayKey: string; dayDate: Date; @@ -108,10 +122,31 @@ export function useTimeGridInteractions({ pointerId: number; startY: number; captured: boolean; + column: HTMLElement; + lastY: number; + range: { startMinutes: number; endMinutes: number } | null; + stopScrollWatch?: () => void; } | null>(null); const [dragCreate, setDragCreate] = useState(null); + const updateDragRange = useCallback((clientY: number) => { + const drag = dragRef.current; + if (!drag) return; + drag.lastY = clientY; + const range = dragCreateRange(drag.startMinutes, snapToMinutes(clientY, drag.column.getBoundingClientRect().top)); + drag.range = range; + setDragCreate(range ? { dayKey: drag.dayKey, ...range } : null); + }, [snapToMinutes]); + + const endDrag = useCallback(() => { + const drag = dragRef.current; + dragRef.current = null; + drag?.stopScrollWatch?.(); + setDragCreate(null); + return drag; + }, []); + const handleGridPointerDown = useCallback(( e: PointerEvent, dayKey: string, @@ -125,34 +160,34 @@ export function useTimeGridInteractions({ const rect = e.currentTarget.getBoundingClientRect(); const minutes = snapToMinutes(e.clientY, rect.top); + dragRef.current?.stopScrollWatch?.(); dragRef.current = { dayKey, dayDate, startMinutes: minutes, pointerId: e.pointerId, startY: e.clientY, captured: false, + column: e.currentTarget, lastY: e.clientY, range: null, }; }, [snapToMinutes, isMobile]); const handleGridPointerMove = useCallback((e: PointerEvent) => { - if (!dragRef.current) return; + const drag = dragRef.current; + if (!drag) return; - if (!dragRef.current.captured) { - if (Math.abs(e.clientY - dragRef.current.startY) < 5) return; - dragRef.current.captured = true; - e.currentTarget.setPointerCapture(dragRef.current.pointerId); + if (!drag.captured) { + if (Math.abs(e.clientY - drag.startY) < 5) return; + drag.captured = true; + e.currentTarget.setPointerCapture(drag.pointerId); + // The wheel or the browser's autoscroll moves the grid under a + // pointer that stands still: follow it. + const onScroll = () => updateDragRange(drag.lastY); + window.addEventListener("scroll", onScroll, true); + drag.stopScrollWatch = () => window.removeEventListener("scroll", onScroll, true); } - const rect = e.currentTarget.getBoundingClientRect(); - const currentMinutes = snapToMinutes(e.clientY, rect.top); - const start = Math.min(dragRef.current.startMinutes, currentMinutes); - const end = Math.max(dragRef.current.startMinutes, currentMinutes); - - setDragCreate(end > start ? { dayKey: dragRef.current.dayKey, startMinutes: start, endMinutes: end } : null); - }, [snapToMinutes]); + updateDragRange(e.clientY); + }, [updateDragRange]); const handleGridPointerUp = useCallback((e: PointerEvent) => { - const drag = dragRef.current; - dragRef.current = null; - setDragCreate(null); - + const drag = endDrag(); if (!drag || !drag.captured) return; wasDragging.current = true; @@ -160,19 +195,22 @@ export function useTimeGridInteractions({ try { e.currentTarget.releasePointerCapture(drag.pointerId); } catch { /* may already be released */ } - const rect = e.currentTarget.getBoundingClientRect(); - const endMinutes = snapToMinutes(e.clientY, rect.top); - const start = Math.min(drag.startMinutes, endMinutes); - const end = Math.max(drag.startMinutes, endMinutes); - - if (end - start < 15) return; + const range = drag.range; + if (!range) return; const startDate = new Date(drag.dayDate); - startDate.setHours(Math.floor(start / 60), start % 60, 0, 0); + startDate.setHours(Math.floor(range.startMinutes / 60), range.startMinutes % 60, 0, 0); const endDate = new Date(drag.dayDate); - endDate.setHours(Math.floor(end / 60), end % 60, 0, 0); + endDate.setHours(Math.floor(range.endMinutes / 60), range.endMinutes % 60, 0, 0); onCreateRange(startDate, endDate); - }, [snapToMinutes, onCreateRange]); + }, [endDrag, onCreateRange]); + + useEffect(() => () => dragRef.current?.stopScrollWatch?.(), []); + + /** The browser took the pointer over (a touch scroll, a system gesture): nothing is created. */ + const handleGridPointerCancel = useCallback(() => { + endDrag(); + }, [endDrag]); // --- Resize --- const resizeRef = useRef<{ @@ -438,6 +476,7 @@ export function useTimeGridInteractions({ handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, + handleGridPointerCancel, resizeVisual, handleResizePointerDown, handleResizePointerMove,