diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 18190db..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,14 +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() + 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( @@ -148,6 +150,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 } @@ -165,39 +168,43 @@ async def get_upcoming( cutoff_date = today + timedelta(days=days) cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) + overdue_floor = today - timedelta(days=30) + overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) # 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 (up to 30 days back) 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 >= overdue_floor, 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 >= overdue_floor_dt, 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 +219,34 @@ 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 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/CalendarWidget.tsx b/frontend/src/components/dashboard/CalendarWidget.tsx index 6d8270d..05732b6 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,39 @@ 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(); + if (end <= start) return 0; + 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 +62,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 59fb09f..f1b3855 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() {
@@ -190,20 +272,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 */} @@ -212,7 +281,9 @@ export default function DashboardPage() { )} - +
+ +
@@ -237,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 da3bcea..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 01b13c5..069031d 100644 --- a/frontend/src/components/dashboard/UpcomingWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingWidget.tsx @@ -1,26 +1,151 @@ -import { format } from 'date-fns'; +import { useState, useMemo, useEffect, useCallback, useRef } 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, 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: { hoverGlow: 'hover:bg-blue-500/[0.08]', pillBg: 'bg-blue-500/15', pillText: 'text-blue-400', label: 'TODO' }, + event: { hoverGlow: 'hover:bg-purple-500/[0.08]', pillBg: 'bg-purple-500/15', pillText: 'text-purple-400', label: 'EVENT' }, + reminder: { hoverGlow: 'hover:bg-orange-500/[0.08]', 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.min(1440, 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); + const hasMounted = useRef(false); + + useEffect(() => { + hasMounted.current = true; + }, []); + + // Update clientNow every 60s for past-event detection + useEffect(() => { + const interval = setInterval(() => setClientNow(new Date()), 60_000); + return () => clearInterval(interval); + }, []); + + // 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: (_err, _id, context) => { + if (context?.previousData) { + queryClient.setQueryData(['upcoming'], context.previousData); + } + toast.error('Failed to complete todo'); + }, + }); + + // 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'); + }, + onError: () => toast.error('Failed to snooze reminder'), + }); + + // 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'); + }, + onError: () => toast.error('Failed to dismiss reminder'), + }); + + // 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,58 +161,264 @@ 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.type === 'event' && item.all_day) return 'All day'; + 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 ( - - + +
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 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); + 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 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} + + {/* 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 + + )} + + {/* Right side: static info + action overlay */} +
+ {/* Always-rendered labels (stable layout) */} +
+ {timeLabel && ( + + {timeLabel} + + )} + + {item.priority && item.priority !== 'none' && ( + + )} +
+ + {/* Action buttons overlaid in same space */} + {isHovered && item.type === 'todo' && ( +
+ +
+ )} + + {isHovered && item.type === 'reminder' && ( +
+
+ + {snoozeOpen === itemKey && ( +
+ + + +
+ )} +
+ +
+ )} +
+
+ ); + })} +
+ )}
); })} 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/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) => (