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'; 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[]; } 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' }, }; 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))); } 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 ? format(new Date(item.datetime), 'yyyy-MM-dd') : item.date; navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } }); break; } case 'todo': navigate('/todos', { state: { todoId: item.id } }); break; case 'reminder': 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'}
{filteredCount === 0 ? (

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

) : (
{dayEntries.map(([dateKey, dayItems], groupIdx) => { const isCollapsed = collapsedDays.has(dateKey); const label = getDayLabel(dateKey); const isTodayGroup = isToday(new Date(dateKey + 'T00:00:00')); return (
{/* 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 && (
)}
)}
); })}
)}
); })}
)}
); }