feat: double-click to create event from month view with smart time suggestion (#37)

* feat: open event modal on month day cell click

* feat: suggest first available hour when creating from month view

* chore: document month view slot suggestion helpers

* fix(calendar): use double-click to create event in month view

* fix(calendar): use 9am fallback for non-today month slots
This commit is contained in:
Rens Reinders
2026-03-18 21:13:52 +01:00
committed by Matthieu MALVACHE
parent 167014871d
commit 0153f66340
2 changed files with 77 additions and 1 deletions
+1
View File
@@ -551,6 +551,7 @@ export default function CalendarPage() {
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
/>
);
+76 -1
View File
@@ -20,6 +20,7 @@ interface CalendarMonthViewProps {
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
}
@@ -29,6 +30,7 @@ export function CalendarMonthView({
calendars,
onSelectDate,
onSelectEvent,
onCreateAtTime,
firstDayOfWeek = 1,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
@@ -88,6 +90,70 @@ export function CalendarMonthView({
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
// Round up to the next whole hour, keeping exact-hour times unchanged.
const getRoundedUpHour = useCallback((date: Date): number => {
const rounded = new Date(date);
if (
rounded.getMinutes() > 0 ||
rounded.getSeconds() > 0 ||
rounded.getMilliseconds() > 0
) {
rounded.setHours(rounded.getHours() + 1, 0, 0, 0);
} else {
rounded.setMinutes(0, 0, 0);
}
return rounded.getHours();
}, []);
// Build busy minute ranges for this specific day, clipped to day boundaries.
const getBusyIntervalsForDay = useCallback((day: Date, dayEvents: CalendarEvent[]) => {
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
const intervals: Array<[number, number]> = [];
for (const ev of dayEvents) {
if (ev.showWithoutTime) continue;
const evStart = new Date(ev.start);
const evEnd = getEventEndDate(ev);
if (evEnd <= dayStart || evStart >= dayEnd) continue;
const clippedStart = evStart < dayStart ? dayStart : evStart;
const clippedEnd = evEnd > dayEnd ? dayEnd : evEnd;
const startMin = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000));
const endMin = Math.min(24 * 60, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000));
if (endMin > startMin) intervals.push([startMin, endMin]);
}
return intervals;
}, []);
// Pick the first free 1-hour slot; fallback to the next whole hour when fully occupied.
const findSuggestedStart = useCallback((day: Date, dayEvents: CalendarEvent[]) => {
const now = new Date();
const startHour = isToday(day) ? getRoundedUpHour(now) : 9;
const fallbackHour = isToday(day) ? getRoundedUpHour(now) : 9;
const busyIntervals = getBusyIntervalsForDay(day, dayEvents);
const isSlotFree = (hour: number) => {
const startMin = hour * 60;
const endMin = startMin + 60;
return busyIntervals.every(([busyStart, busyEnd]) => endMin <= busyStart || startMin >= busyEnd);
};
for (let hour = Math.max(0, startHour); hour < 24; hour++) {
if (isSlotFree(hour)) {
const start = new Date(day);
start.setHours(hour, 0, 0, 0);
return start;
}
}
const fallback = new Date(day);
fallback.setHours(Math.min(23, Math.max(0, fallbackHour)), 0, 0, 0);
return fallback;
}, [getBusyIntervalsForDay, getRoundedUpHour]);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
@@ -159,7 +225,16 @@ export function CalendarMonthView({
role="gridcell"
aria-selected={selected}
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onClick={() => {
onSelectDate(day);
}}
onDoubleClick={() => {
onSelectDate(day);
const suggestedStart = findSuggestedStart(day, dayEvents);
const suggestedEnd = new Date(suggestedStart);
suggestedEnd.setHours(suggestedEnd.getHours() + 1);
onCreateAtTime(suggestedStart, suggestedEnd);
}}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}