import { useState, useEffect, useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; import { X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut, } from 'lucide-react'; import axios from 'axios'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useEventLock } from '@/hooks/useEventLock'; import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations'; import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import EventLockBanner from './EventLockBanner'; import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection'; import LeaveEventDialog from './LeaveEventDialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import LocationPicker from '@/components/ui/location-picker'; // --- Helpers --- function toDateOnly(dt: string): string { if (!dt) return ''; return dt.split('T')[0]; } function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string { if (!dt) return ''; if (dt.includes('T')) return dt.slice(0, 16); return `${dt}T${fallbackTime}`; } function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string { if (!dt) return ''; return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); } function adjustAllDayEndForDisplay(dateStr: string): string { if (!dateStr) return ''; const d = new Date(dateStr.split('T')[0] + 'T12:00:00'); d.setDate(d.getDate() - 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function adjustAllDayEndForSave(dateStr: string): string { if (!dateStr) return ''; const d = new Date(dateStr + 'T12:00:00'); d.setDate(d.getDate() + 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function nowLocal(): string { const now = new Date(); const pad = (n: number) => n.toString().padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; } function plusOneHour(dt: string): string { const d = new Date(dt); d.setHours(d.getHours() + 1); const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function formatRecurrenceRule(rule: string): string { try { const parsed = JSON.parse(rule); const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; switch (parsed.type) { case 'every_n_days': return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`; case 'weekly': return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly'; case 'monthly_nth_weekday': return parsed.week && parsed.weekday != null ? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}` : 'Monthly'; case 'monthly_date': return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly'; default: return 'Recurring'; } } catch { return 'Recurring'; } } function ordinal(n: number): string { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; } function parseRecurrenceRule(raw?: string): RecurrenceRule | null { if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } // Python weekday: 0=Monday, 6=Sunday const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; // --- Types --- export interface CreateDefaults { start?: string; end?: string; allDay?: boolean; templateData?: Partial; templateName?: string; } interface EventDetailPanelProps { event: CalendarEvent | null; isCreating?: boolean; createDefaults?: CreateDefaults | null; onClose: () => void; onSaved?: () => void; onDeleted?: () => void; locationName?: string; myPermission?: CalendarPermission | 'owner' | null; isSharedEvent?: boolean; } interface EditState { title: string; description: string; start_datetime: string; end_datetime: string; all_day: boolean; location_id: string; calendar_id: string; is_starred: boolean; recurrence_type: string; recurrence_interval: number; recurrence_weekday: number; recurrence_week: number; recurrence_day: number; } function buildEditStateFromEvent(event: CalendarEvent): EditState { const rule = parseRecurrenceRule(event.recurrence_rule); const isAllDay = event.all_day; const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime; return { title: event.title, description: event.description || '', start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'), end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), all_day: isAllDay, location_id: event.location_id?.toString() || '', calendar_id: event.calendar_id?.toString() || '', is_starred: event.is_starred || false, recurrence_type: rule?.type || '', recurrence_interval: rule?.interval || 2, recurrence_weekday: rule?.weekday ?? 1, recurrence_week: rule?.week || 1, recurrence_day: rule?.day || 1, }; } function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState { const source = defaults?.templateData; const isAllDay = source?.all_day ?? defaults?.allDay ?? false; const defaultStart = nowLocal(); const defaultEnd = plusOneHour(defaultStart); const rawStart = defaults?.start || defaultStart; const rawEnd = defaults?.end || defaultEnd; const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd; const rule = parseRecurrenceRule(source?.recurrence_rule); return { title: source?.title || '', description: source?.description || '', start_datetime: formatForInput(rawStart, isAllDay, '09:00'), end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), all_day: isAllDay, location_id: source?.location_id?.toString() || '', calendar_id: source?.calendar_id?.toString() || defaultCalendarId, is_starred: source?.is_starred || false, recurrence_type: rule?.type || '', recurrence_interval: rule?.interval || 2, recurrence_weekday: rule?.weekday ?? 1, recurrence_week: rule?.week || 1, recurrence_day: rule?.day || 1, }; } function buildRecurrencePayload(state: EditState): RecurrenceRule | null { if (!state.recurrence_type) return null; switch (state.recurrence_type) { case 'every_n_days': return { type: 'every_n_days', interval: state.recurrence_interval }; case 'weekly': return { type: 'weekly' }; case 'monthly_nth_weekday': return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday }; case 'monthly_date': return { type: 'monthly_date', day: state.recurrence_day }; default: return null; } } // --- Component --- export default function EventDetailPanel({ event, isCreating = false, createDefaults, onClose, onSaved, onDeleted, locationName, myPermission, isSharedEvent = false, }: EventDetailPanelProps) { const queryClient = useQueryClient(); const { data: calendars = [], sharedData: sharedMemberships = [] } = useCalendars(); const selectableCalendars = [ ...calendars.filter((c) => !c.is_system), ...sharedMemberships .filter((m) => m.permission === 'create_modify' || m.permission === 'full_access') .map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })), ]; const defaultCalendar = calendars.find((c) => c.is_default); const { data: locations = [] } = useQuery({ queryKey: ['locations'], queryFn: async () => { const { data } = await api.get('/locations'); return data; }, staleTime: 5 * 60 * 1000, }); const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock( isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null ); const [lockInfo, setLockInfo] = useState(null); // Event invitation hooks const eventNumericId = event && typeof event.id === 'number' ? event.id : null; const parentEventId = event?.parent_event_id ?? eventNumericId; const { invitees, invite, isInviting, respond: respondInvitation, isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving, } = useEventInvitations(parentEventId); const { connections } = useConnectedUsersSearch(); const [showLeaveDialog, setShowLeaveDialog] = useState(false); const isInvitedEvent = !!event?.is_invited; const myInvitationStatus = event?.invitation_status ?? null; const myInvitationId = event?.invitation_id ?? null; const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating ? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '') : event ? buildEditStateFromEvent(event) : buildCreateState(null, defaultCalendar?.id?.toString() || '') ); const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null); const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); const [locationSearch, setLocationSearch] = useState(''); // Poll lock status in view mode for shared events (Stream A: real-time lock awareness) // lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly. const viewLockQuery = useQuery({ queryKey: ['event-lock', event?.id], queryFn: async () => { const { data } = await api.get( `/shared-calendars/events/${event!.id}/lock` ); return data; }, enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating, refetchInterval: 5_000, refetchIntervalInBackground: true, refetchOnMount: 'always', }); // Clear 423-error lockInfo when poll confirms lock is gone useEffect(() => { if (viewLockQuery.data && !viewLockQuery.data.locked) { setLockInfo(null); } }, [viewLockQuery.data]); // Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback const activeLockInfo: EventLockInfo | null = (viewLockQuery.data?.locked ? viewLockQuery.data : null) ?? (lockInfo?.locked ? lockInfo : null); const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access'; // Reset state when event changes useEffect(() => { setIsEditing(false); setScopeStep(null); setEditScope(null); setLocationSearch(''); setLockInfo(null); if (event) setEditState(buildEditStateFromEvent(event)); }, [event?.id]); // Enter edit mode when creating useEffect(() => { if (isCreating) { setIsEditing(true); setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')); setLocationSearch(''); } }, [isCreating, createDefaults]); // Initialize location search text from existing location useEffect(() => { if (isEditing && !isCreating && event?.location_id) { const loc = locations.find((l) => l.id === event.location_id); if (loc) setLocationSearch(loc.name); } }, [isEditing, isCreating, event?.location_id, locations]); const invalidateAll = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); }, [queryClient]); // --- Mutations --- const saveMutation = useMutation({ mutationFn: async (data: EditState) => { const rule = buildRecurrencePayload(data); let endDt = data.end_datetime; if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt); const payload: Record = { title: data.title, description: data.description || null, start_datetime: data.start_datetime, end_datetime: endDt, all_day: data.all_day, location_id: data.location_id ? parseInt(data.location_id) : null, calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, is_starred: data.is_starred, recurrence_rule: rule, }; if (event && !isCreating) { if (editScope) payload.edit_scope = editScope; return api.put(`/events/${event.id}`, payload); } else { return api.post('/events', payload); } }, onSuccess: () => { if (isSharedEvent) releaseLock(); invalidateAll(); toast.success(isCreating ? 'Event created' : 'Event updated'); if (isCreating) { onClose(); onSaved?.(); } else { setIsEditing(false); setEditScope(null); } }, onError: (error) => { toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event')); }, }); const deleteMutation = useMutation({ mutationFn: async () => { const scope = editScope ? `?scope=${editScope}` : ''; await api.delete(`/events/${event!.id}${scope}`); }, onSuccess: () => { invalidateAll(); toast.success('Event deleted'); onClose(); onDeleted?.(); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete event')); }, }); const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete); // --- Handlers --- const handleEditStart = async () => { // For shared events, acquire lock first if (isSharedEvent && event && typeof event.id === 'number') { try { await acquireLock(); setLockInfo(null); } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 423) { const data = err.response.data as { locked_by_name?: string; expires_at?: string; is_permanent?: boolean } | undefined; setLockInfo({ locked: true, locked_by_name: data?.locked_by_name || 'another user', expires_at: data?.expires_at || null, is_permanent: data?.is_permanent || false, }); return; } toast.error('Failed to acquire edit lock'); return; } } if (isRecurring) { setScopeStep('edit'); } else { if (event) setEditState(buildEditStateFromEvent(event)); setIsEditing(true); } }; const handleScopeSelect = (scope: 'this' | 'this_and_future') => { setEditScope(scope); if (scopeStep === 'edit') { if (event) setEditState(buildEditStateFromEvent(event)); setIsEditing(true); setScopeStep(null); } else if (scopeStep === 'delete') { // Delete with scope — execute immediately setScopeStep(null); const scopeParam = `?scope=${scope}`; api.delete(`/events/${event!.id}${scopeParam}`).then(() => { invalidateAll(); toast.success('Event(s) deleted'); onClose(); onDeleted?.(); }).catch((error) => { toast.error(getErrorMessage(error, 'Failed to delete event')); }); } }; const handleEditCancel = () => { if (isSharedEvent) releaseLock(); setIsEditing(false); setEditScope(null); setLocationSearch(''); if (isCreating) { onClose(); } else if (event) { setEditState(buildEditStateFromEvent(event)); } }; const handleEditSave = () => { saveMutation.mutate(editState); }; const handleDeleteStart = () => { if (isRecurring) { setScopeStep('delete'); } else { handleDeleteClick(); } }; // --- Render helpers --- const updateField = (key: K, value: EditState[K]) => { setEditState((s) => ({ ...s, [key]: value })); }; // Empty state if (!event && !isCreating) { return (

Select an event to view details

); } // View mode data const startDate = event ? parseISO(event.start_datetime) : null; const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null; const startStr = startDate ? event!.all_day ? format(startDate, 'EEEE, MMMM d, yyyy') : format(startDate, 'EEEE, MMMM d, yyyy · h:mm a') : ''; const endStr = endDate ? event!.all_day ? format(endDate, 'EEEE, MMMM d, yyyy') : format(endDate, 'h:mm a') : null; const panelTitle = isCreating ? createDefaults?.templateName ? `New Event from ${createDefaults.templateName}` : 'New Event' : event?.title || ''; return (
{/* Header */}
{isEditing ? (
{isCreating ? (

{panelTitle}

) : ( updateField('title', e.target.value)} className="h-8 text-base font-semibold" placeholder="Event title" autoFocus /> )}
) : scopeStep ? (

{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}

) : (

{event?.title}

{event?.calendar_name}
)}
{scopeStep ? ( ) : (isEditing || isCreating) ? ( <> ) : ( <> {!event?.is_virtual && ( <> {/* Edit button — only for own events or shared with edit permission */} {canEdit && !isInvitedEvent && ( )} {/* Leave button for invited events */} {isInvitedEvent && myInvitationId && ( )} {/* Delete button for own events */} {canDelete && !isInvitedEvent && ( confirmingDelete ? ( ) : ( ) )} )} )}
{/* Body */}
{/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */} {activeLockInfo && ( )} {scopeStep ? ( /* Scope selection step */

This is a recurring event. How would you like to proceed?

) : (isEditing || isCreating) ? ( /* Edit / Create mode */
{/* Title (only shown in body for create mode; edit mode has it in header) */} {isCreating && (
updateField('title', e.target.value)} placeholder="Event title" required autoFocus />
)}