/** * Calendar page - 3-column explorer-style layout. * * Left: CalendarTree (calendar selection + visibility) * Middle: Calendar view (Day / Week / Month / Range) with date controls * Right: CalendarDetail (selected entry details + subtasks) * * Uses PluginToolbar for actions, ResizablePanel for desktop layout. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { useToast } from '@/components/ui/Toast'; import { CalendarTree } from '@/components/calendar/CalendarTree'; import { MonthView } from '@/components/calendar/MonthView'; import { WeekView } from '@/components/calendar/WeekView'; import { DayView } from '@/components/calendar/DayView'; import { RangeView } from '@/components/calendar/RangeView'; import { formatDateInput } from '@/utils/date'; import { CalendarDetail } from '@/components/calendar/CalendarDetail'; import { AppointmentModal } from '@/components/calendar/AppointmentModal'; import { IcsControls } from '@/components/calendar/IcsControls'; import { SharingSettings } from '@/components/calendar/SharingSettings'; import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { ChevronLeft, ChevronRight, Info, Plus } from 'lucide-react'; import { fetchCalendars, createCalendar, listEntries, updateEntry, deleteEntry, type Calendar, type CalendarEntry, } from '@/api/calendar'; export function CalendarPage() { const { t } = useTranslation(); const toast = useToast(); const { viewMode, setViewMode, visibleMonth, visibleWeek, visibleDay, rangeStart, rangeEnd, setRangeStart, setRangeEnd, goToNextMonth, goToPrevMonth, goToNextWeek, goToPrevWeek, goToNextDay, goToPrevDay, goToToday, activeCalendarId, setActiveCalendarId, calendars, setCalendars, selectedEntry, setSelectedEntry, visibleCalendarIds, toggleCalendarVisibility, } = useCalendarStore(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [loadingCalendars, setLoadingCalendars] = useState(true); const [error, setError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalEntry, setModalEntry] = useState(null); const [prefillDate, setPrefillDate] = useState(null); const [showSharing, setShowSharing] = useState(false); const [showIcs, setShowIcs] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [showDetails, setShowDetails] = useState(true); // Mobile view state const [activeView, setActiveView] = useState<'tree' | 'calendar' | 'details'>('tree'); // Load calendars once useEffect(() => { let cancelled = false; setLoadingCalendars(true); fetchCalendars() .then((list) => { if (cancelled) return; setCalendars(list); if (!activeCalendarId && list.length > 0) { setActiveCalendarId(list[0].id); } }) .catch((e: Error) => { if (!cancelled) setError(e.message); }) .finally(() => { if (!cancelled) setLoadingCalendars(false); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Compute date range based on viewMode const range = useMemo(() => { if (viewMode === 'month') { const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1); const dayOfWeek = (first.getDay() + 6) % 7; const start = new Date(first); start.setDate(first.getDate() - dayOfWeek); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(start.getDate() + 42); return { start, end }; } if (viewMode === 'week') { const start = new Date(visibleWeek); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(start.getDate() + 7); return { start, end }; } if (viewMode === 'day') { const start = new Date(visibleDay); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(start.getDate() + 1); return { start, end }; } // range const start = rangeStart ? new Date(rangeStart) : new Date(); start.setHours(0, 0, 0, 0); const end = rangeEnd ? new Date(rangeEnd) : new Date(start); end.setHours(23, 59, 59, 999); end.setDate(end.getDate() + 1); return { start, end }; }, [viewMode, visibleMonth, visibleWeek, visibleDay, rangeStart, rangeEnd]); const loadEntries = useCallback(async () => { setLoading(true); setError(null); try { const list = await listEntries({ start: range.start.toISOString(), end: range.end.toISOString(), }); setEntries(list); } catch (e) { setError((e as Error).message ?? t('calendar.errorLoad')); } finally { setLoading(false); } }, [range.start, range.end, t]); useEffect(() => { void loadEntries(); }, [loadEntries]); // Filter entries by visible calendars const filteredEntries = useMemo(() => { if (visibleCalendarIds.size === 0) return entries; return entries.filter((e) => visibleCalendarIds.has(e.calendar_id)); }, [entries, visibleCalendarIds]); const activeCalendar = calendars.find((c) => c.id === activeCalendarId) ?? null; const selectedCalendar = selectedEntry ? calendars.find((c) => c.id === selectedEntry.calendar_id) ?? null : null; // ??? Handlers ??????????????????????????????????????????????????????????? const handleCreateAt = useCallback((date: Date) => { setModalEntry(null); setPrefillDate(date); setModalOpen(true); }, []); const handleEditEntry = useCallback( (entry: CalendarEntry) => { setSelectedEntry(entry); if (showDetails) { setActiveView('details'); } }, [setSelectedEntry, showDetails], ); const handleOpenEditModal = useCallback(() => { if (!selectedEntry) return; setModalEntry(selectedEntry); setPrefillDate(null); setModalOpen(true); }, [selectedEntry]); const handleMoveEntry = useCallback( async (entry: CalendarEntry, newStart: Date) => { try { const oldEnd = entry.end_at ? new Date(entry.end_at) : null; const oldStart = entry.start_at ? new Date(entry.start_at) : null; const durationMs = oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000; const newEnd = new Date(newStart.getTime() + durationMs); const updated = await updateEntry(entry.id, { start_at: newStart.toISOString(), end_at: newEnd.toISOString(), }); setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e))); if (selectedEntry?.id === updated.id) { setSelectedEntry(updated); } } catch (e) { setError((e as Error).message ?? t('calendar.errorSave')); await loadEntries(); } }, [t, loadEntries, selectedEntry, setSelectedEntry], ); const handleSaved = useCallback( (saved: CalendarEntry) => { setEntries((cur) => { const exists = cur.some((e) => e.id === saved.id); return exists ? cur.map((e) => (e.id === saved.id ? saved : e)) : [...cur, saved]; }); if (selectedEntry?.id === saved.id) { setSelectedEntry(saved); } }, [selectedEntry, setSelectedEntry], ); const handleDeleted = useCallback( (entryId: string) => { setEntries((cur) => cur.filter((e) => e.id !== entryId)); if (selectedEntry?.id === entryId) { setSelectedEntry(null); } }, [selectedEntry, setSelectedEntry], ); const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; try { await deleteEntry(deleteTarget.id); toast.success(t('calendar.detail.delete')); handleDeleted(deleteTarget.id); setDeleteTarget(null); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [deleteTarget, handleDeleted, toast, t]); const handleDeleteFromDetail = useCallback(() => { if (!selectedEntry) return; setDeleteTarget(selectedEntry); }, [selectedEntry]); const handleEntryChanged = useCallback( (updated: CalendarEntry) => { setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e))); if (selectedEntry?.id === updated.id) { setSelectedEntry(updated); } }, [selectedEntry, setSelectedEntry], ); const handleCreateCalendar = useCallback(async () => { const name = window.prompt(t('calendar.tree.newCalendar')); if (!name?.trim()) return; try { const created = await createCalendar({ name: name.trim() }); setCalendars([...calendars, created]); setActiveCalendarId(created.id); toast.success(t('calendar.tree.newCalendar')); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [t, toast, setCalendars, setActiveCalendarId, calendars]); const handleSelectCalendar = useCallback( (id: string) => { setActiveCalendarId(id); setActiveView('calendar'); }, [setActiveCalendarId], ); // ??? Toolbar registration ??????????????????????????????????????????????? const registerItems = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); useEffect(() => { const items = [ // Navigation group { id: 'nav-prev', plugin: 'calendar', label: t('calendar.navigation.prev'), group: 'navigation', onClick: viewMode === 'month' ? goToPrevMonth : viewMode === 'week' ? goToPrevWeek : viewMode === 'day' ? goToPrevDay : () => {}, icon: ( ), }, { id: 'nav-today', plugin: 'calendar', label: t('calendar.navigation.today'), group: 'navigation', onClick: goToToday, }, { id: 'nav-next', plugin: 'calendar', label: t('calendar.navigation.next'), group: 'navigation', onClick: viewMode === 'month' ? goToNextMonth : viewMode === 'week' ? goToNextWeek : viewMode === 'day' ? goToNextDay : () => {}, icon: ( ), }, // View mode group { id: 'view-mode', plugin: 'calendar', label: t('calendar.viewMonth'), group: 'view-mode', type: 'select' as const, selectValue: viewMode, selectOptions: [ { value: 'day', label: t('calendar.viewDay') }, { value: 'week', label: t('calendar.viewWeek') }, { value: 'month', label: t('calendar.viewMonth') }, { value: 'range', label: t('calendar.viewRange') }, ], onSelect: (value: string) => setViewMode(value as CalendarViewMode), onClick: () => {}, }, // Actions group { id: 'new-appointment', plugin: 'calendar', label: t('calendar.newAppointment'), group: 'actions', onClick: () => { setModalEntry(null); setPrefillDate(null); setModalOpen(true); }, icon: ( ), }, { id: 'ics-import', plugin: 'calendar', label: t('calendar.ics.import'), group: 'actions', onClick: () => setShowIcs((v) => !v), }, { id: 'ics-export', plugin: 'calendar', label: t('calendar.ics.export'), group: 'actions', onClick: () => setShowIcs((v) => !v), }, // Details toggle group { id: 'toggle-details', plugin: 'calendar', label: t('calendar.detail.title'), group: 'details', active: showDetails, onClick: () => setShowDetails((v) => !v), icon: ( ), }, ]; registerItems('calendar', items); return () => unregisterPlugin('calendar'); }, [ viewMode, showDetails, goToPrevMonth, goToNextMonth, goToPrevWeek, goToNextWeek, goToPrevDay, goToNextDay, goToToday, setViewMode, registerItems, unregisterPlugin, t, ]); // ??? Render ????????????????????????????????????????????????????????????? const renderCalendarView = () => { if (calendars.length === 0 && !loading) { return (

{t('calendar.noCalendars')}

{t('calendar.noEntries')}

); } switch (viewMode) { case 'day': return ( ); case 'week': return ( ); case 'range': return ( ); default: return ( ); } }; const renderRangeControls = () => { if (viewMode !== 'range') return null; return (
setRangeStart(e.target.value ? new Date(e.target.value) : null)} className="text-sm rounded-md border border-secondary-300 px-2 py-1" data-testid="calendar-range-start" /> setRangeEnd(e.target.value ? new Date(e.target.value) : null)} className="text-sm rounded-md border border-secondary-300 px-2 py-1" data-testid="calendar-range-end" />
); }; return (
{error && (

{error}

)} {/* ICS controls panel */} {showIcs && (
void loadEntries()} />
)} {/* Sharing settings panel */} {showSharing && activeCalendar && (
)} {/* Desktop: three-pane layout with resizable panels */}
{/* Left: CalendarTree */} {/* Middle: Calendar view */}
{renderRangeControls()}
{renderCalendarView()}
{/* Right: CalendarDetail (only when toggled on) */} {showDetails && ( { setSelectedEntry(null); setShowDetails(false); }} onEntryChanged={handleEntryChanged} /> )}
{/* Mobile: single-pane view switching */}
{/* View 1: CalendarTree */} {activeView === 'tree' && (

{t('calendar.tree.title')}

)} {/* View 2: Calendar */} {activeView === 'calendar' && (
{showDetails && ( )}
{renderRangeControls()}
{renderCalendarView()}
)} {/* View 3: CalendarDetail */} {activeView === 'details' && showDetails && (
{ setSelectedEntry(null); setActiveView('calendar'); }} onEntryChanged={handleEntryChanged} />
)}
{/* Delete confirmation */} setDeleteTarget(null)} /> {/* Appointment modal */} setModalOpen(false)} entry={modalEntry} prefillDate={prefillDate} calendars={calendars} defaultCalendarId={activeCalendarId} onSaved={handleSaved} onDeleted={handleDeleted} />
); } export default CalendarPage;