From 898ecc407a767aacb5dc186703457a0db378a649 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 22:08:08 +0800 Subject: [PATCH] =?UTF-8?q?Stage=207:=20final=20polish=20=E2=80=94=20trans?= =?UTF-8?q?itions,=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 }) - } - /> - -
- -
-