From 898ecc407a767aacb5dc186703457a0db378a649 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 22:08:08 +0800 Subject: [PATCH 01/10] =?UTF-8?q?Stage=207:=20final=20polish=20=E2=80=94?= =?UTF-8?q?=20transitions,=20navigation,=20calendar=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add animate-fade-in page transitions to all pages - Persist sidebar collapsed state in localStorage - Add two-click logout confirmation using useConfirmAction - Restructure Todos header: replace setEventSearch(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setTimeout(() => setSearchFocused(false), 200)} + className="w-52 h-8 pl-8 text-sm" + /> + {searchFocused && searchResults.length > 0 && ( +
+ {searchResults.map((event) => ( + + ))} +
+ )} + + + +
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
- {/* Calendar grid */} -
-
- +
+
+ +
+
+ + {/* Detail panel (desktop) */} +
+ setSelectedEventId(null)} + locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} />
+ {/* Mobile detail panel overlay */} + {panelOpen && selectedEvent && ( +
setSelectedEventId(null)} + > +
e.stopPropagation()} + > + setSelectedEventId(null)} + locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} + /> +
+
+ )} + {showForm && ( void; + onDelete: () => void; + deleteLoading?: boolean; + onClose: () => void; + locationName?: string; +} + +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]; +} + +export default function EventDetailPanel({ + event, + onEdit, + onDelete, + deleteLoading = false, + onClose, + locationName, +}: EventDetailPanelProps) { + const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); + + if (!event) return null; + + const startDate = parseISO(event.start_datetime); + const endDate = event.end_datetime ? parseISO(event.end_datetime) : null; + const isRecurring = !!(event.is_recurring || event.parent_event_id); + + const startStr = 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; + + return ( +
+ {/* Header */} +
+
+
+
+

{event.title}

+ {event.calendar_name} +
+
+ +
+ + {/* Body */} +
+ {/* Calendar */} +
+

Calendar

+
+
+ {event.calendar_name} +
+
+ + {/* Start */} +
+

Start

+ +
+ + {/* End */} + {endStr && ( +
+

End

+ +
+ )} + + {/* Location */} + {locationName && ( +
+

Location

+ +
+ )} + + {/* Description */} + {event.description && ( +
+

Description

+
+ +

{event.description}

+
+
+ )} + + {/* Recurrence */} + {isRecurring && event.recurrence_rule && ( +
+

Recurrence

+
+ + {formatRecurrenceRule(event.recurrence_rule)} +
+
+ )} + + {isRecurring && !event.recurrence_rule && ( +
+

Recurrence

+
+ + Recurring event +
+
+ )} +
+ + {/* Footer */} + {!event.is_virtual && ( +
+ + {formatUpdatedAt(event.updated_at)} + +
+ + {confirming ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/CalendarWidget.tsx b/frontend/src/components/dashboard/CalendarWidget.tsx index 688a202..89b8cd2 100644 --- a/frontend/src/components/dashboard/CalendarWidget.tsx +++ b/frontend/src/components/dashboard/CalendarWidget.tsx @@ -1,4 +1,5 @@ import { format } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; import { Calendar } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -17,6 +18,9 @@ interface CalendarWidgetProps { } export default function CalendarWidget({ events }: CalendarWidgetProps) { + const navigate = useNavigate(); + const todayStr = format(new Date(), 'yyyy-MM-dd'); + return ( @@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) { {events.map((event) => (
navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay' } })} + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" >
differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0); if (visible.length === 0) return null; @@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) { {visible.map((event) => { const days = differenceInCalendarDays(new Date(event.start_datetime), new Date()); const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`; + const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd'); return ( -
+
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } })} + className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150" + > {label} diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index fa3c202..e3e5578 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react'; @@ -33,6 +34,7 @@ function getGreeting(name?: string): string { } export default function DashboardPage() { + const navigate = useNavigate(); const { settings } = useSettings(); const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts(); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); @@ -234,7 +236,8 @@ export default function DashboardPage() { {futureReminders.map((reminder) => (
navigate('/reminders')} + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" >
{reminder.title} diff --git a/frontend/src/components/dashboard/StatsWidget.tsx b/frontend/src/components/dashboard/StatsWidget.tsx index 4c7c815..34d09a9 100644 --- a/frontend/src/components/dashboard/StatsWidget.tsx +++ b/frontend/src/components/dashboard/StatsWidget.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; @@ -11,6 +12,8 @@ interface StatsWidgetProps { } export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) { + const navigate = useNavigate(); + const statCards = [ { label: 'PROJECTS', @@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe icon: FolderKanban, color: 'text-blue-400', glowBg: 'bg-blue-500/10', + onClick: () => navigate('/projects'), }, { label: 'IN PROGRESS', @@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe icon: TrendingUp, color: 'text-purple-400', glowBg: 'bg-purple-500/10', + onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }), }, { label: 'OPEN TODOS', @@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe icon: CheckSquare, color: 'text-teal-400', glowBg: 'bg-teal-500/10', + onClick: () => navigate('/todos'), }, ]; return (
{statCards.map((stat) => ( - +
diff --git a/frontend/src/components/dashboard/TodoWidget.tsx b/frontend/src/components/dashboard/TodoWidget.tsx index b81b29c..fc28dd1 100644 --- a/frontend/src/components/dashboard/TodoWidget.tsx +++ b/frontend/src/components/dashboard/TodoWidget.tsx @@ -1,4 +1,5 @@ import { format, isPast, endOfDay } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; import { CheckCircle2 } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -29,6 +30,8 @@ const dotColors: Record = { }; export default function TodoWidget({ todos }: TodoWidgetProps) { + const navigate = useNavigate(); + return ( @@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) { return (
navigate('/todos')} + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" >
{todo.title} diff --git a/frontend/src/components/dashboard/UpcomingWidget.tsx b/frontend/src/components/dashboard/UpcomingWidget.tsx index e09ff81..d3cba4e 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -1,4 +1,5 @@ import { format } from 'date-fns'; +import { useNavigate } from 'react-router-dom'; import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react'; import type { UpcomingItem } from '@/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -17,6 +18,26 @@ const typeConfig: Record { + switch (item.type) { + case 'event': { + const dateStr = item.datetime + ? format(new Date(item.datetime), 'yyyy-MM-dd') + : item.date; + navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } }); + break; + } + case 'todo': + navigate('/todos'); + break; + case 'reminder': + navigate('/reminders'); + break; + } + }; + return ( @@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) return (
handleItemClick(item)} + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" > {item.title} diff --git a/frontend/src/components/dashboard/WeekTimeline.tsx b/frontend/src/components/dashboard/WeekTimeline.tsx index d19f988..582a5ef 100644 --- a/frontend/src/components/dashboard/WeekTimeline.tsx +++ b/frontend/src/components/dashboard/WeekTimeline.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns'; import type { UpcomingItem } from '@/types'; import { cn } from '@/lib/utils'; @@ -14,6 +15,7 @@ const typeColors: Record = { }; export default function WeekTimeline({ items }: WeekTimelineProps) { + const navigate = useNavigate(); const today = useMemo(() => startOfDay(new Date()), []); const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]); @@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) { {days.map((day) => (
navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })} className={cn( - 'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border', + 'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer', day.isToday ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' : day.isPast - ? 'border-transparent opacity-50' + ? 'border-transparent opacity-50 hover:opacity-75' : 'border-transparent hover:border-border/50' )} > diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index ddd1929..22a173a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay'; export default function AppLayout() { useTheme(); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(() => { + try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } + catch { return false; } + }); const [mobileOpen, setMobileOpen] = useState(false); return ( @@ -19,7 +22,11 @@ export default function AppLayout() {
setCollapsed(!collapsed)} + onToggle={() => { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); + }} mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 50d0e51..e269f9e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -20,6 +20,7 @@ import { import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; import { useLock } from '@/hooks/useLock'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; import { Button } from '@/components/ui/button'; import api from '@/lib/api'; import type { Project } from '@/types'; @@ -57,10 +58,12 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose select: (data) => data.map(({ id, name }) => ({ id, name })), }); - const handleLogout = async () => { + const doLogout = useCallback(async () => { await logout(); navigate('/login'); - }; + }, [logout, navigate]); + + const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout); const isProjectsActive = location.pathname.startsWith('/projects'); const showExpanded = !collapsed || mobileOpen; @@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index ea5c090..d6c488a 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -283,7 +283,7 @@ export default function LocationsPage() { ); return ( -
+
{/* Header */}

Locations

diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 54ae755..447968c 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -400,7 +400,7 @@ export default function PeoplePage() { ); return ( -
+
{/* Header */}

People

diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 7a1e519..867d9e8 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -344,7 +344,7 @@ export default function ProjectDetail() { if (isLoading) { return ( -
+
+ {categories.map((cat) => ( + + ))} +
+ + {/* Completed toggle */} + + +
+ +
-
- - -
- -
- - setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked }) - } - /> - -
- -
- + +
+ {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( + + ))} +
+

{calendarTitle}

@@ -483,30 +435,10 @@ export default function CalendarPage() { )}
- - -
- {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( - - ))} -
{/* Calendar grid + event detail panel */} @@ -549,11 +481,11 @@ export default function CalendarPage() { }`} > setSelectedEventId(null)} + event={panelMode === 'view' ? selectedEvent : null} + isCreating={panelMode === 'create'} + createDefaults={createDefaults} + onClose={handlePanelClose} + onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} />
@@ -561,79 +493,26 @@ export default function CalendarPage() {
{/* Mobile detail panel overlay */} - {panelOpen && selectedEvent && ( + {panelOpen && (
setSelectedEventId(null)} + onClick={handlePanelClose} >
e.stopPropagation()} > setSelectedEventId(null)} + event={panelMode === 'view' ? selectedEvent : null} + isCreating={panelMode === 'create'} + createDefaults={createDefaults} + onClose={handlePanelClose} + onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} />
)} - - {showForm && ( - - )} - - {/* Recurring event scope dialog */} - - - - - {scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} - - -

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

-
- - - -
-
-
); } diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index bfa420d..0b71fd9 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -1,18 +1,69 @@ +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, Clock, MapPin, AlignLeft, Repeat } from 'lucide-react'; -import type { CalendarEvent } from '@/types'; -import { Button } from '@/components/ui/button'; +import { + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, +} from 'lucide-react'; +import api, { getErrorMessage } from '@/lib/api'; +import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; import { useConfirmAction } from '@/hooks/useConfirmAction'; import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +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'; -interface EventDetailPanelProps { - event: CalendarEvent | null; - onEdit: () => void; - onDelete: () => void; - deleteLoading?: boolean; - onClose: () => void; - locationName?: string; +// --- 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 { @@ -44,162 +95,805 @@ function ordinal(n: number): string { 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; +} + +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, - onEdit, - onDelete, - deleteLoading = false, + isCreating = false, + createDefaults, onClose, + onSaved, + onDeleted, locationName, }: EventDetailPanelProps) { - const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); + const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); + const selectableCalendars = calendars.filter((c) => !c.is_system); + const defaultCalendar = calendars.find((c) => c.is_default); - if (!event) return null; + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); - const startDate = parseISO(event.start_datetime); - const endDate = event.end_datetime ? parseISO(event.end_datetime) : null; - const isRecurring = !!(event.is_recurring || event.parent_event_id); + const [isEditing, setIsEditing] = useState(false); + 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(''); - const startStr = event.all_day - ? format(startDate, 'EEEE, MMMM d, yyyy') - : format(startDate, 'EEEE, MMMM d, yyyy · h:mm a'); + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); + // Reset state when event changes + useEffect(() => { + setIsEditing(false); + setScopeStep(null); + setEditScope(null); + setLocationSearch(''); + 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: () => { + invalidateAll(); + toast.success(isCreating ? 'Event created' : 'Event updated'); + if (isCreating) { + onClose(); + } else { + setIsEditing(false); + setEditScope(null); + } + onSaved?.(); + }, + 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 = () => { + 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); + // The deleteMutation will read editScope, but we need to set it first + // Since setState is async, use the mutation directly with the scope + 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 = () => { + 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 + ? 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 */} -
-
-
-
-

{event.title}

- {event.calendar_name} +
+
+ {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 ? ( + <> + + + + ) : ( + <> + {!event?.is_virtual && ( + <> + + {confirmingDelete ? ( + + ) : ( + + )} + + )} + + + )}
-
{/* Body */}
- {/* Calendar */} -
-

Calendar

-
-
- {event.calendar_name} -
-
- - {/* Start */} -
-

Start

- -
- - {/* End */} - {endStr && ( -
-

End

- -
- )} - - {/* Location */} - {locationName && ( -
-

Location

- -
- )} - - {/* Description */} - {event.description && ( -
-

Description

-
- -

{event.description}

+ {scopeStep ? ( + /* Scope selection step */ +
+

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

+
+ + +
- )} + ) : isEditing ? ( + /* 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 + /> +
+ )} + +
+ +