From ccbab654f3280747801b8640caad3308903d5f70 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Sun, 22 Feb 2026 17:32:10 +0100 Subject: [PATCH] feat(calendar): add drag-create, resize, recurring scope, quick-create, and duplication - Click-drag on empty time slots to create events with pre-filled time range - Resize events by dragging bottom edge (15-min snap, optimistic JMAP update) - Recurring event edit/delete scope dialog (this/following/all occurrences) - Double-click quick event creation with inline title input - Event duplication button in modal (+1 day offset) - Shared interaction hook for pointer-based calendar interactions Fixes #13 --- README.md | 5 + ROADMAP.md | 5 + app/[locale]/calendar/page.tsx | 339 +++++++++- components/calendar/calendar-agenda-view.tsx | 16 +- components/calendar/calendar-day-view.tsx | 202 +++--- components/calendar/calendar-month-view.tsx | 16 +- components/calendar/calendar-week-view.tsx | 194 +++--- components/calendar/event-card.tsx | 9 +- components/calendar/event-detail-popover.tsx | 609 ++++++++++++++++++ components/calendar/event-modal.tsx | 100 ++- components/calendar/quick-event-input.tsx | 61 ++ .../calendar/recurrence-scope-dialog.tsx | 113 ++++ hooks/use-time-grid-interactions.ts | 354 ++++++++++ lib/calendar-utils.ts | 54 ++ locales/de/common.json | 32 +- locales/en/common.json | 32 +- locales/es/common.json | 32 +- locales/fr/common.json | 32 +- locales/it/common.json | 32 +- locales/ja/common.json | 32 +- locales/nl/common.json | 32 +- locales/pt/common.json | 32 +- 22 files changed, 2011 insertions(+), 322 deletions(-) create mode 100644 components/calendar/event-detail-popover.tsx create mode 100644 components/calendar/quick-event-input.tsx create mode 100644 components/calendar/recurrence-scope-dialog.tsx create mode 100644 hooks/use-time-grid-interactions.ts create mode 100644 lib/calendar-utils.ts diff --git a/README.md b/README.md index 71c82990..9423f6b7 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Locale-aware date formatting (respects user's language) - Settings for first day of week, time format (12h/24h), and default view - Drag-and-drop rescheduling (week/day time snap, month date move) +- Click-drag on empty time slots to create events with pre-filled time range +- Resize events by dragging bottom edge (15-minute snap) +- Double-click quick create with inline title input +- Event duplication (+1 day offset) +- Recurring event edit/delete scope (this event, this and following, all events) - iCalendar (.ics) file import with event preview and bulk create - Real-time updates via JMAP push notifications - Event notifications with client-side alert evaluation and toast display diff --git a/ROADMAP.md b/ROADMAP.md index bd4dd0f6..1976aa8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,6 +163,11 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete) - [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display) - [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete) +- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay) +- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update) +- [x] Recurring event edit/delete scope dialog (this event / this and following / all events) +- [x] Double-click quick event creation (inline title input, PT1H default) +- [x] Event duplication button in modal (clones event +1 day, opens for editing) ### Email Filters - [x] JMAP Sieve Scripts (RFC 9661) with capability detection diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 4241f318..5dfb7a10 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -6,7 +6,7 @@ import { useTranslations } from "next-intl"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, - format, + format, parseISO, } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -22,9 +22,21 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { EventModal } from "@/components/calendar/event-modal"; +import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; +import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; +import { getUserParticipantId } from "@/lib/calendar-participants"; +import { debug } from "@/lib/debug"; + +type PendingScopeAction = + | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } + | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }; + +function isRecurringEvent(event: CalendarEvent): boolean { + return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null; +} export default function CalendarPage() { const router = useRouter(); @@ -49,7 +61,11 @@ export default function CalendarPage() { const [showImportModal, setShowImportModal] = useState(false); const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); + const [defaultModalEndDate, setDefaultModalEndDate] = useState(); const [miniMonth, setMiniMonth] = useState(new Date()); + const [pendingScopeAction, setPendingScopeAction] = useState(null); + const [detailEvent, setDetailEvent] = useState(null); + const [detailAnchorRect, setDetailAnchorRect] = useState(null); const hasFetched = useRef(false); useEffect(() => { @@ -149,9 +165,10 @@ export default function CalendarPage() { setSelectedDate(date); }, [setSelectedDate]); - const openCreateModal = useCallback((date?: Date) => { + const openCreateModal = useCallback((date?: Date, endDate?: Date) => { setEditEvent(null); setDefaultModalDate(date || selectedDate); + setDefaultModalEndDate(endDate); setShowEventModal(true); }, [selectedDate]); @@ -161,10 +178,65 @@ export default function CalendarPage() { setShowEventModal(true); }, []); - const handleSaveEvent = useCallback(async (data: Partial, sendSchedulingMessages?: boolean) => { + const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { + setDetailEvent(event); + setDetailAnchorRect(anchorRect); + }, []); + + const closeDetail = useCallback(() => { + setDetailEvent(null); + setDetailAnchorRect(null); + }, []); + + const handleEditFromDetail = useCallback(() => { + if (detailEvent) { + const ev = detailEvent; + closeDetail(); + openEditModal(ev); + } + }, [detailEvent, closeDetail, openEditModal]); + + const findMasterEvent = useCallback(async (occurrence: CalendarEvent): Promise => { + if ((occurrence.recurrenceRules?.length ?? 0) > 0 && !occurrence.recurrenceId) { + return occurrence; + } + const master = events.find(e => + e.uid === occurrence.uid && !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0 + ); + if (master) return master; + if (!client) return null; + try { + const results = await client.queryCalendarEvents({ uid: occurrence.uid }); + return results.find(e => !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0) || null; + } catch (error) { + debug.error("Failed to query master event for UID:", occurrence.uid, error); + throw error; + } + }, [events, client]); + + const refetchCurrentRange = useCallback(async () => { if (!client) return; + const { dateRange: currentRange } = useCalendarStore.getState(); + if (currentRange) { + await fetchEvents(client, currentRange.start, currentRange.end); + } + }, [client, fetchEvents]); + + const handleSaveEvent = useCallback(async (data: Partial, sendSchedulingMessages?: boolean) => { + if (!client) { toast.error(t("notifications.event_error")); return; } try { if (editEvent) { + if (isRecurringEvent(editEvent)) { + setPendingScopeAction({ + type: "edit", + event: editEvent, + updates: data, + sendScheduling: sendSchedulingMessages, + }); + setShowEventModal(false); + setEditEvent(null); + return; + } await updateEvent(client, editEvent.id, data, sendSchedulingMessages); toast.success(t("notifications.event_updated")); } else { @@ -186,15 +258,172 @@ export default function CalendarPage() { } }, [client, editEvent, createEvent, updateEvent, t]); + const handleDuplicateEvent = useCallback(async (data: Partial) => { + if (!client) { toast.error(t("notifications.event_error")); return; } + try { + const created = await createEvent(client, data); + if (!created) { + toast.error(t("notifications.event_error")); + return; + } + toast.success(t("notifications.event_duplicated")); + setEditEvent(created); + setDefaultModalDate(undefined); + } catch { + toast.error(t("notifications.event_error")); + setShowEventModal(false); + setEditEvent(null); + } + }, [client, createEvent, t]); + const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => { - if (!client) return; + if (!client) { toast.error(t("notifications.event_error")); return; } + const eventToDelete = events.find(e => e.id === id) || editEvent; + if (eventToDelete && isRecurringEvent(eventToDelete)) { + setPendingScopeAction({ + type: "delete", + event: eventToDelete, + sendScheduling: sendSchedulingMessages || undefined, + }); + setShowEventModal(false); + setEditEvent(null); + return; + } try { await deleteEvent(client, id, sendSchedulingMessages); toast.success(t("notifications.event_deleted")); } catch { toast.error(t("notifications.event_error")); } - }, [client, deleteEvent, t]); + }, [client, deleteEvent, events, editEvent, t]); + + const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{ + master: CalendarEvent; + originalRules: CalendarEvent["recurrenceRules"]; + } | null> => { + const master = await findMasterEvent(event); + if (!master) return null; + const originalRules = master.recurrenceRules + ? JSON.parse(JSON.stringify(master.recurrenceRules)) + : null; + const occurrenceDate = event.recurrenceId || event.start; + const untilDate = new Date(occurrenceDate); + untilDate.setSeconds(untilDate.getSeconds() - 1); + const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss"); + const truncatedRules = (master.recurrenceRules || []).map(rule => ({ + ...rule, + until, + count: null, + })); + await updateEvent(client!, master.id, { recurrenceRules: truncatedRules }); + return { master, originalRules }; + }, [client, findMasterEvent, updateEvent]); + + const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => { + if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; } + const { type, event, sendScheduling } = pendingScopeAction; + const updates = type === "edit" ? pendingScopeAction.updates : undefined; + setPendingScopeAction(null); + + try { + if (type === "edit" && updates) { + switch (scope) { + case "this": + await updateEvent(client, event.id, updates, sendScheduling); + break; + case "this_and_future": { + const result = await truncateRecurrenceAtEvent(event); + if (!result) { + toast.error(t("notifications.event_error")); + return; + } + const { master, originalRules } = result; + const occurrenceStart = event.recurrenceId || event.start; + const newEventData: Partial = { + title: master.title, + description: master.description, + duration: master.duration, + timeZone: master.timeZone, + calendarIds: { ...master.calendarIds }, + status: master.status, + freeBusyStatus: master.freeBusyStatus, + privacy: master.privacy, + showWithoutTime: master.showWithoutTime, + ...updates, + start: updates.start || occurrenceStart, + recurrenceRules: originalRules, + }; + delete (newEventData as Record).id; + delete (newEventData as Record).uid; + delete (newEventData as Record).recurrenceId; + try { + await createEvent(client, newEventData, sendScheduling); + } catch (createError) { + debug.error("Failed to create new series, rolling back master truncation:", createError); + try { + await updateEvent(client, master.id, { recurrenceRules: originalRules }); + } catch (rollbackError) { + debug.error("Rollback of master event also failed:", rollbackError); + } + throw createError; + } + break; + } + case "all": { + const master = await findMasterEvent(event); + if (!master) { + toast.error(t("notifications.event_error")); + return; + } + const allUpdates = { ...updates }; + delete (allUpdates as Record).recurrenceId; + await updateEvent(client, master.id, allUpdates, sendScheduling); + break; + } + default: { + const _exhaustive: never = scope; + throw new Error(`Unhandled scope: ${_exhaustive}`); + } + } + toast.success(t("notifications.event_updated")); + } else { + switch (scope) { + case "this": + await deleteEvent(client, event.id, sendScheduling); + break; + case "this_and_future": { + const result = await truncateRecurrenceAtEvent(event); + if (!result) { + toast.error(t("notifications.event_error")); + return; + } + break; + } + case "all": { + const master = await findMasterEvent(event); + if (!master) { + toast.error(t("notifications.event_error")); + return; + } + await deleteEvent(client, master.id, sendScheduling); + break; + } + default: { + const _exhaustive: never = scope; + throw new Error(`Unhandled scope: ${_exhaustive}`); + } + } + toast.success(t("notifications.event_deleted")); + } + try { + await refetchCurrentRange(); + } catch { + debug.error("Failed to refresh calendar after scope operation"); + } + } catch { + toast.error(t("notifications.event_error")); + } + }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, t]); const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => { if (!client) return; @@ -206,11 +435,74 @@ export default function CalendarPage() { } }, [client, rsvpEvent, t]); + const handleDeleteFromDetail = useCallback(() => { + if (!detailEvent) return; + const hasParticipants = detailEvent.participants && Object.keys(detailEvent.participants).length > 0; + closeDetail(); + handleDeleteEvent(detailEvent.id, hasParticipants || undefined); + }, [detailEvent, closeDetail, handleDeleteEvent]); + + const handleDuplicateFromDetail = useCallback(async () => { + if (!detailEvent || !client) return; + const start = parseISO(detailEvent.start); + const newStart = addDays(start, 1); + const data: Partial = { + title: detailEvent.title, + description: detailEvent.description, + start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), + duration: detailEvent.duration, + timeZone: detailEvent.timeZone, + showWithoutTime: detailEvent.showWithoutTime, + calendarIds: { ...detailEvent.calendarIds }, + status: "confirmed", + freeBusyStatus: detailEvent.freeBusyStatus, + privacy: detailEvent.privacy, + }; + if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations); + if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules); + if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts); + if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants); + closeDetail(); + try { + const created = await createEvent(client, data); + if (created) { + toast.success(t("notifications.event_duplicated")); + openEditModal(created); + } + } catch { + toast.error(t("notifications.event_error")); + } + }, [detailEvent, client, createEvent, closeDetail, openEditModal, t]); + + const handleSaveNoteFromDetail = useCallback(async (note: string) => { + if (!detailEvent || !client) return; + const timestamp = format(new Date(), "yyyy-MM-dd HH:mm"); + const separator = `\n\n--- ${timestamp} ---\n`; + const newDescription = detailEvent.description + ? `${detailEvent.description}${separator}${note}` + : `--- ${timestamp} ---\n${note}`; + try { + await updateEvent(client, detailEvent.id, { description: newDescription }); + setDetailEvent({ ...detailEvent, description: newDescription }); + toast.success(t("detail.note_saved")); + } catch { + toast.error(t("notifications.event_error")); + } + }, [detailEvent, client, updateEvent, t]); + + const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => { + if (!detailEvent || !client) return; + const participantId = getUserParticipantId(detailEvent, currentUserEmails); + if (!participantId) return; + closeDetail(); + await handleRsvp(detailEvent.id, participantId, status); + }, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; - if (showEventModal) return; + if (showEventModal || detailEvent) return; switch (e.key) { case "ArrowLeft": e.preventDefault(); navigatePrev(); break; @@ -225,7 +517,7 @@ export default function CalendarPage() { }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal]); + }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]); const visibleEvents = useMemo(() => events.filter((e) => { @@ -255,7 +547,7 @@ export default function CalendarPage() { events={visibleEvents} calendars={calendars} onSelectDate={handleSelectDate} - onSelectEvent={openEditModal} + onSelectEvent={handleSelectEvent} firstDayOfWeek={firstDayOfWeek} /> ); @@ -266,7 +558,7 @@ export default function CalendarPage() { events={visibleEvents} calendars={calendars} onSelectDate={handleSelectDate} - onSelectEvent={openEditModal} + onSelectEvent={handleSelectEvent} onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} @@ -278,7 +570,7 @@ export default function CalendarPage() { selectedDate={selectedDate} events={visibleEvents} calendars={calendars} - onSelectEvent={openEditModal} + onSelectEvent={handleSelectEvent} onCreateAtTime={openCreateModal} timeFormat={timeFormat} /> @@ -289,7 +581,7 @@ export default function CalendarPage() { selectedDate={selectedDate} events={visibleEvents} calendars={calendars} - onSelectEvent={openEditModal} + onSelectEvent={handleSelectEvent} timeFormat={timeFormat} /> ); @@ -358,13 +650,31 @@ export default function CalendarPage() { )} + {detailEvent && detailAnchorRect && ( + detailEvent.calendarIds[c.id])} + anchorRect={detailAnchorRect} + onEdit={handleEditFromDetail} + onDelete={handleDeleteFromDetail} + onDuplicate={handleDuplicateFromDetail} + onClose={closeDetail} + onSaveNote={handleSaveNoteFromDetail} + onRsvp={handleRsvpFromDetail} + currentUserEmails={currentUserEmails} + timeFormat={timeFormat} + /> + )} + {showEventModal && ( { setShowEventModal(false); setEditEvent(null); }} currentUserEmails={currentUserEmails} @@ -378,6 +688,13 @@ export default function CalendarPage() { onClose={() => setShowImportModal(false)} /> )} + + setPendingScopeAction(null)} + /> ); } diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 735c5c79..6dec10ec 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -6,6 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns"; import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseDuration, getEventColor } from "./event-card"; +import { getEventEndDate } from "@/lib/calendar-utils"; import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -13,7 +14,7 @@ interface CalendarAgendaViewProps { selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; - onSelectEvent: (event: CalendarEvent) => void; + onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; timeFormat?: "12h" | "24h"; } @@ -23,17 +24,6 @@ interface DayGroup { events: CalendarEvent[]; } -function getEventEndDate(event: CalendarEvent): Date { - const start = new Date(event.start); - if (!event.duration) return start; - const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0"); - const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0"); - const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0"); - const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0"); - const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000; - return new Date(start.getTime() + totalMs); -} - export function CalendarAgendaView({ events, calendars, @@ -149,7 +139,7 @@ export function CalendarAgendaView({ return ( + + + {/* Content */} +
+ {/* Date & Time */} +
+ +
+ + {format(startDate, "EEE, MMM d, yyyy")} + + {event.showWithoutTime ? ( + {t("events.all_day")} + ) : ( +
+ {formatTime(startDate)} – {formatTime(endDate)} + ({formatDurationDisplay(durationMinutes)}) +
+ )} +
+
+ + {/* Location */} + {locationName && ( +
+ + {/^https?:\/\//i.test(locationName) ? ( + + {(() => { + try { return new URL(locationName).hostname; } catch { return locationName; } + })()} + + ) : ( + {locationName} + )} +
+ )} + + {/* Virtual Location / Meeting Link */} + {virtualLocation && ( + + )} + + {/* Participants */} + {hasParticipants && ( +
+ +
+ + {t("participants.count", { count: participants.length })} + +
+ {participants.slice(0, 5).map((p) => ( +
+ + {p.name || p.email} + {p.isOrganizer && ( + + ({t("participants.organizer").toLowerCase()}) + + )} + + +
+ ))} + {participants.length > 5 && ( + + +{participants.length - 5} + + )} +
+
+
+ )} + + {/* Recurrence */} + {recurrenceLabel && ( +
+ + {recurrenceLabel} +
+ )} + + {/* Reminder */} + {alertLabel && ( +
+ + {alertLabel} +
+ )} + + {/* Description */} + {event.description && ( +
+ +

+ {event.description} +

+
+ )} +
+ + {/* Quick Note */} + {!isAttendeeMode && ( +
+ {noteExpanded ? ( +
+