import type { Calendar, Event } from '@nylas-labs/cli-kit/v3' import { AlertTriangle, AlignLeft, CalendarDays, Clock, GripVertical, MapPin, Pencil, Trash2, Users, X, } from 'lucide-react' import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react' import { RecipientInput } from '#shared/components/RecipientInput' import { Dialog, DialogContent, DialogTitle } from '#shared/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#shared/components/ui/select' import { Textarea } from '#shared/components/ui/textarea' import { type EventTone, eventColorClass, labelBadgeClass } from '#shared/lib/color-tone' import { valueToTokens } from '#shared/lib/contact-token' import { clampPointToViewport, createPanelPosition, ESTIMATED_PANEL_SIZE, type Point, type Rect, type Size, } from '#shared/lib/modal-position' import { cn } from '#shared/lib/utils' import { calendarDateInTimeZone, calendarSlotTime, calendarWallClockHour, eventTimes, fmtCompactTime, formatFullDate, ymd, } from '../lib/calendar.js' import { calendarTone, eventTone } from '../lib/calendar-ui-model.js' import { useCreateEventMutation, useDeleteEventMutation, useRsvpEventMutation, useUpdateEventMutation, } from '../state/calendar-state.js' const START_TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => i * 0.5) const END_TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => (i + 1) * 0.5) const WEEKDAYS = [ ['MO', 'Mon'], ['TU', 'Tue'], ['WE', 'Wed'], ['TH', 'Thu'], ['FR', 'Fri'], ['SA', 'Sat'], ['SU', 'Sun'], ] as const type Weekday = (typeof WEEKDAYS)[number][0] const WEEKDAY_BY_DAY = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const satisfies readonly Weekday[] type RepeatOption = 'none' | 'weekly' | 'biweekly' | 'yearly' export const NEW_EVENT_HOURS = { startHour: 9, endHour: 10 } as const export const EVENT_DIALOG_PANEL_CLASS = 'w-full overflow-y-auto overscroll-contain bg-card sm:max-h-[85vh] sm:max-w-md' /** Floating, draggable composer panel — no backdrop, positioned beside the slot. */ export const EVENT_COMPOSER_PANEL_CLASS = 'event-composer-panel fixed z-50 flex flex-col overflow-hidden border border-border bg-card shadow-2xl' export function eventComposerMaxHeight(top: number): string { return `calc(100dvh - ${Math.max(0, top) + 8}px)` } function currentViewportSize(): Size { return { width: window.innerWidth, height: window.innerHeight } } function eventBarClass(tone: EventTone): string { return eventColorClass(tone, 'bg') } function eventDotClass(tone: EventTone): string { return eventColorClass(tone, 'bg') } /** Create/edit/RSVP dialog for a single event on the primary calendar. */ export function EventModal({ event, defaultStart, calendarId, calendarName, calendars, anchorRect, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone, preserveDefaultStartTime = false, events = [], onDraftChange, onClose, }: { event: Event | null defaultStart: Date calendarId: string calendarName: string calendars: Calendar[] anchorRect?: Rect | null timeZone?: string preserveDefaultStartTime?: boolean events?: Event[] onDraftChange?: (event: Event | null) => void onClose: (changed: boolean) => void }) { const times = event ? eventTimes(event) : null const initialStart = times?.start ?? new Date(defaultStart.getTime()) const initialDate = times?.allDay ? initialStart : calendarDateInTimeZone(initialStart, timeZone) const initialHours = eventInitialHours(initialStart, Boolean(event) || preserveDefaultStartTime, timeZone) const [title, setTitle] = useState(event?.title ?? '') const [location, setLocation] = useState(event?.location ?? '') const [description, setDescription] = useState(event?.description ?? '') const [guests, setGuests] = useState('') const [startHour, setStartHour] = useState(initialHours.startHour) const [endHour, setEndHour] = useState(initialHours.endHour) const [eventDate, setEventDate] = useState(() => ymd(initialDate)) const [allDay, setAllDay] = useState(times?.allDay ?? false) const [repeat, setRepeat] = useState('none') const [weekdays, setWeekdays] = useState(() => [defaultWeekday(initialDate)]) const [weekdaysTouched, setWeekdaysTouched] = useState(false) const [selectedCalendarId, setSelectedCalendarId] = useState(calendarId) const [editing, setEditing] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [confirmingDelete, setConfirmingDelete] = useState(false) const titleInputRef = useRef(null) const editButtonRef = useRef(null) const deleteButtonRef = useRef(null) const cancelDeleteButtonRef = useRef(null) const deletePendingRef = useRef(false) const wasEditing = useRef(false) const wasConfirmingDelete = useRef(false) const createMutation = useCreateEventMutation() const updateMutation = useUpdateEventMutation(event) const deleteMutation = useDeleteEventMutation(event?.id ?? '') const rsvpMutation = useRsvpEventMutation(event?.id ?? '') // The create composer floats over the calendar (no backdrop) so the grid // stays visible; the user can drag it aside by its header to reference a day. const [panelPos, setPanelPos] = useState(() => createPanelPosition(anchorRect, ESTIMATED_PANEL_SIZE, currentViewportSize()), ) const dragCleanup = useRef<(() => void) | null>(null) function startPanelDrag(pointerEvent: React.PointerEvent) { if (pointerEvent.pointerType === 'touch') return const start = { x: pointerEvent.clientX, y: pointerEvent.clientY } const origin = { x: panelPos.x, y: panelPos.y } function onMove(moveEvent: PointerEvent) { setPanelPos( clampPointToViewport( { x: origin.x + (moveEvent.clientX - start.x), y: origin.y + (moveEvent.clientY - start.y) }, ESTIMATED_PANEL_SIZE, currentViewportSize(), ), ) } function stop() { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', stop) dragCleanup.current = null } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', stop) dragCleanup.current = stop } // Tear down a drag still in flight if the composer unmounts mid-drag. useEffect(() => () => dragCleanup.current?.(), []) // Keep the full composer inside the viewport after a resize or device rotation. useEffect(() => { function keepPanelInViewport() { setPanelPos((position) => clampPointToViewport(position, ESTIMATED_PANEL_SIZE, currentViewportSize())) } window.addEventListener('resize', keepPanelInViewport) return () => window.removeEventListener('resize', keepPanelInViewport) }, []) // The floating composer has no backdrop to click away, so Escape closes it. useEffect(() => { if (event) return function onKey(keyEvent: KeyboardEvent) { if (keyEvent.key === 'Escape' && !busy) onClose(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [busy, event, onClose]) const canRsvp = Boolean(event?.participants?.length && event?.organizer) const eventCalendar = event ? calendars.find((calendar) => calendar.id === event.calendar_id) : undefined const tone = event ? eventTone(event, 0, eventCalendar) : 'blue' const selectedCalendar = calendars.find((calendar) => calendar.id === selectedCalendarId) ?? calendars[0] const selectedCalendarTone = selectedCalendar ? calendarTone(selectedCalendar) : 'blue' const previewEvent = useMemo(() => { if (event || !isDateInput(eventDate)) return null const selectedId = selectedCalendar?.id ?? calendarId const recurrence = repeat === 'none' ? undefined : recurrenceFromForm(repeat, weekdays) const when = allDay ? { object: 'date' as const, date: eventDate } : { object: 'timespan' as const, start_time: Math.floor( calendarSlotTime(dateFromInput(eventDate), startHour, timeZone).getTime() / 1000, ), end_time: Math.floor( calendarSlotTime( dateFromInput(eventDate), Math.max(endHour, startHour + 0.5), timeZone, ).getTime() / 1000, ), } return { id: '__new-event-preview__', calendar_id: selectedId, title: title.trim() || 'Untitled event', when, ...(recurrence ? { recurrence: [recurrence] } : {}), } as Event }, [ allDay, calendarId, endHour, event, eventDate, repeat, selectedCalendar?.id, startHour, title, timeZone, weekdays, ]) const conflictCount = previewEvent ? countConflicts(previewEvent, events) : 0 useEffect(() => { onDraftChange?.(previewEvent) }, [onDraftChange, previewEvent]) useEffect(() => () => onDraftChange?.(null), [onDraftChange]) async function save() { if (!isDateInput(eventDate)) { setError('Choose a valid event date.') return } if ((repeat === 'weekly' || repeat === 'biweekly') && weekdays.length === 0) { setError('Choose at least one weekday for a repeating event.') return } if ( (repeat === 'weekly' || repeat === 'biweekly') && !weekdays.includes(defaultWeekday(dateFromInput(eventDate))) ) { setError('Include the event date weekday in the repeating schedule.') return } setBusy(true) setError(null) try { const startTime = Math.floor( calendarSlotTime(dateFromInput(eventDate), startHour, timeZone).getTime() / 1000, ) const endTime = Math.floor( calendarSlotTime(dateFromInput(eventDate), Math.max(endHour, startHour + 0.5), timeZone).getTime() / 1000, ) const participants = valueToTokens(guests) const recurrence = repeat === 'none' ? undefined : recurrenceFromForm(repeat, weekdays) await createMutation.mutateAsync({ calendarId: selectedCalendar?.id ?? calendarId, title: title.trim() || 'Untitled event', ...(location ? { location } : {}), ...(description.trim() ? { description } : {}), ...(participants.length ? { participants } : {}), ...(allDay ? { allDayDate: eventDate } : { startTime, endTime }), ...(recurrence ? { recurrence, timezone: timeZone } : {}), }) onClose(true) } catch { setError('Could not save the event. Check your connection, then try again.') setBusy(false) } } async function saveEdit() { /* v8 ignore next -- saveEdit() is only wired to the edit form, which renders only when event is present -- @preserve */ if (!event) return setBusy(true) setError(null) try { const eventDay = calendarDateInTimeZone(initialStart, timeZone) const startTime = Math.floor(calendarSlotTime(eventDay, startHour, timeZone).getTime() / 1000) const endTime = Math.floor( calendarSlotTime(eventDay, Math.max(endHour, startHour + 0.5), timeZone).getTime() / 1000, ) await updateMutation.mutateAsync({ eventId: event.id, calendarId: event.calendar_id ?? calendarId, title: title.trim() || 'Untitled event', location, description, ...(allDay ? {} : { startTime, endTime }), }) onClose(true) } catch { setError('Could not save the event. Check your connection, then try again.') setBusy(false) } } async function remove() { /* v8 ignore next -- remove() is only wired to the delete button, which renders only when event is present -- @preserve */ if (!event) return /* v8 ignore next -- @preserve the disabled confirmation button prevents repeat UI activation; this guard also closes same-tick re-entry */ if (deletePendingRef.current) return deletePendingRef.current = true setBusy(true) setError(null) try { await deleteMutation.mutateAsync({ eventId: event.id, calendarId: event.calendar_id ?? calendarId, }) onClose(true) } catch { deletePendingRef.current = false setError('Could not delete the event. Check your connection, then try again.') setBusy(false) } } async function rsvp(status: 'yes' | 'no' | 'maybe') { /* v8 ignore next -- rsvp() is only wired to the RSVP buttons, which render only when event is present -- @preserve */ if (!event) return setBusy(true) try { await rsvpMutation.mutateAsync({ eventId: event.id, calendarId: event.calendar_id ?? calendarId, status, }) onClose(true) } catch { setError('RSVP failed') setBusy(false) } } useEffect(() => { if (!event) titleInputRef.current?.focus({ preventScroll: true }) }, [event]) useEffect(() => { if (!editing && wasEditing.current) editButtonRef.current?.focus() wasEditing.current = editing }, [editing]) useEffect(() => { if (confirmingDelete) cancelDeleteButtonRef.current?.focus() else if (wasConfirmingDelete.current) deleteButtonRef.current?.focus() wasConfirmingDelete.current = confirmingDelete }, [confirmingDelete]) function beginDelete() { setError(null) setConfirmingDelete(true) } function cancelDelete() { setError(null) setConfirmingDelete(false) } if (event && times) { const persistedEvent = event const persistedTimes = times function resetEditDraft() { setTitle(persistedEvent.title ?? '') setLocation(persistedEvent.location ?? '') setDescription(persistedEvent.description ?? '') setStartHour(initialHours.startHour) setEndHour(initialHours.endHour) setAllDay(persistedTimes.allDay) setError(null) } function beginEdit() { resetEditDraft() setEditing(true) } function cancelEdit() { resetEditDraft() setEditing(false) } const when = times.allDay ? 'All day' : `${fmtCompactTime(times.start)} – ${fmtCompactTime(times.end)}` const attendeeText = event.participants ?.map((participant) => participant.name || participant.email) .filter(Boolean) .join(', ') return ( { /* v8 ignore else -- @preserve the controlled open dialog only requests dismissal; busy or open requests are intentional no-ops */ if (!next && !busy) { if (confirmingDelete) cancelDelete() else onClose(false) } }} > Event details

