UMBRA/frontend/src/components/calendar/CalendarPage.tsx
Kyle Pope a94485b138 Address code review findings across all phases
Phase 1 fixes:
- W-01: Add start_period: 30s to backend healthcheck for migration window
- W-03: Narrow .dockerignore *.md to specific files (preserve alembic/README)

Phase 2 fixes:
- C-01: Wrap Argon2id calls in totp.py (disable, regenerate, backup verify,
  backup store) — missed in initial AC-2 pass
- S-01: Extract async wrappers (ahash_password, averify_password,
  averify_password_with_upgrade) into services/auth.py, refactor all
  callers to use them instead of manual run_in_executor boilerplate
- W-01: Fix ntfy dedup regression — commit per category instead of per-user
  to preserve dedup records if a later category fails

Phase 4 fixes:
- C-01: Fix optimistic drag-and-drop cache key to include date range
- C-02: Replace toISOString() with format() to avoid UTC date shift in
  visible range calculation
- W-02: Initialize visibleRange from current month to eliminate unscoped
  first fetch + immediate refetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:19:33 +08:00

687 lines
26 KiB
TypeScript

import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { format } from 'date-fns';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import CalendarSidebar from './CalendarSidebar';
import EventDetailPanel from './EventDetailPanel';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
import type { CreateDefaults } from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
const viewLabels: Record<CalendarView, string> = {
dayGridMonth: 'Month',
timeGridWeek: 'Week',
timeGridDay: 'Day',
};
export default function CalendarPage() {
const queryClient = useQueryClient();
const location = useLocation();
const calendarRef = useRef<FullCalendar>(null);
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState('');
const [eventSearch, setEventSearch] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
// Panel state
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings();
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null);
// Resizable sidebar
const SIDEBAR_STORAGE_KEY = 'umbra-calendar-sidebar-width';
const SIDEBAR_MIN = 180;
const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 224; // w-56
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (saved) {
const n = parseInt(saved, 10);
if (!isNaN(n) && n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) return n;
}
return SIDEBAR_DEFAULT;
});
const isResizingRef = useRef(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isResizingRef.current = true;
const startX = e.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
const onMouseMove = (ev: MouseEvent) => {
latestWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX)));
// Direct DOM mutation — bypasses React entirely during drag, zero re-renders
if (sidebarRef.current) {
sidebarRef.current.style.width = latestWidth + 'px';
}
};
const onMouseUp = () => {
isResizingRef.current = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Single React state commit on release — triggers localStorage persist + final reconciliation
setSidebarWidth(latestWidth);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}, [sidebarWidth]);
// Persist sidebar width on change
useEffect(() => {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
}, [sidebarWidth]);
// Location data for event panel
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const locationMap = useMemo(() => {
const map = new Map<number, string>();
locations.forEach((l) => map.set(l.id, l.name));
return map;
}, [locations]);
// Build permission map: calendar_id -> permission level
const permissionMap = useMemo(() => {
const map = new Map<number, CalendarPermission | 'owner'>();
calendars.forEach((cal) => map.set(cal.id, 'owner'));
sharedData.forEach((m) => map.set(m.calendar_id, m.permission));
return map;
}, [calendars, sharedData]);
// Set of calendar IDs that are shared (owned or membership)
const sharedCalendarIds = useMemo(() => {
const ids = new Set<number>();
calendars.forEach((cal) => { if (cal.is_shared) ids.add(cal.id); });
sharedData.forEach((m) => ids.add(m.calendar_id));
return ids;
}, [calendars, sharedData]);
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
if (!state) return;
const calApi = calendarRef.current?.getApi();
if (state.date && calApi) {
calApi.gotoDate(state.date);
if (state.view) calApi.changeView(state.view);
}
if (state.eventId) {
setSelectedEventId(state.eventId);
setPanelMode('view');
}
window.history.replaceState({}, '');
}, [location.state]);
// Resize FullCalendar when container size changes (e.g. sidebar collapse)
useEffect(() => {
const el = calendarContainerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
calendarRef.current?.getApi().updateSize();
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount
const isDesktop = useMediaQuery(DESKTOP);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
let rafId: number;
const start = performance.now();
const duration = 350; // slightly longer than the 300ms CSS transition
const tick = () => {
calendarRef.current?.getApi().updateSize();
if (performance.now() - start < duration) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [panelOpen]);
// Scroll wheel navigation in month view
useEffect(() => {
const el = calendarContainerRef.current;
if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => {
// Skip wheel navigation on touch devices (let them scroll normally)
if ('ontouchstart' in window) return;
const api = calendarRef.current?.getApi();
if (!api || api.view.type !== 'dayGridMonth') return;
e.preventDefault();
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
}, 300);
if (e.deltaY > 0) api.next();
else if (e.deltaY < 0) api.prev();
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, []);
// AW-2: Track visible date range for scoped event fetching
// W-02 fix: Initialize from current month to avoid unscoped first fetch
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
// FullCalendar month view typically fetches prev month to next month
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
return { start, end };
});
const { data: events = [] } = useQuery({
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
queryFn: async () => {
const { data } = await api.get<CalendarEvent[]>('/events', {
params: { start: visibleRange.start, end: visibleRange.end },
});
return data;
},
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
refetchInterval: 30_000,
});
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) ?? null,
[selectedEventId, events],
);
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
// Close panel if shared calendar was removed while viewing
useEffect(() => {
if (!selectedEvent || allCalendarIds.size === 0) return;
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
handlePanelClose();
toast.info('This calendar is no longer available');
}
}, [allCalendarIds, selectedEvent]);
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
const visibleCalendarIds = useMemo(
() => {
const owned = calendars.filter((c) => c.is_visible).map((c) => c.id);
return new Set([...owned, ...visibleSharedIds]);
},
[calendars, visibleSharedIds],
);
const toLocalDatetime = (d: Date): string => {
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())}:${pad(d.getSeconds())}`;
};
const updateEventTimes = (
id: number,
start: string,
end: string,
allDay: boolean,
revert: () => void,
) => {
// C-01 fix: match active query key which includes date range
queryClient.setQueryData<CalendarEvent[]>(
['calendar-events', visibleRange.start, visibleRange.end],
(old) =>
old?.map((e) =>
e.id === id
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
: e,
),
);
eventMutation.mutate({ id, start, end, allDay, revert });
};
const eventMutation = useMutation({
mutationFn: async ({
id,
start,
end,
allDay,
}: {
id: number;
start: string;
end: string;
allDay: boolean;
revert: () => void;
}) => {
const response = await api.put(`/events/${id}`, {
start_datetime: start,
end_datetime: end,
all_day: allDay,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Event updated');
},
onError: (error, variables) => {
variables.revert();
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
if (axios.isAxiosError(error) && error.response?.status === 423) {
toast.error('Event is locked by another user');
} else {
toast.error(getErrorMessage(error, 'Failed to update event'));
}
},
});
const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [events, visibleCalendarIds, calendars.length]);
const searchResults = useMemo(() => {
if (!eventSearch.trim()) return [];
const q = eventSearch.toLowerCase();
return filteredEvents
.filter((e) => e.title.toLowerCase().includes(q))
.slice(0, 8);
}, [filteredEvents, eventSearch]);
const handleSearchSelect = (event: CalendarEvent) => {
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
const startDate = new Date(event.start_datetime);
calApi.gotoDate(startDate);
if (event.all_day) {
calApi.changeView('dayGridMonth');
} else {
calApi.changeView('timeGridDay');
}
setEventSearch('');
setSearchFocused(false);
// Also open the event in the panel
if (!event.is_virtual) {
setSelectedEventId(event.id);
setPanelMode('view');
}
};
const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id),
title: event.title,
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
editable: permissionMap.get(event.calendar_id) !== 'read_only',
extendedProps: {
is_virtual: event.is_virtual,
is_recurring: event.is_recurring,
parent_event_id: event.parent_event_id,
calendar_id: event.calendar_id,
},
}));
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return;
if (event.is_virtual) {
toast.info(`${event.title} — from People contacts`);
return;
}
setSelectedEventId(event.id);
setPanelMode('view');
};
const handleEventDrop = (info: EventDropArg) => {
if (info.event.extendedProps.is_virtual) {
info.revert();
return;
}
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
return;
}
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
info.revert();
toast.error('You have read-only access to this calendar');
return;
}
const id = parseInt(info.event.id);
const start = info.event.allDay
? info.event.startStr
: info.event.start
? toLocalDatetime(info.event.start)
: info.event.startStr;
const end = info.event.allDay
? info.event.endStr || info.event.startStr
: info.event.end
? toLocalDatetime(info.event.end)
: start;
updateEventTimes(id, start, end, info.event.allDay, info.revert);
};
const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => {
if (info.event.extendedProps.is_virtual) {
info.revert();
return;
}
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
return;
}
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
info.revert();
toast.error('You have read-only access to this calendar');
return;
}
const id = parseInt(info.event.id);
const start = info.event.allDay
? info.event.startStr
: info.event.start
? toLocalDatetime(info.event.start)
: info.event.startStr;
const end = info.event.allDay
? info.event.endStr || info.event.startStr
: info.event.end
? toLocalDatetime(info.event.end)
: start;
updateEventTimes(id, start, end, info.event.allDay, info.revert);
};
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
start: selectInfo.startStr,
end: selectInfo.endStr,
allDay: selectInfo.allDay,
});
};
const handleCreateNew = () => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults(null);
};
const handlePanelClose = () => {
calendarRef.current?.getApi().unselect();
setPanelMode('closed');
setSelectedEventId(null);
setCreateDefaults(null);
};
const handleUseTemplate = (template: EventTemplate) => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
templateData: {
title: template.title,
description: template.description || '',
all_day: template.all_day,
calendar_id: template.calendar_id ?? undefined,
location_id: template.location_id || undefined,
is_starred: template.is_starred,
recurrence_rule: template.recurrence_rule || undefined,
} as Partial<CalendarEvent>,
templateName: template.name,
});
};
const handleDatesSet = (arg: DatesSetArg) => {
setCalendarTitle(arg.view.title);
setCurrentView(arg.view.type as CalendarView);
// AW-2: Capture visible range for scoped event fetching
// C-02 fix: use format() not toISOString() to avoid UTC date shift
const start = format(arg.start, 'yyyy-MM-dd');
const end = format(arg.end, 'yyyy-MM-dd');
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end }
);
};
const navigatePrev = () => calendarRef.current?.getApi().prev();
const navigateNext = () => calendarRef.current?.getApi().next();
const navigateToday = () => calendarRef.current?.getApi().today();
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
return (
<div className="flex h-full overflow-hidden animate-fade-in">
<div className="hidden lg:flex lg:flex-row shrink-0">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<div
onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
/>
</div>
{!isDesktop && (
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent className="w-72 p-0">
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
</SheetContent>
</Sheet>
)}
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */}
<div className="border-b bg-card/95 backdrop-blur-md px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}>
<PanelLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigateNext}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today
</Button>
<div className="md:hidden">
<Select
value={currentView}
onChange={(e) => changeView(e.target.value as CalendarView)}
className="h-8 text-sm w-auto pr-8"
>
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<option key={view} value={view}>{label}</option>
))}
</Select>
</div>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button
key={view}
onClick={() => changeView(view)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
currentView === view
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
}}
>
{label}
</button>
))}
</div>
<h2 className="text-sm sm:text-lg font-semibold font-heading truncate min-w-0 flex-shrink">{calendarTitle}</h2>
<div className="flex-1" />
{/* Event search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search events..."
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className="w-32 sm:w-52 h-8 pl-8 text-sm ring-inset"
/>
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
{searchResults.map((event) => (
<button
key={event.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSearchSelect(event)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{event.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
</button>
))}
</div>
)}
</div>
<Button size="sm" onClick={handleCreateNew}>
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Create Event</span>
</Button>
</div>
{/* Calendar grid + event detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="h-full">
<FullCalendar
key={`fc-${settings?.first_day_of_week ?? 0}`}
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={false}
firstDay={settings?.first_day_of_week ?? 0}
events={calendarEvents}
editable={true}
selectable={true}
selectMirror={true}
unselectAuto={false}
dayMaxEvents={true}
weekends={true}
nowIndicator={true}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
eventResize={handleEventResize}
select={handleDateSelect}
datesSet={handleDatesSet}
height="100%"
/>
</div>
</div>
{/* Detail panel (desktop) */}
{panelOpen && isDesktop && (
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/>
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && !isDesktop && (
<MobileDetailOverlay open onClose={handlePanelClose} className="sm:max-w-[400px]">
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/>
</MobileDetailOverlay>
)}
</div>
);
}