fix: calendar click/double-click created events near current time instead of the clicked slot

This commit is contained in:
Linus Rath
2026-08-21 18:04:33 +02:00
parent 165ec6d321
commit 7e9aeaf0d4
3 changed files with 88 additions and 9 deletions
+6 -1
View File
@@ -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);
@@ -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));
});
});
+16 -8
View File
@@ -256,6 +256,18 @@ export function useTimeGridInteractions({
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [quickCreate, setQuickCreate] = useState<QuickCreateState | null>(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;