{event.title || '(untitled)'}

{calendarName}

{editing ? ( <>
setTitle(e.target.value)} placeholder="Add title" className="event-dialog-field w-full border-b border-border bg-transparent pb-2 text-lg font-medium outline-none placeholder:text-muted-foreground focus:border-primary" />
{formatFullDate(times.start)}
{error ? (

{error}

) : null}
) : ( <>
{formatFullDate(times.start)}
{when}
{event.location ? (
{event.location}
) : null} {attendeeText ? (
{attendeeText}
) : null} {event.description ? (
{event.description}
) : null} {error ? (

{error}

) : null}
{confirmingDelete ? (
Delete this event? This action cannot be undone.
) : ( <> {canRsvp ? (['yes', 'maybe', 'no'] as const).map((status) => ( )) : null} {!event.read_only ? ( <> ) : null} )}
)}
) } return (

When

{formatFullDate(dateFromInput(eventDate))}

{conflictCount > 0 ? (

May conflict with {conflictCount} existing {conflictCount === 1 ? 'event' : 'events'}.

) : null}

Guests

{ setWeekdaysTouched(true) setWeekdays(nextWeekdays) }} />

Calendar

{calendars.map((calendar, index) => { const active = calendar.id === selectedCalendarId const tone = calendarTone(calendar, index) return ( ) })}
{error ? (

{error}

) : null}
) } function EventTimeFields({ startHour, endHour, allDay, onStartHour, onEndHour, }: { startHour: number endHour: number allDay: boolean onStartHour: (hour: number) => void onEndHour: (hour: number) => void }) { if (allDay) return

This event will appear across the full day.

return (
Starts
Ends
) } function EventDetailsFields({ location, onLocation, description, onDescription, }: { location: string onLocation: (value: string) => void description: string onDescription: (value: string) => void }) { return (

Details