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')}
-
-
- {config.label}
-
-
- {item.priority || ''}
-
+
+ {/* 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 && (
+
+ {config.label}
+
+ )}
+
+ {/* Priority pill (todos only) */}
+ {!isHovered && item.priority && item.priority !== 'none' && (
+
+ {item.priority}
+
+ )}
+
+ );
+ })}
+
+ )}
);
})}
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) => (