diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 88bbab1c7..3a65b4b04 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1664,11 +1664,16 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const d = new Date(date); if (typeof hour === "number") { d.setHours(hour, 0, 0, 0); + // Explicit end so the modal keeps the clicked hour instead of + // applying its next-hour fallback for date-only defaults (#435). + const end = new Date(date); + end.setHours(hour + 1, 0, 0, 0); + openCreateModal(d, end); } else { const now = new Date(); d.setHours(now.getHours() + 1, 0, 0, 0); + openCreateModal(d); } - openCreateModal(d); }} onNewAllDayEvent={() => { const d = new Date(date); diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts new file mode 100644 index 000000000..a0f7526c2 --- /dev/null +++ b/hooks/__tests__/use-time-grid-interactions.test.ts @@ -0,0 +1,66 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; + +const errorMessages = { resize: "resize", move: "move", created: "created", error: "error" }; + +function renderInteractions(onCreateRange = vi.fn()) { + const { result } = renderHook(() => + useTimeGridInteractions({ hourHeight: 60, calendars: [], onCreateRange, errorMessages }), + ); + return { result, onCreateRange }; +} + +describe("useTimeGridInteractions slot clicks (#435)", () => { + beforeEach(() => { + // Freeze "now" far from the clicked slot so a regression to the modal's + // next-hour-from-now fallback would produce a different time than asserted. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 21, 12, 2, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("single click creates a range at the clicked hour, not near the current time", () => { + const { result, onCreateRange } = renderInteractions(); + const day = new Date(2026, 8, 3); + + act(() => result.current.handleSlotClick(day, 16)); + act(() => vi.advanceTimersByTime(300)); + + expect(onCreateRange).toHaveBeenCalledTimes(1); + const [start, end] = onCreateRange.mock.calls[0]; + expect(start).toEqual(new Date(2026, 8, 3, 16, 0, 0, 0)); + expect(end).toEqual(new Date(2026, 8, 3, 17, 0, 0, 0)); + }); + + it("double click creates a range at the clicked hour immediately", () => { + const { result, onCreateRange } = renderInteractions(); + const day = new Date(2026, 8, 3); + + act(() => result.current.handleSlotClick(day, 9)); + act(() => result.current.handleSlotDoubleClick(day, 9)); + + expect(onCreateRange).toHaveBeenCalledTimes(1); + const [start, end] = onCreateRange.mock.calls[0]; + expect(start).toEqual(new Date(2026, 8, 3, 9, 0, 0, 0)); + expect(end).toEqual(new Date(2026, 8, 3, 10, 0, 0, 0)); + + // The pending single-click timer must have been cancelled by the double click. + act(() => vi.advanceTimersByTime(500)); + expect(onCreateRange).toHaveBeenCalledTimes(1); + }); + + it("a 23:00 click ends at midnight of the next day", () => { + const { result, onCreateRange } = renderInteractions(); + const day = new Date(2026, 8, 3); + + act(() => result.current.handleSlotDoubleClick(day, 23)); + + const [start, end] = onCreateRange.mock.calls[0]; + expect(start).toEqual(new Date(2026, 8, 3, 23, 0, 0, 0)); + expect(end).toEqual(new Date(2026, 8, 4, 0, 0, 0, 0)); + }); +}); diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index cfea62328..316f4e448 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -256,6 +256,18 @@ export function useTimeGridInteractions({ const clickTimerRef = useRef | null>(null); const [quickCreate, setQuickCreate] = useState(null); + // Pass an explicit end alongside the clicked slot: the event modal only + // honors a timed `defaultDate` when `defaultEndDate` is present — without it + // the modal falls back to "next hour from now" (meant for date-only month + // clicks) and discards the clicked time (#435). + const slotToRange = useCallback((day: Date, hour: number): [Date, Date] => { + const start = new Date(day); + start.setHours(hour, 0, 0, 0); + const end = new Date(day); + end.setHours(hour + 1, 0, 0, 0); + return [start, end]; + }, []); + const handleSlotClick = useCallback((day: Date, hour: number) => { if (wasDragging.current) return; if (clickTimerRef.current) { @@ -265,11 +277,9 @@ export function useTimeGridInteractions({ } clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; - const d = new Date(day); - d.setHours(hour, 0, 0, 0); - onCreateRange(d); + onCreateRange(...slotToRange(day, hour)); }, 250); - }, [onCreateRange]); + }, [onCreateRange, slotToRange]); const handleSlotDoubleClick = useCallback((day: Date, hour: number) => { if (wasDragging.current) return; @@ -277,10 +287,8 @@ export function useTimeGridInteractions({ clearTimeout(clickTimerRef.current); clickTimerRef.current = null; } - const d = new Date(day); - d.setHours(hour, 0, 0, 0); - onCreateRange(d); - }, [onCreateRange]); + onCreateRange(...slotToRange(day, hour)); + }, [onCreateRange, slotToRange]); const handleQuickCreateSubmit = useCallback(async (title: string) => { if (!quickCreate) return;