From 9635401fe83963e2003c1d7e2b778f8733a6ff27 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 11 Mar 2026 21:07:14 +0800 Subject: [PATCH 01/12] Redesign Upcoming Widget with day groups, status pills, and inline actions Backend: Include overdue todos and snoozed reminders in /upcoming response, add end_datetime/snoozed_until/is_overdue fields, widen snooze schema to accept 1-1440 minutes for 1h/3h/tomorrow options. Frontend: Full UpcomingWidget rewrite with sticky day separators (Today highlighted in accent), collapsible groups, past-event toggle, focus mode (Today + Tomorrow), color-coded left borders, compact type pills, relative time for today's items, item count badge, and inline quick actions (complete todo, snooze/dismiss reminder on hover). Card fills available height with no dead space. DashboardPage always renders widget (no duplicate empty state). Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/dashboard.py | 39 +- backend/app/schemas/reminder.py | 2 +- .../src/components/dashboard/AlertBanner.tsx | 2 +- .../components/dashboard/DashboardPage.tsx | 15 +- .../components/dashboard/UpcomingWidget.tsx | 396 ++++++++++++++++-- .../components/reminders/SnoozeDropdown.tsx | 9 +- frontend/src/hooks/useAlerts.tsx | 6 +- frontend/src/types/index.ts | 3 + 8 files changed, 391 insertions(+), 81 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 18190db..1a0a74e 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -156,12 +156,14 @@ async def get_dashboard( async def get_upcoming( days: int = Query(default=7, ge=1, le=90), client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), + include_past: bool = Query(default=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() + now = datetime.now() cutoff_date = today + timedelta(days=days) cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) @@ -169,35 +171,35 @@ async def get_upcoming( # Subquery: calendar IDs belonging to this user user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) - # Get upcoming todos with due dates (today onward only, scoped to user) + # Build queries — include overdue todos and snoozed reminders todos_query = select(Todo).where( Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), - Todo.due_date >= today, Todo.due_date <= cutoff_date ) - todos_result = await db.execute(todos_query) - todos = todos_result.scalars().all() - # Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars) events_query = select(CalendarEvent).where( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, ) - events_result = await db.execute(events_query) - events = events_result.scalars().all() - # Get upcoming reminders (today onward only, scoped to user) reminders_query = select(Reminder).where( Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, - Reminder.remind_at >= today_start, Reminder.remind_at <= cutoff_datetime ) + + # Execute queries sequentially (single session cannot run concurrent queries) + todos_result = await db.execute(todos_query) + todos = todos_result.scalars().all() + + events_result = await db.execute(events_query) + events = events_result.scalars().all() + reminders_result = await db.execute(reminders_query) reminders = reminders_result.scalars().all() @@ -212,28 +214,39 @@ async def get_upcoming( "date": todo.due_date.isoformat() if todo.due_date else None, "datetime": None, "priority": todo.priority, - "category": todo.category + "category": todo.category, + "is_overdue": todo.due_date < today if todo.due_date else False, }) for event in events: + end_dt = event.end_datetime + # When include_past=False, filter out past events + if not include_past and end_dt: + if end_dt < now: + continue + upcoming_items.append({ "type": "event", "id": event.id, "title": event.title, "date": event.start_datetime.date().isoformat(), "datetime": event.start_datetime.isoformat(), + "end_datetime": end_dt.isoformat() if end_dt else None, "all_day": event.all_day, "color": event.color, - "is_starred": event.is_starred + "is_starred": event.is_starred, }) for reminder in reminders: + remind_at_date = reminder.remind_at.date() if reminder.remind_at else None upcoming_items.append({ "type": "reminder", "id": reminder.id, "title": reminder.title, - "date": reminder.remind_at.date().isoformat(), - "datetime": reminder.remind_at.isoformat() + "date": remind_at_date.isoformat() if remind_at_date else None, + "datetime": reminder.remind_at.isoformat() if reminder.remind_at else None, + "snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None, + "is_overdue": remind_at_date < today if remind_at_date else False, }) # Sort by date/datetime diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py index f639c75..8198ab0 100644 --- a/backend/app/schemas/reminder.py +++ b/backend/app/schemas/reminder.py @@ -27,7 +27,7 @@ class ReminderUpdate(BaseModel): class ReminderSnooze(BaseModel): model_config = ConfigDict(extra="forbid") - minutes: Literal[5, 10, 15] + minutes: int = Field(ge=1, le=1440) client_now: Optional[datetime] = None diff --git a/frontend/src/components/dashboard/AlertBanner.tsx b/frontend/src/components/dashboard/AlertBanner.tsx index f098851..f9f946c 100644 --- a/frontend/src/components/dashboard/AlertBanner.tsx +++ b/frontend/src/components/dashboard/AlertBanner.tsx @@ -6,7 +6,7 @@ import type { Reminder } from '@/types'; interface AlertBannerProps { alerts: Reminder[]; onDismiss: (id: number) => void; - onSnooze: (id: number, minutes: 5 | 10 | 15) => void; + onSnooze: (id: number, minutes: number) => void; } export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBannerProps) { diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 59fb09f..6902b39 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -190,20 +190,7 @@ export default function DashboardPage() {
{/* Left: Upcoming feed (wider) */}
- {upcomingData && upcomingData.items.length > 0 ? ( - - ) : ( - - - Upcoming - - -

- Nothing upcoming. Enjoy the quiet. -

-
-
- )} +
{/* Right: Countdown + Today's events + todos stacked */} diff --git a/frontend/src/components/dashboard/UpcomingWidget.tsx b/frontend/src/components/dashboard/UpcomingWidget.tsx index 01b13c5..fd18d93 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -1,26 +1,131 @@ -import { format } from 'date-fns'; +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { format, isToday, isTomorrow, isThisWeek } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { + ArrowRight, + Eye, + EyeOff, + Target, + ChevronRight, + Check, + Clock, + X, +} from 'lucide-react'; import type { UpcomingItem } from '@/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +import { getRelativeTime } from '@/lib/date-utils'; +import { toLocalDatetime } from '@/lib/date-utils'; +import api from '@/lib/api'; interface UpcomingWidgetProps { items: UpcomingItem[]; days?: number; } -const typeConfig: Record = { - todo: { icon: CheckSquare, color: 'text-blue-400', label: 'TODO' }, - event: { icon: Calendar, color: 'text-purple-400', label: 'EVENT' }, - reminder: { icon: Bell, color: 'text-orange-400', label: 'REMINDER' }, +const typeConfig: Record = { + todo: { borderColor: 'border-l-blue-400', pillBg: 'bg-blue-500/15', pillText: 'text-blue-400', label: 'TODO' }, + event: { borderColor: 'border-l-purple-400', pillBg: 'bg-purple-500/15', pillText: 'text-purple-400', label: 'EVENT' }, + reminder: { borderColor: 'border-l-orange-400', pillBg: 'bg-orange-500/15', pillText: 'text-orange-400', label: 'REMINDER' }, }; -export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) { - const navigate = useNavigate(); +function getMinutesUntilTomorrowMorning(): number { + const now = new Date(); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0); + return Math.max(1, Math.round((tomorrow.getTime() - now.getTime()) / 60000)); +} - const handleItemClick = (item: UpcomingItem) => { +function getDayLabel(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + if (isToday(d)) return 'Today'; + if (isTomorrow(d)) return 'Tomorrow'; + if (isThisWeek(d, { weekStartsOn: 1 })) return format(d, 'EEEE'); + return format(d, 'EEE, MMM d'); +} + +function isEventPast(item: UpcomingItem, now: Date): boolean { + if (item.type !== 'event') return false; + const endStr = item.end_datetime || item.datetime; + if (!endStr) return false; + return new Date(endStr) < now; +} + +export default function UpcomingWidget({ items }: UpcomingWidgetProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [showPast, setShowPast] = useState(false); + const [focusMode, setFocusMode] = useState(false); + const [collapsedDays, setCollapsedDays] = useState>(new Set()); + const [clientNow, setClientNow] = useState(() => new Date()); + const [hoveredItem, setHoveredItem] = useState(null); + const [snoozeOpen, setSnoozeOpen] = useState(null); + + // Update clientNow every 60s for past-event detection + useEffect(() => { + const interval = setInterval(() => setClientNow(new Date()), 60_000); + return () => clearInterval(interval); + }, []); + + // Toggle todo completion + const toggleTodo = useMutation({ + mutationFn: (id: number) => api.patch(`/todos/${id}/toggle`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + toast.success('Todo completed'); + }, + }); + + // Snooze reminder + const snoozeReminder = useMutation({ + mutationFn: ({ id, minutes }: { id: number; minutes: number }) => + api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + toast.success('Reminder snoozed'); + }, + }); + + // Dismiss reminder + const dismissReminder = useMutation({ + mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + toast.success('Reminder dismissed'); + }, + }); + + // Filter and group items + const { grouped, filteredCount } = useMemo(() => { + let filtered = items.filter((item) => { + // Hide past events unless toggle is on + if (!showPast && isEventPast(item, clientNow)) return false; + return true; + }); + + // Focus mode: only Today + Tomorrow + if (focusMode) { + filtered = filtered.filter((item) => { + const d = new Date(item.date + 'T00:00:00'); + return isToday(d) || isTomorrow(d); + }); + } + + const map = new Map(); + for (const item of filtered) { + const key = item.date; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(item); + } + + return { grouped: map, filteredCount: filtered.length }; + }, [items, showPast, focusMode, clientNow]); + + const handleItemClick = useCallback((item: UpcomingItem) => { switch (item.type) { case 'event': { const dateStr = item.datetime @@ -36,10 +141,39 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) navigate('/reminders', { state: { reminderId: item.id } }); break; } - }; + }, [navigate]); + + const toggleDay = useCallback((dateKey: string) => { + setCollapsedDays((prev) => { + const next = new Set(prev); + if (next.has(dateKey)) next.delete(dateKey); + else next.add(dateKey); + return next; + }); + }, []); + + const handleSnooze = useCallback((id: number, minutes: number, e: React.MouseEvent) => { + e.stopPropagation(); + setSnoozeOpen(null); + snoozeReminder.mutate({ id, minutes }); + }, [snoozeReminder]); + + const formatTime = useCallback((item: UpcomingItem) => { + if (item.type === 'todo') { + const d = new Date(item.date + 'T00:00:00'); + if (isToday(d)) return 'Due today'; + return null; // date shown in header + } + if (!item.datetime) return null; + const d = new Date(item.date + 'T00:00:00'); + if (isToday(d)) return getRelativeTime(item.datetime); + return format(new Date(item.datetime), 'h:mm a'); + }, []); + + const dayEntries = Array.from(grouped.entries()); return ( - +
@@ -47,47 +181,219 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
Upcoming + + {filteredCount} {filteredCount === 1 ? 'item' : 'items'} + - {days} days +
+ + +
- - {items.length === 0 ? ( + + {filteredCount === 0 ? (

- Nothing upcoming + {focusMode ? 'Nothing for today or tomorrow' : 'Nothing upcoming'}

) : ( - -
- {items.map((item, index) => { - const config = typeConfig[item.type] || typeConfig.todo; - const Icon = config.icon; + +
+ {dayEntries.map(([dateKey, dayItems], groupIdx) => { + const isCollapsed = collapsedDays.has(dateKey); + const label = getDayLabel(dateKey); + const isTodayGroup = isToday(new Date(dateKey + 'T00:00:00')); + 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} - - {item.datetime - ? format(new Date(item.datetime), 'MMM d, h:mm a') - : format(new Date(item.date), 'MMM d')} - - - +
+ {/* Sticky day header */} + + + {/* Day items */} + {!isCollapsed && ( +
+ {dayItems.map((item, idx) => { + const config = typeConfig[item.type] || typeConfig.todo; + const itemKey = `${item.type}-${item.id}-${idx}`; + const isPast = isEventPast(item, clientNow); + const isHovered = hoveredItem === itemKey; + const timeLabel = formatTime(item); + + return ( +
handleItemClick(item)} + onMouseEnter={() => setHoveredItem(itemKey)} + onMouseLeave={() => { setHoveredItem(null); setSnoozeOpen(null); }} + className={cn( + 'flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer border-l-2', + config.borderColor, + isPast && 'opacity-50' + )} + > + {/* Title */} + {item.title} + + {/* Status pills */} + {item.is_overdue && item.type === 'todo' && ( + + OVERDUE + + )} + {item.type === 'reminder' && item.snoozed_until && ( + + SNOOZED + + )} + {item.type === 'reminder' && item.is_overdue && !item.snoozed_until && ( + + OVERDUE + + )} + + {/* Inline quick actions (desktop hover) */} + {isHovered && item.type === 'todo' && ( + + )} + + {isHovered && item.type === 'reminder' && ( +
+ {/* Snooze button with dropdown */} +
+ + {snoozeOpen === itemKey && ( +
+ + + +
+ )} +
+ {/* Dismiss button */} + +
+ )} + + {/* Time label */} + {timeLabel && !isHovered && ( + + {timeLabel} + + )} + + {/* Type pill */} + {!isHovered && ( + + )} + + {/* Priority pill (todos only) */} + {!isHovered && item.priority && item.priority !== 'none' && ( + + )} +
+ ); + })} +
+ )}
); })} diff --git a/frontend/src/components/reminders/SnoozeDropdown.tsx b/frontend/src/components/reminders/SnoozeDropdown.tsx index 97d1ebc..7363cfd 100644 --- a/frontend/src/components/reminders/SnoozeDropdown.tsx +++ b/frontend/src/components/reminders/SnoozeDropdown.tsx @@ -2,18 +2,19 @@ import { useState, useRef, useEffect } from 'react'; import { Clock } from 'lucide-react'; interface SnoozeDropdownProps { - onSnooze: (minutes: 5 | 10 | 15) => void; + onSnooze: (minutes: number) => void; label: string; direction?: 'up' | 'down'; + options?: { value: number; label: string }[]; } -const OPTIONS: { value: 5 | 10 | 15; label: string }[] = [ +const DEFAULT_OPTIONS: { value: number; label: string }[] = [ { value: 5, label: '5 minutes' }, { value: 10, label: '10 minutes' }, { value: 15, label: '15 minutes' }, ]; -export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: SnoozeDropdownProps) { +export default function SnoozeDropdown({ onSnooze, label, direction = 'up', options = DEFAULT_OPTIONS }: SnoozeDropdownProps) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -51,7 +52,7 @@ export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: Sn
- {OPTIONS.map((opt) => ( + {options.map((opt) => ( {snoozeOpen === itemKey && ( -
+
diff --git a/frontend/src/components/dashboard/TodoWidget.tsx b/frontend/src/components/dashboard/TodoWidget.tsx index da3bcea..1afab55 100644 --- a/frontend/src/components/dashboard/TodoWidget.tsx +++ b/frontend/src/components/dashboard/TodoWidget.tsx @@ -33,7 +33,7 @@ export default function TodoWidget({ todos }: TodoWidgetProps) { const navigate = useNavigate(); return ( - +
From 8b6530c9013c4d0ccc49c4c1967606731ae79af3 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 11 Mar 2026 23:29:37 +0800 Subject: [PATCH 10/12] Fix hover jitter by overlaying actions instead of swapping content The type pill, time label, and priority pill were being removed on hover and replaced with action buttons, causing layout reflow and visible jitter. Now the labels stay rendered (invisible when hovered for todos/reminders) to hold their space, and action buttons are absolutely positioned on top. Events show no actions so their labels stay visible on hover. Zero layout shift. Co-Authored-By: Claude Opus 4.6 --- .../components/dashboard/UpcomingWidget.tsx | 169 +++++++++--------- 1 file changed, 84 insertions(+), 85 deletions(-) diff --git a/frontend/src/components/dashboard/UpcomingWidget.tsx b/frontend/src/components/dashboard/UpcomingWidget.tsx index 7a18533..2d821ea 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -299,100 +299,99 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { )} - {/* Inline quick actions (desktop hover) */} - {isHovered && item.type === 'todo' && ( - - )} + {/* Right side: static info + action overlay */} +
+ {/* Always-rendered labels (stable layout) */} +
+ {timeLabel && ( + + {timeLabel} + + )} + + {item.priority && item.priority !== 'none' && ( + + )} +
- {isHovered && item.type === 'reminder' && ( -
- {/* Snooze button with dropdown */} -
+ {/* Action buttons overlaid in same space */} + {isHovered && item.type === 'todo' && ( +
- {snoozeOpen === itemKey && ( -
- - - -
- )}
- {/* Dismiss button */} - -
- )} + )} - {/* Time label */} - {timeLabel && !isHovered && ( - - {timeLabel} - - )} - - {/* Type pill */} - {!isHovered && ( - - )} - - {/* Priority pill (todos only) */} - {!isHovered && item.priority && item.priority !== 'none' && ( - - )} + {isHovered && item.type === 'reminder' && ( +
+
+ + {snoozeOpen === itemKey && ( +
+ + + +
+ )} +
+ +
+ )} +
); })} From b41b0b66353999ea2f021346818b21975c979e44 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 00:02:04 +0800 Subject: [PATCH 11/12] Add dashboard polish: micro-animations, visual upgrades, and interactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 1+2 implementation (17 items): plus button rotation, card hover glow consistency, DayBriefing container with Sparkles icon, WeekTimeline hover scale + pulsing today dot + dot tooltips, countdown urgency scaling, CalendarWidget time progress bar + current event highlight + empty state, TodoWidget inline complete + empty state, dashboard auto-refresh (2min), optimistic todo completion, "Updated Xm ago" with refresh button, keyboard quick-add (Ctrl+N → e/t/r), progress rings on stat cards, staggered row entrance in Upcoming, content crossfade, prefers-reduced-motion support, ARIA attributes on dropdown menu, and hover:bg-card-elevated consistency. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/dashboard.py | 7 ++ .../components/dashboard/CalendarWidget.tsx | 99 ++++++++++++---- .../components/dashboard/CountdownWidget.tsx | 11 +- .../components/dashboard/DashboardPage.tsx | 106 ++++++++++++++++-- .../src/components/dashboard/DayBriefing.tsx | 10 +- .../src/components/dashboard/StatsWidget.tsx | 61 ++++++++-- .../src/components/dashboard/TodoWidget.tsx | 72 +++++++++--- .../dashboard/TrackedProjectsWidget.tsx | 2 +- .../components/dashboard/UpcomingWidget.tsx | 28 ++++- .../src/components/dashboard/WeekTimeline.tsx | 10 +- frontend/src/index.css | 39 +++++++ frontend/src/types/index.ts | 1 + 12 files changed, 377 insertions(+), 69 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 45a6773..c83ce7b 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -93,6 +93,12 @@ async def get_dashboard( ) total_incomplete_todos = total_incomplete_result.scalar() + # Total todos count (for progress ring ratio) + total_todos_result = await db.execute( + select(func.count(Todo.id)).where(Todo.user_id == current_user.id) + ) + total_todos = total_todos_result.scalar() + # Starred events (upcoming, ordered by date, scoped to user's calendars) starred_query = select(CalendarEvent).where( CalendarEvent.calendar_id.in_(user_calendar_ids), @@ -148,6 +154,7 @@ async def get_dashboard( "by_status": projects_by_status }, "total_incomplete_todos": total_incomplete_todos, + "total_todos": total_todos, "starred_events": starred_events_data } diff --git a/frontend/src/components/dashboard/CalendarWidget.tsx b/frontend/src/components/dashboard/CalendarWidget.tsx index 6d8270d..8703527 100644 --- a/frontend/src/components/dashboard/CalendarWidget.tsx +++ b/frontend/src/components/dashboard/CalendarWidget.tsx @@ -1,7 +1,9 @@ +import { useState, useEffect } from 'react'; 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'; +import { cn } from '@/lib/utils'; interface DashboardEvent { id: number; @@ -17,12 +19,38 @@ interface CalendarWidgetProps { events: DashboardEvent[]; } +function getEventTimeState(event: DashboardEvent, now: Date) { + if (event.all_day) return 'all-day' as const; + const start = new Date(event.start_datetime).getTime(); + const end = new Date(event.end_datetime).getTime(); + const current = now.getTime(); + if (current >= end) return 'past' as const; + if (current >= start && current < end) return 'current' as const; + return 'future' as const; +} + +function getProgressPercent(event: DashboardEvent, now: Date): number { + if (event.all_day) return 0; + const start = new Date(event.start_datetime).getTime(); + const end = new Date(event.end_datetime).getTime(); + const current = now.getTime(); + if (current >= end) return 100; + if (current <= start) return 0; + return Math.round(((current - start) / (end - start)) * 100); +} + export default function CalendarWidget({ events }: CalendarWidgetProps) { const navigate = useNavigate(); const todayStr = format(new Date(), 'yyyy-MM-dd'); + const [clientNow, setClientNow] = useState(() => new Date()); + + useEffect(() => { + const interval = setInterval(() => setClientNow(new Date()), 60_000); + return () => clearInterval(interval); + }, []); return ( - +
@@ -33,29 +61,58 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) { {events.length === 0 ? ( -

- No events today -

+
+
+ +
+

Enjoy the free time

+
) : (
- {events.map((event) => ( -
navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })} - className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" - > + {events.map((event) => { + const timeState = getEventTimeState(event, clientNow); + const progress = getProgressPercent(event, clientNow); + const isCurrent = timeState === 'current'; + const isPast = timeState === 'past'; + + return (
- - {event.all_day - ? 'All day' - : `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`} - - {event.title} -
- ))} + key={event.id} + onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })} + className={cn( + 'flex items-center gap-2 py-1.5 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer relative pl-3.5', + isCurrent && 'bg-accent/[0.05]', + isPast && 'opacity-50' + )} + > + {/* Time progress bar — always rendered for consistent layout */} +
+ {!event.all_day && ( +
+ )} +
+
+ + {event.all_day + ? 'All day' + : `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`} + + {event.title} +
+ ); + })}
)} diff --git a/frontend/src/components/dashboard/CountdownWidget.tsx b/frontend/src/components/dashboard/CountdownWidget.tsx index ab15adb..ce4d26e 100644 --- a/frontend/src/components/dashboard/CountdownWidget.tsx +++ b/frontend/src/components/dashboard/CountdownWidget.tsx @@ -1,6 +1,7 @@ import { differenceInCalendarDays, format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; import { Star } from 'lucide-react'; +import { cn } from '@/lib/utils'; interface CountdownWidgetProps { events: Array<{ @@ -16,16 +17,22 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) { if (visible.length === 0) return null; return ( -
+
{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'); + const isUrgent = days <= 3; + const isFar = days >= 7; return (
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })} - className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150" + className={cn( + 'flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1.5 -mx-1.5 transition-all duration-150 border border-transparent', + isUrgent && 'border-amber-500/30 shadow-[0_0_8px_hsl(38_92%_50%/0.15)]', + isFar && 'opacity-70' + )} > diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 2972677..46692c9 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } 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'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { format, formatDistanceToNow } from 'date-fns'; +import { Bell, Plus, Calendar as CalIcon, ListTodo, RefreshCw } from 'lucide-react'; import api from '@/lib/api'; import type { DashboardData, UpcomingResponse, WeatherData } from '@/types'; import { useSettings } from '@/hooks/useSettings'; @@ -22,6 +22,7 @@ import ReminderForm from '../reminders/ReminderForm'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { DashboardSkeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; function getGreeting(name?: string): string { const hour = new Date().getHours(); @@ -35,12 +36,14 @@ function getGreeting(name?: string): string { export default function DashboardPage() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { settings } = useSettings(); const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts(); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); + // Click outside to close dropdown useEffect(() => { function handleClickOutside(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { @@ -51,7 +54,47 @@ export default function DashboardPage() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [dropdownOpen]); - const { data, isLoading } = useQuery({ + // Keyboard quick-add: Ctrl+N / Cmd+N opens dropdown, e/t/r selects type + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // Don't trigger inside inputs/textareas or when a form is open + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (quickAddType) return; + + if ((e.ctrlKey || e.metaKey) && e.key === 'n') { + e.preventDefault(); + setDropdownOpen(true); + return; + } + + if (dropdownOpen) { + if (e.key === 'Escape') { + setDropdownOpen(false); + return; + } + if (e.key === 'e') { + setQuickAddType('event'); + setDropdownOpen(false); + return; + } + if (e.key === 't') { + setQuickAddType('todo'); + setDropdownOpen(false); + return; + } + if (e.key === 'r') { + setQuickAddType('reminder'); + setDropdownOpen(false); + return; + } + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [dropdownOpen, quickAddType]); + + const { data, isLoading, dataUpdatedAt } = useQuery({ queryKey: ['dashboard'], queryFn: async () => { const now = new Date(); @@ -59,6 +102,8 @@ export default function DashboardPage() { const { data } = await api.get(`/dashboard?client_date=${today}`); return data; }, + staleTime: 60_000, + refetchInterval: 120_000, }); const { data: upcomingData } = useQuery({ @@ -70,6 +115,8 @@ export default function DashboardPage() { const { data } = await api.get(`/upcoming?days=${days}&client_date=${clientDate}`); return data; }, + staleTime: 60_000, + refetchInterval: 120_000, }); const { data: weatherData } = useQuery({ @@ -83,6 +130,11 @@ export default function DashboardPage() { enabled: !!(settings?.weather_city || (settings?.weather_lat != null && settings?.weather_lon != null)), }); + const handleRefresh = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + }, [queryClient]); + if (isLoading) { return (
@@ -107,6 +159,10 @@ export default function DashboardPage() { ); } + const updatedAgo = dataUpdatedAt + ? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true }) + : null; + return (
{/* Header — greeting + date + quick add */} @@ -115,9 +171,25 @@ export default function DashboardPage() {

{getGreeting(settings?.preferred_name || undefined)}

-

- {format(new Date(), 'EEEE, MMMM d, yyyy')} -

+
+

+ {format(new Date(), 'EEEE, MMMM d, yyyy')} +

+ {updatedAgo && ( + <> + · + Updated {updatedAgo} + + + )} +
{dropdownOpen && ( -
+
)} @@ -157,7 +238,7 @@ export default function DashboardPage() {
-
+
{/* Week Timeline */} {upcomingData && (
@@ -179,6 +260,7 @@ export default function DashboardPage() {
@@ -226,7 +308,7 @@ export default function DashboardPage() {
navigate('/reminders', { state: { reminderId: reminder.id } })} - className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer" >
{reminder.title} diff --git a/frontend/src/components/dashboard/DayBriefing.tsx b/frontend/src/components/dashboard/DayBriefing.tsx index c1019d9..988ac93 100644 --- a/frontend/src/components/dashboard/DayBriefing.tsx +++ b/frontend/src/components/dashboard/DayBriefing.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { format, isSameDay, startOfDay, addDays, isAfter } from 'date-fns'; +import { Sparkles } from 'lucide-react'; import type { UpcomingItem, DashboardData } from '@/types'; interface DayBriefingProps { @@ -148,8 +149,11 @@ export default function DayBriefing({ upcomingItems, dashboardData, weatherData if (!briefing) return null; return ( -

- {briefing} -

+
+ +

+ {briefing} +

+
); } diff --git a/frontend/src/components/dashboard/StatsWidget.tsx b/frontend/src/components/dashboard/StatsWidget.tsx index 1fd72a9..e55ca53 100644 --- a/frontend/src/components/dashboard/StatsWidget.tsx +++ b/frontend/src/components/dashboard/StatsWidget.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react'; +import { FolderKanban, CloudSun } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; interface StatsWidgetProps { @@ -8,12 +8,50 @@ interface StatsWidgetProps { by_status: Record; }; totalIncompleteTodos: number; + totalTodos?: number; weatherData?: { temp: number; description: string; city?: string } | null; } -export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) { +function ProgressRing({ value, total, color }: { value: number; total: number; color: string }) { + const size = 32; + const strokeWidth = 3; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const ratio = total > 0 ? Math.min(value / total, 1) : 0; + const offset = circumference * (1 - ratio); + + return ( + + + + + ); +} + +export default function StatsWidget({ projectStats, totalIncompleteTodos, totalTodos, weatherData }: StatsWidgetProps) { const navigate = useNavigate(); + const inProgress = projectStats.by_status['in_progress'] || 0; + const completedTodos = (totalTodos || 0) - totalIncompleteTodos; + const statCards = [ { label: 'PROJECTS', @@ -22,22 +60,25 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe color: 'text-blue-400', glowBg: 'bg-blue-500/10', onClick: () => navigate('/projects'), + ring: null, }, { label: 'IN PROGRESS', - value: projectStats.by_status['in_progress'] || 0, - icon: TrendingUp, + value: inProgress, + icon: null, color: 'text-purple-400', glowBg: 'bg-purple-500/10', onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }), + ring: { value: inProgress, total: projectStats.total, color: 'hsl(270, 70%, 60%)' }, }, { label: 'OPEN TODOS', value: totalIncompleteTodos, - icon: CheckSquare, + icon: null, color: 'text-teal-400', glowBg: 'bg-teal-500/10', onClick: () => navigate('/todos'), + ring: totalTodos ? { value: completedTodos, total: totalTodos, color: 'hsl(170, 70%, 50%)' } : null, }, ]; @@ -59,9 +100,13 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe {stat.value}

-
- -
+ {stat.ring ? ( + + ) : stat.icon ? ( +
+ +
+ ) : null}
diff --git a/frontend/src/components/dashboard/TodoWidget.tsx b/frontend/src/components/dashboard/TodoWidget.tsx index 1afab55..827d9a0 100644 --- a/frontend/src/components/dashboard/TodoWidget.tsx +++ b/frontend/src/components/dashboard/TodoWidget.tsx @@ -1,9 +1,13 @@ +import { useState } from 'react'; import { format, isPast, endOfDay } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { CheckCircle2 } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { CheckCircle2, Check } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import api from '@/lib/api'; interface DashboardTodo { id: number; @@ -31,9 +35,21 @@ const dotColors: Record = { export default function TodoWidget({ todos }: TodoWidgetProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [hoveredId, setHoveredId] = useState(null); + + const toggleTodo = useMutation({ + mutationFn: (id: number) => api.patch(`/todos/${id}/toggle`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); + toast.success('Todo completed'); + }, + onError: () => toast.error('Failed to complete todo'), + }); return ( - +
@@ -44,31 +60,55 @@ export default function TodoWidget({ todos }: TodoWidgetProps) { {todos.length === 0 ? ( -

- All caught up. -

+
+
+ +
+

Your slate is clean

+
) : (
{todos.slice(0, 5).map((todo) => { const isOverdue = isPast(endOfDay(new Date(todo.due_date))); + const isHovered = hoveredId === todo.id; return (
navigate('/todos', { state: { todoId: todo.id } })} - className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer" + onMouseEnter={() => setHoveredId(todo.id)} + onMouseLeave={() => setHoveredId(null)} + className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer" >
{todo.title} - - {format(new Date(todo.due_date), 'MMM d')} - {isOverdue && ' overdue'} - - - {todo.priority} - +
+
+ + {format(new Date(todo.due_date), 'MMM d')} + {isOverdue && ' overdue'} + + + {todo.priority} + +
+ {isHovered && ( +
+ +
+ )} +
); })} diff --git a/frontend/src/components/dashboard/TrackedProjectsWidget.tsx b/frontend/src/components/dashboard/TrackedProjectsWidget.tsx index 9d24bbd..9977f53 100644 --- a/frontend/src/components/dashboard/TrackedProjectsWidget.tsx +++ b/frontend/src/components/dashboard/TrackedProjectsWidget.tsx @@ -49,7 +49,7 @@ export default function TrackedProjectsWidget() { if (!tasks || tasks.length === 0) return null; return ( - +
diff --git a/frontend/src/components/dashboard/UpcomingWidget.tsx b/frontend/src/components/dashboard/UpcomingWidget.tsx index 2d821ea..a8d9a60 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { format, isToday, isTomorrow, isThisWeek } from 'date-fns'; import { useNavigate } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -62,6 +62,11 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { const [clientNow, setClientNow] = useState(() => new Date()); const [hoveredItem, setHoveredItem] = useState(null); const [snoozeOpen, setSnoozeOpen] = useState(null); + const hasMounted = useRef(false); + + useEffect(() => { + hasMounted.current = true; + }, []); // Update clientNow every 60s for past-event detection useEffect(() => { @@ -69,15 +74,29 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { return () => clearInterval(interval); }, []); - // Toggle todo completion + // Toggle todo completion with optimistic update const toggleTodo = useMutation({ mutationFn: (id: number) => api.patch(`/todos/${id}/toggle`), + onMutate: async (id: number) => { + await queryClient.cancelQueries({ queryKey: ['upcoming'] }); + const previousData = queryClient.getQueryData(['upcoming']); + queryClient.setQueryData(['upcoming'], (old: any) => { + if (!old?.items) return old; + return { ...old, items: old.items.filter((item: UpcomingItem) => !(item.type === 'todo' && item.id === id)) }; + }); + return { previousData }; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['upcoming'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); toast.success('Todo completed'); }, - onError: () => toast.error('Failed to complete todo'), + onError: (_err, _id, context) => { + if (context?.previousData) { + queryClient.setQueryData(['upcoming'], context.previousData); + } + toast.error('Failed to complete todo'); + }, }); // Snooze reminder @@ -261,6 +280,7 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { {!isCollapsed && (
{dayItems.map((item, idx) => { + const animDelay = Math.min(idx, 8) * 30; const config = typeConfig[item.type] || typeConfig.todo; const itemKey = `${item.type}-${item.id}-${idx}`; const isPast = isEventPast(item, clientNow); @@ -275,9 +295,11 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { onMouseLeave={() => { setHoveredItem(null); setSnoozeOpen(null); }} className={cn( 'flex items-center gap-2 py-1.5 px-2 rounded-md transition-colors duration-150 cursor-pointer', + !hasMounted.current && 'animate-slide-in-row', config.hoverGlow, isPast && 'opacity-50' )} + style={!hasMounted.current ? { animationDelay: `${animDelay}ms`, animationFillMode: 'backwards' } : undefined} > {/* Title */} {item.title} diff --git a/frontend/src/components/dashboard/WeekTimeline.tsx b/frontend/src/components/dashboard/WeekTimeline.tsx index 9e8ee96..7102d23 100644 --- a/frontend/src/components/dashboard/WeekTimeline.tsx +++ b/frontend/src/components/dashboard/WeekTimeline.tsx @@ -49,8 +49,8 @@ export default function WeekTimeline({ items }: WeekTimelineProps) { day.isToday ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' : day.isPast - ? 'border-transparent opacity-50 hover:opacity-75' - : 'border-transparent hover:border-border/50' + ? 'border-transparent opacity-50 hover:opacity-75 hover:scale-[1.04] hover:bg-card-elevated' + : 'border-transparent hover:border-border/50 hover:scale-[1.04] hover:bg-card-elevated' )} > {day.dayNum} -
+
{day.items.slice(0, 4).map((item) => (
))} {day.items.length > 4 && ( @@ -85,6 +86,9 @@ export default function WeekTimeline({ items }: WeekTimelineProps) { +{day.items.length - 4} )} + {day.isToday && ( +
+ )}
))} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8e6005e..93454d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -422,5 +422,44 @@ form[data-submitted] input:invalid + button { .animate-drift-1 { animation: drift-1 25s ease-in-out infinite; } .animate-drift-2 { animation: drift-2 30s ease-in-out infinite; } .animate-drift-3 { animation: drift-3 20s ease-in-out infinite; } +@keyframes pulse-dot { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +@keyframes slide-in-row { + from { + opacity: 0; + transform: translateX(-6px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes content-reveal { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + .animate-slide-up { animation: slide-up 0.5s ease-out both; } .animate-fade-in { animation: fade-in 0.3s ease-out both; } +.animate-pulse-dot { animation: pulse-dot 2s ease-in-out infinite; } +.animate-slide-in-row { animation: slide-in-row 250ms ease-out both; } +.animate-content-reveal { animation: content-reveal 400ms ease-out both; } + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bb27b6d..5e7339f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -335,6 +335,7 @@ export interface DashboardData { by_status: Record; }; total_incomplete_todos: number; + total_todos: number; starred_events: Array<{ id: number; title: string; From ac3f746ba327a4f630bc6eee9fc27b9bd6da137c Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 00:16:00 +0800 Subject: [PATCH 12/12] Fix QA findings: combine todo queries, remove dead prop, add aria-labels - Merge total_todos and total_incomplete_todos into single DB query (W-04) - Remove unused `days` prop from UpcomingWidget interface (W-03) - Add aria-label to focus/show-past toggle buttons (S-08) - Add zero-duration event guard in CalendarWidget progress calc (S-07) - Combine duplicate date-utils imports (S-01) Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/dashboard.py | 24 ++++++++----------- .../components/dashboard/CalendarWidget.tsx | 1 + .../components/dashboard/DashboardPage.tsx | 2 +- .../components/dashboard/UpcomingWidget.tsx | 6 ++--- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index c83ce7b..3e6f076 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_ +from sqlalchemy import select, func, or_, case from datetime import datetime, date, timedelta from typing import Optional, List, Dict, Any @@ -84,20 +84,16 @@ async def get_dashboard( projects_by_status_result = await db.execute(projects_by_status_query) projects_by_status = {row[0]: row[1] for row in projects_by_status_result} - # Total incomplete todos count (scoped to user) - total_incomplete_result = await db.execute( - select(func.count(Todo.id)).where( - Todo.user_id == current_user.id, - Todo.completed == False, - ) + # Todo counts: total and incomplete in a single query + todo_counts_result = await db.execute( + select( + func.count(Todo.id).label("total"), + func.count(case((Todo.completed == False, Todo.id))).label("incomplete"), + ).where(Todo.user_id == current_user.id) ) - total_incomplete_todos = total_incomplete_result.scalar() - - # Total todos count (for progress ring ratio) - total_todos_result = await db.execute( - select(func.count(Todo.id)).where(Todo.user_id == current_user.id) - ) - total_todos = total_todos_result.scalar() + todo_row = todo_counts_result.one() + total_todos = todo_row.total + total_incomplete_todos = todo_row.incomplete # Starred events (upcoming, ordered by date, scoped to user's calendars) starred_query = select(CalendarEvent).where( diff --git a/frontend/src/components/dashboard/CalendarWidget.tsx b/frontend/src/components/dashboard/CalendarWidget.tsx index 8703527..05732b6 100644 --- a/frontend/src/components/dashboard/CalendarWidget.tsx +++ b/frontend/src/components/dashboard/CalendarWidget.tsx @@ -33,6 +33,7 @@ function getProgressPercent(event: DashboardEvent, now: Date): number { if (event.all_day) return 0; const start = new Date(event.start_datetime).getTime(); const end = new Date(event.end_datetime).getTime(); + if (end <= start) return 0; const current = now.getTime(); if (current >= end) return 100; if (current <= start) return 0; diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 46692c9..f1b3855 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -272,7 +272,7 @@ export default function DashboardPage() {
{/* Left: Upcoming feed (wider) */}
- +
{/* Right: Countdown + Today's events + todos stacked */} diff --git a/frontend/src/components/dashboard/UpcomingWidget.tsx b/frontend/src/components/dashboard/UpcomingWidget.tsx index a8d9a60..069031d 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -17,13 +17,11 @@ import type { UpcomingItem } from '@/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; -import { getRelativeTime } from '@/lib/date-utils'; -import { toLocalDatetime } from '@/lib/date-utils'; +import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils'; import api from '@/lib/api'; interface UpcomingWidgetProps { items: UpcomingItem[]; - days?: number; } const typeConfig: Record = { @@ -216,6 +214,7 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { focusMode ? 'bg-accent/15 text-accent' : 'text-muted-foreground hover:text-foreground hover:bg-white/5' )} title={focusMode ? 'Show all days' : 'Focus: Today + Tomorrow'} + aria-label={focusMode ? 'Show all days' : 'Focus: Today + Tomorrow'} > @@ -226,6 +225,7 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) { showPast ? 'bg-accent/15 text-accent' : 'text-muted-foreground hover:text-foreground hover:bg-white/5' )} title={showPast ? 'Hide past events' : 'Show past events'} + aria-label={showPast ? 'Hide past events' : 'Show past events'} > {showPast ? : }