- Removed bg-card from sticky headers (was creating opaque bars against glassmorphic card background) - Reduced padding from pb-1.5 to py-0.5 for slimmer profile - Added leading-none for proper vertical centering of chevron + text - Softened border opacity to 30%, text to 70%, chevron to 60% - Shrunk text from text-xs to text-[10px], chevron from h-3 to h-2.5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
432 lines
20 KiB
TypeScript
432 lines
20 KiB
TypeScript
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<string, { hoverGlow: string; pillBg: string; pillText: string; label: string }> = {
|
|
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<Set<string>>(new Set());
|
|
const [clientNow, setClientNow] = useState(() => new Date());
|
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
|
const [snoozeOpen, setSnoozeOpen] = useState<string | null>(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<string, UpcomingItem[]>();
|
|
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 (
|
|
<Card className="flex flex-col h-full overflow-hidden">
|
|
<CardHeader className="shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-accent/10">
|
|
<ArrowRight className="h-4 w-4 text-accent" />
|
|
</div>
|
|
Upcoming
|
|
<span className="text-xs text-muted-foreground font-normal ml-1">
|
|
{filteredCount} {filteredCount === 1 ? 'item' : 'items'}
|
|
</span>
|
|
</CardTitle>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setFocusMode(!focusMode)}
|
|
className={cn(
|
|
'p-1.5 rounded-md transition-colors',
|
|
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'}
|
|
>
|
|
<Target className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowPast(!showPast)}
|
|
className={cn(
|
|
'p-1.5 rounded-md transition-colors',
|
|
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 ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 min-h-0 flex flex-col">
|
|
{filteredCount === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
{focusMode ? 'Nothing for today or tomorrow' : 'Nothing upcoming'}
|
|
</p>
|
|
) : (
|
|
<ScrollArea className="flex-1 min-h-0 -mr-2 pr-2">
|
|
<div>
|
|
{dayEntries.map(([dateKey, dayItems], groupIdx) => {
|
|
const isCollapsed = collapsedDays.has(dateKey);
|
|
const label = getDayLabel(dateKey);
|
|
const isTodayGroup = isToday(new Date(dateKey + 'T00:00:00'));
|
|
|
|
return (
|
|
<div key={dateKey}>
|
|
{/* Sticky day header */}
|
|
<button
|
|
onClick={() => toggleDay(dateKey)}
|
|
className={cn(
|
|
'sticky top-0 z-10 w-full flex items-center gap-1.5 py-0.5 border-b border-border/30 cursor-pointer select-none leading-none',
|
|
groupIdx === 0 ? 'pt-0' : 'mt-2'
|
|
)}
|
|
>
|
|
<ChevronRight
|
|
className={cn(
|
|
'h-2.5 w-2.5 text-muted-foreground/60 transition-transform duration-150 shrink-0',
|
|
!isCollapsed && 'rotate-90'
|
|
)}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-semibold uppercase tracking-wider leading-none',
|
|
isTodayGroup ? 'text-accent' : 'text-muted-foreground/70'
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
{isCollapsed && (
|
|
<span className="text-[9px] text-muted-foreground/50 font-normal ml-auto mr-1 leading-none">
|
|
{dayItems.length} {dayItems.length === 1 ? 'item' : 'items'}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Day items */}
|
|
{!isCollapsed && (
|
|
<div className="py-0.5">
|
|
{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 (
|
|
<div
|
|
key={itemKey}
|
|
onClick={() => 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 */}
|
|
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
|
|
|
{/* Status pills */}
|
|
{item.is_overdue && item.type === 'todo' && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-red-500/10 text-red-400 shrink-0">
|
|
OVERDUE
|
|
</span>
|
|
)}
|
|
{item.type === 'reminder' && item.snoozed_until && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-amber-500/10 text-amber-400 shrink-0">
|
|
SNOOZED
|
|
</span>
|
|
)}
|
|
{item.type === 'reminder' && item.is_overdue && !item.snoozed_until && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-red-500/10 text-red-400 shrink-0">
|
|
OVERDUE
|
|
</span>
|
|
)}
|
|
|
|
{/* Right side: static info + action overlay */}
|
|
<div className="relative flex items-center gap-1.5 shrink-0">
|
|
{/* Always-rendered labels (stable layout) */}
|
|
<div className={cn('flex items-center gap-1.5', isHovered && (item.type === 'todo' || item.type === 'reminder') && 'invisible')}>
|
|
{timeLabel && (
|
|
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
|
|
{timeLabel}
|
|
</span>
|
|
)}
|
|
<span className={cn(
|
|
'text-[9px] px-1.5 py-0.5 rounded font-medium shrink-0 hidden sm:inline-block',
|
|
config.pillBg,
|
|
config.pillText
|
|
)}>
|
|
{config.label}
|
|
</span>
|
|
{item.priority && item.priority !== 'none' && (
|
|
<span className={cn(
|
|
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 hidden sm:inline-block',
|
|
item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
|
|
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
|
|
'bg-green-500/10 text-green-400'
|
|
)}>
|
|
{item.priority}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons overlaid in same space */}
|
|
{isHovered && item.type === 'todo' && (
|
|
<div className="absolute inset-0 flex items-center justify-end">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleTodo.mutate(item.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-green-500/15 text-muted-foreground hover:text-green-400 transition-colors"
|
|
title="Complete todo"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isHovered && item.type === 'reminder' && (
|
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5">
|
|
<div className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSnoozeOpen(snoozeOpen === itemKey ? null : itemKey);
|
|
}}
|
|
className="p-1 rounded hover:bg-orange-500/15 text-muted-foreground hover:text-orange-400 transition-colors"
|
|
title="Snooze reminder"
|
|
>
|
|
<Clock className="h-3.5 w-3.5" />
|
|
</button>
|
|
{snoozeOpen === itemKey && (
|
|
<div className="absolute right-0 bottom-full mb-1 w-28 rounded-md border bg-popover shadow-lg z-50 py-1 animate-fade-in">
|
|
<button
|
|
onClick={(e) => handleSnooze(item.id, 60, e)}
|
|
className="flex items-center w-full px-3 py-1.5 text-xs hover:bg-card-elevated transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
1 hour
|
|
</button>
|
|
<button
|
|
onClick={(e) => handleSnooze(item.id, 180, e)}
|
|
className="flex items-center w-full px-3 py-1.5 text-xs hover:bg-card-elevated transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
3 hours
|
|
</button>
|
|
<button
|
|
onClick={(e) => handleSnooze(item.id, getMinutesUntilTomorrowMorning(), e)}
|
|
className="flex items-center w-full px-3 py-1.5 text-xs hover:bg-card-elevated transition-colors text-muted-foreground hover:text-foreground"
|
|
>
|
|
Tomorrow
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
dismissReminder.mutate(item.id);
|
|
}}
|
|
className="p-1 rounded hover:bg-red-500/15 text-muted-foreground hover:text-red-400 transition-colors"
|
|
title="Dismiss reminder"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|