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 <noreply@anthropic.com>
This commit is contained in:
parent
1291807847
commit
9635401fe8
@ -156,12 +156,14 @@ async def get_dashboard(
|
|||||||
async def get_upcoming(
|
async def get_upcoming(
|
||||||
days: int = Query(default=7, ge=1, le=90),
|
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)),
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
current_settings: Settings = Depends(get_current_settings),
|
||||||
):
|
):
|
||||||
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
|
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
|
||||||
today = client_date or date.today()
|
today = client_date or date.today()
|
||||||
|
now = datetime.now()
|
||||||
cutoff_date = today + timedelta(days=days)
|
cutoff_date = today + timedelta(days=days)
|
||||||
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
@ -169,35 +171,35 @@ async def get_upcoming(
|
|||||||
# Subquery: calendar IDs belonging to this user
|
# Subquery: calendar IDs belonging to this user
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
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(
|
todos_query = select(Todo).where(
|
||||||
Todo.user_id == current_user.id,
|
Todo.user_id == current_user.id,
|
||||||
Todo.completed == False,
|
Todo.completed == False,
|
||||||
Todo.due_date.isnot(None),
|
Todo.due_date.isnot(None),
|
||||||
Todo.due_date >= today,
|
|
||||||
Todo.due_date <= cutoff_date
|
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(
|
events_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
CalendarEvent.start_datetime >= today_start,
|
CalendarEvent.start_datetime >= today_start,
|
||||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||||
_not_parent_template,
|
_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(
|
reminders_query = select(Reminder).where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
Reminder.is_active == True,
|
Reminder.is_active == True,
|
||||||
Reminder.is_dismissed == False,
|
Reminder.is_dismissed == False,
|
||||||
Reminder.remind_at >= today_start,
|
|
||||||
Reminder.remind_at <= cutoff_datetime
|
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_result = await db.execute(reminders_query)
|
||||||
reminders = reminders_result.scalars().all()
|
reminders = reminders_result.scalars().all()
|
||||||
|
|
||||||
@ -212,28 +214,39 @@ async def get_upcoming(
|
|||||||
"date": todo.due_date.isoformat() if todo.due_date else None,
|
"date": todo.due_date.isoformat() if todo.due_date else None,
|
||||||
"datetime": None,
|
"datetime": None,
|
||||||
"priority": todo.priority,
|
"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:
|
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({
|
upcoming_items.append({
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": event.title,
|
"title": event.title,
|
||||||
"date": event.start_datetime.date().isoformat(),
|
"date": event.start_datetime.date().isoformat(),
|
||||||
"datetime": event.start_datetime.isoformat(),
|
"datetime": event.start_datetime.isoformat(),
|
||||||
|
"end_datetime": end_dt.isoformat() if end_dt else None,
|
||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color,
|
"color": event.color,
|
||||||
"is_starred": event.is_starred
|
"is_starred": event.is_starred,
|
||||||
})
|
})
|
||||||
|
|
||||||
for reminder in reminders:
|
for reminder in reminders:
|
||||||
|
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
|
||||||
upcoming_items.append({
|
upcoming_items.append({
|
||||||
"type": "reminder",
|
"type": "reminder",
|
||||||
"id": reminder.id,
|
"id": reminder.id,
|
||||||
"title": reminder.title,
|
"title": reminder.title,
|
||||||
"date": reminder.remind_at.date().isoformat(),
|
"date": remind_at_date.isoformat() if remind_at_date else None,
|
||||||
"datetime": reminder.remind_at.isoformat()
|
"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
|
# Sort by date/datetime
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class ReminderUpdate(BaseModel):
|
|||||||
class ReminderSnooze(BaseModel):
|
class ReminderSnooze(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
minutes: Literal[5, 10, 15]
|
minutes: int = Field(ge=1, le=1440)
|
||||||
client_now: Optional[datetime] = None
|
client_now: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { Reminder } from '@/types';
|
|||||||
interface AlertBannerProps {
|
interface AlertBannerProps {
|
||||||
alerts: Reminder[];
|
alerts: Reminder[];
|
||||||
onDismiss: (id: number) => void;
|
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) {
|
export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBannerProps) {
|
||||||
|
|||||||
@ -190,20 +190,7 @@ export default function DashboardPage() {
|
|||||||
<div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
<div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
||||||
{/* Left: Upcoming feed (wider) */}
|
{/* Left: Upcoming feed (wider) */}
|
||||||
<div className="lg:col-span-3 flex flex-col">
|
<div className="lg:col-span-3 flex flex-col">
|
||||||
{upcomingData && upcomingData.items.length > 0 ? (
|
<UpcomingWidget items={upcomingData?.items ?? []} days={upcomingData?.days} />
|
||||||
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
|
|
||||||
) : (
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Upcoming</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
Nothing upcoming. Enjoy the quiet.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Countdown + Today's events + todos stacked */}
|
{/* Right: Countdown + Today's events + todos stacked */}
|
||||||
|
|||||||
@ -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 { 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 type { UpcomingItem } from '@/types';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getRelativeTime } from '@/lib/date-utils';
|
||||||
|
import { toLocalDatetime } from '@/lib/date-utils';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
interface UpcomingWidgetProps {
|
interface UpcomingWidgetProps {
|
||||||
items: UpcomingItem[];
|
items: UpcomingItem[];
|
||||||
days?: number;
|
days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; label: string }> = {
|
const typeConfig: Record<string, { borderColor: string; pillBg: string; pillText: string; label: string }> = {
|
||||||
todo: { icon: CheckSquare, color: 'text-blue-400', label: 'TODO' },
|
todo: { borderColor: 'border-l-blue-400', pillBg: 'bg-blue-500/15', pillText: 'text-blue-400', label: 'TODO' },
|
||||||
event: { icon: Calendar, color: 'text-purple-400', label: 'EVENT' },
|
event: { borderColor: 'border-l-purple-400', pillBg: 'bg-purple-500/15', pillText: 'text-purple-400', label: 'EVENT' },
|
||||||
reminder: { icon: Bell, color: 'text-orange-400', label: 'REMINDER' },
|
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) {
|
function getMinutesUntilTomorrowMorning(): number {
|
||||||
const navigate = useNavigate();
|
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<Set<string>>(new Set());
|
||||||
|
const [clientNow, setClientNow] = useState(() => new Date());
|
||||||
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||||
|
const [snoozeOpen, setSnoozeOpen] = useState<string | null>(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<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) {
|
switch (item.type) {
|
||||||
case 'event': {
|
case 'event': {
|
||||||
const dateStr = item.datetime
|
const dateStr = item.datetime
|
||||||
@ -36,10 +141,39 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
|
|||||||
navigate('/reminders', { state: { reminderId: item.id } });
|
navigate('/reminders', { state: { reminderId: item.id } });
|
||||||
break;
|
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 (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="flex flex-col h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@ -47,47 +181,219 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
|
|||||||
<ArrowRight className="h-4 w-4 text-accent" />
|
<ArrowRight className="h-4 w-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
Upcoming
|
Upcoming
|
||||||
|
<span className="text-xs text-muted-foreground font-normal ml-1">
|
||||||
|
{filteredCount} {filteredCount === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<span className="text-xs text-muted-foreground">{days} days</span>
|
<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'}
|
||||||
|
>
|
||||||
|
<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'}
|
||||||
|
>
|
||||||
|
{showPast ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 min-h-0 flex flex-col" style={{ minHeight: 200 }}>
|
||||||
{items.length === 0 ? (
|
{filteredCount === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
Nothing upcoming
|
{focusMode ? 'Nothing for today or tomorrow' : 'Nothing upcoming'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="max-h-[400px] -mr-2 pr-2">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-0.5">
|
<div>
|
||||||
{items.map((item, index) => {
|
{dayEntries.map(([dateKey, dayItems], groupIdx) => {
|
||||||
const config = typeConfig[item.type] || typeConfig.todo;
|
const isCollapsed = collapsedDays.has(dateKey);
|
||||||
const Icon = config.icon;
|
const label = getDayLabel(dateKey);
|
||||||
|
const isTodayGroup = isToday(new Date(dateKey + 'T00:00:00'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={dateKey}>
|
||||||
key={`${item.type}-${item.id}-${index}`}
|
{/* Sticky day header */}
|
||||||
onClick={() => handleItemClick(item)}
|
<button
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
onClick={() => toggleDay(dateKey)}
|
||||||
>
|
className={cn(
|
||||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
|
'sticky top-0 bg-card z-10 w-full flex items-center gap-1.5 pb-1.5 border-b border-border cursor-pointer select-none',
|
||||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
groupIdx === 0 ? 'pt-0' : 'pt-3'
|
||||||
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap tabular-nums w-[7rem] text-right">
|
)}
|
||||||
{item.datetime
|
>
|
||||||
? format(new Date(item.datetime), 'MMM d, h:mm a')
|
<ChevronRight
|
||||||
: format(new Date(item.date), 'MMM d')}
|
className={cn(
|
||||||
</span>
|
'h-3 w-3 text-muted-foreground transition-transform duration-150',
|
||||||
<span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right hidden sm:block', config.color)}>
|
!isCollapsed && 'rotate-90'
|
||||||
{config.label}
|
)}
|
||||||
</span>
|
/>
|
||||||
<span className={cn(
|
<span
|
||||||
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center hidden sm:block',
|
className={cn(
|
||||||
item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
|
'text-xs font-semibold uppercase tracking-wider',
|
||||||
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
|
isTodayGroup ? 'text-accent' : 'text-muted-foreground'
|
||||||
item.priority === 'low' ? 'bg-green-500/10 text-green-400' :
|
)}
|
||||||
item.priority === 'none' ? 'bg-gray-500/10 text-gray-400' :
|
>
|
||||||
'invisible'
|
{label}
|
||||||
)}>
|
</span>
|
||||||
{item.priority || ''}
|
{isCollapsed && (
|
||||||
</span>
|
<span className="text-[10px] text-muted-foreground font-normal ml-auto mr-1">
|
||||||
|
{dayItems.length} {dayItems.length === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Day items */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="py-0.5">
|
||||||
|
{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 (
|
||||||
|
<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 hover:bg-white/5 transition-colors duration-150 cursor-pointer border-l-2',
|
||||||
|
config.borderColor,
|
||||||
|
isPast && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline quick actions (desktop hover) */}
|
||||||
|
{isHovered && item.type === 'todo' && (
|
||||||
|
<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 shrink-0"
|
||||||
|
title="Complete todo"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHovered && item.type === 'reminder' && (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
{/* Snooze button with dropdown */}
|
||||||
|
<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 top-full mt-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>
|
||||||
|
{/* Dismiss button */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time label */}
|
||||||
|
{timeLabel && !isHovered && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type pill */}
|
||||||
|
{!isHovered && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-[9px] px-1.5 py-0.5 rounded font-medium shrink-0 hidden sm:block',
|
||||||
|
config.pillBg,
|
||||||
|
config.pillText
|
||||||
|
)}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority pill (todos only) */}
|
||||||
|
{!isHovered && item.priority && item.priority !== 'none' && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 hidden sm: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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -2,18 +2,19 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
interface SnoozeDropdownProps {
|
interface SnoozeDropdownProps {
|
||||||
onSnooze: (minutes: 5 | 10 | 15) => void;
|
onSnooze: (minutes: number) => void;
|
||||||
label: string;
|
label: string;
|
||||||
direction?: 'up' | 'down';
|
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: 5, label: '5 minutes' },
|
||||||
{ value: 10, label: '10 minutes' },
|
{ value: 10, label: '10 minutes' },
|
||||||
{ value: 15, label: '15 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 [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: Sn
|
|||||||
<div role="menu" className={`absolute right-0 w-32 rounded-md border bg-popover shadow-lg z-50 py-1 animate-fade-in ${
|
<div role="menu" className={`absolute right-0 w-32 rounded-md border bg-popover shadow-lg z-50 py-1 animate-fade-in ${
|
||||||
direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'
|
direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
}`}>
|
}`}>
|
||||||
{OPTIONS.map((opt) => (
|
{options.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const MAX_TOASTS = 3;
|
|||||||
interface AlertsContextValue {
|
interface AlertsContextValue {
|
||||||
alerts: Reminder[];
|
alerts: Reminder[];
|
||||||
dismiss: (id: number) => void;
|
dismiss: (id: number) => void;
|
||||||
snooze: (id: number, minutes: 5 | 10 | 15) => void;
|
snooze: (id: number, minutes: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlertsContext = createContext<AlertsContextValue>({
|
const AlertsContext = createContext<AlertsContextValue>({
|
||||||
@ -73,7 +73,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snoozeMutation = useMutation({
|
const snoozeMutation = useMutation({
|
||||||
mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) =>
|
mutationFn: ({ id, minutes }: { id: number; minutes: number }) =>
|
||||||
api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
|
api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
@ -106,7 +106,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
|||||||
dismissMutation.mutate(id);
|
dismissMutation.mutate(id);
|
||||||
}, [dismissMutation, updateSummaryToast]);
|
}, [dismissMutation, updateSummaryToast]);
|
||||||
|
|
||||||
const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => {
|
const handleSnooze = useCallback((id: number, minutes: number) => {
|
||||||
toast.dismiss(`reminder-${id}`);
|
toast.dismiss(`reminder-${id}`);
|
||||||
firedRef.current.delete(id);
|
firedRef.current.delete(id);
|
||||||
updateSummaryToast();
|
updateSummaryToast();
|
||||||
|
|||||||
@ -359,11 +359,14 @@ export interface UpcomingItem {
|
|||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
datetime?: string;
|
datetime?: string;
|
||||||
|
end_datetime?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
all_day?: boolean;
|
all_day?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
is_starred?: boolean;
|
is_starred?: boolean;
|
||||||
|
snoozed_until?: string;
|
||||||
|
is_overdue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpcomingResponse {
|
export interface UpcomingResponse {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user