diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 94bb260..99fd875 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import FullCalendar from '@fullcalendar/react'; @@ -6,12 +7,13 @@ 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 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, EventTemplate } from '@/types'; +import type { CalendarEvent, EventTemplate, Location as LocationType } 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 { Dialog, DialogContent, @@ -20,6 +22,7 @@ import { } from '@/components/ui/dialog'; import CalendarSidebar from './CalendarSidebar'; import EventForm from './EventForm'; +import EventDetailPanel from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @@ -33,6 +36,7 @@ type ScopeAction = 'edit' | 'delete'; export default function CalendarPage() { const queryClient = useQueryClient(); + const location = useLocation(); const calendarRef = useRef(null); const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); @@ -45,6 +49,10 @@ export default function CalendarPage() { const [templateEvent, setTemplateEvent] = useState | null>(null); const [templateName, setTemplateName] = useState(null); + const [eventSearch, setEventSearch] = useState(''); + const [searchFocused, setSearchFocused] = useState(false); + const [selectedEventId, setSelectedEventId] = useState(null); + // Scope dialog state const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeAction, setScopeAction] = useState('edit'); @@ -55,6 +63,34 @@ export default function CalendarPage() { const { data: calendars = [] } = useCalendars(); const calendarContainerRef = useRef(null); + // Location data for event panel + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + staleTime: 5 * 60 * 1000, + }); + + const locationMap = useMemo(() => { + const map = new Map(); + locations.forEach((l) => map.set(l.id, l.name)); + return map; + }, [locations]); + + // Handle navigation state from dashboard + useEffect(() => { + const state = location.state as { date?: string; view?: string } | null; + if (!state?.date) return; + const calApi = calendarRef.current?.getApi(); + if (!calApi) return; + calApi.gotoDate(state.date); + if (state.view) calApi.changeView(state.view); + // Clear state to prevent re-triggering + window.history.replaceState({}, ''); + }, [location.state]); + // Resize FullCalendar when container size changes (e.g. sidebar collapse) useEffect(() => { const el = calendarContainerRef.current; @@ -94,6 +130,22 @@ export default function CalendarPage() { }, }); + const panelOpen = selectedEventId !== null; + const selectedEvent = useMemo( + () => events.find((e) => e.id === selectedEventId) ?? null, + [selectedEventId, events], + ); + + // Escape key closes detail panel + useEffect(() => { + if (!panelOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSelectedEventId(null); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [panelOpen]); + const visibleCalendarIds = useMemo( () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), [calendars], @@ -163,6 +215,7 @@ export default function CalendarPage() { toast.success('Event(s) deleted'); setScopeDialogOpen(false); setScopeEvent(null); + setSelectedEventId(null); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete event')); @@ -174,6 +227,28 @@ export default function CalendarPage() { 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 api = calendarRef.current?.getApi(); + if (!api) return; + const startDate = new Date(event.start_datetime); + api.gotoDate(startDate); + if (event.all_day) { + api.changeView('dayGridMonth'); + } else { + api.changeView('timeGridDay'); + } + setEventSearch(''); + setSearchFocused(false); + }; + const calendarEvents = filteredEvents.map((event) => ({ id: String(event.id), title: event.title, @@ -200,15 +275,7 @@ export default function CalendarPage() { toast.info(`${event.title} — from People contacts`); return; } - - if (isRecurring(event)) { - setScopeEvent(event); - setScopeAction('edit'); - setScopeDialogOpen(true); - } else { - setEditingEvent(event); - setShowForm(true); - } + setSelectedEventId(event.id); }; const handleScopeChoice = (scope: 'this' | 'this_and_future') => { @@ -312,13 +379,53 @@ export default function CalendarPage() { setCurrentView(arg.view.type as CalendarView); }; + // Panel actions + const handlePanelEdit = () => { + if (!selectedEvent) return; + if (isRecurring(selectedEvent)) { + setScopeEvent(selectedEvent); + setScopeAction('edit'); + setScopeDialogOpen(true); + } else { + setEditingEvent(selectedEvent); + setShowForm(true); + } + }; + + const handlePanelDelete = () => { + if (!selectedEvent) return; + if (isRecurring(selectedEvent)) { + setScopeEvent(selectedEvent); + setScopeAction('delete'); + setScopeDialogOpen(true); + } else { + panelDeleteMutation.mutate(selectedEvent.id as number); + } + }; + + const panelDeleteMutation = useMutation({ + mutationFn: async (id: number) => { + await api.delete(`/events/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + toast.success('Event deleted'); + setSelectedEventId(null); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }, + }); + 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 ( -
+
@@ -335,7 +442,52 @@ export default function CalendarPage() { -

{calendarTitle}

+

{calendarTitle}

+ +
+ + {/* Event search */} +
+ + 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 }) - } - /> - -
- -
-