Compare commits

..

13 Commits

Author SHA1 Message Date
c21d7592ae Merge feature/upcoming-widget-redesign into main
Upcoming Widget redesign + Dashboard Polish (Batch 1+2):
- Upcoming feed: day-grouped, collapsible, focus mode, hover actions,
  optimistic todo completion, staggered row entrance
- Dashboard: plus rotation, card hover glow, DayBriefing container,
  WeekTimeline hover+pulse+tooltips, countdown urgency, CalendarWidget
  progress bars + current highlight + empty state, TodoWidget inline
  complete + empty state, auto-refresh, keyboard quick-add, progress
  rings, content crossfade, prefers-reduced-motion, ARIA compliance
2026-03-12 00:16:20 +08:00
ac3f746ba3 Fix QA findings: combine todo queries, remove dead prop, add aria-labels
- Merge total_todos and total_incomplete_todos into single DB query (W-04)
- Remove unused `days` prop from UpcomingWidget interface (W-03)
- Add aria-label to focus/show-past toggle buttons (S-08)
- Add zero-duration event guard in CalendarWidget progress calc (S-07)
- Combine duplicate date-utils imports (S-01)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:16:00 +08:00
b41b0b6635 Add dashboard polish: micro-animations, visual upgrades, and interactivity
Batch 1+2 implementation (17 items): plus button rotation, card hover
glow consistency, DayBriefing container with Sparkles icon, WeekTimeline
hover scale + pulsing today dot + dot tooltips, countdown urgency scaling,
CalendarWidget time progress bar + current event highlight + empty state,
TodoWidget inline complete + empty state, dashboard auto-refresh (2min),
optimistic todo completion, "Updated Xm ago" with refresh button, keyboard
quick-add (Ctrl+N → e/t/r), progress rings on stat cards, staggered row
entrance in Upcoming, content crossfade, prefers-reduced-motion support,
ARIA attributes on dropdown menu, and hover:bg-card-elevated consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:04 +08:00
8b6530c901 Fix hover jitter by overlaying actions instead of swapping content
The type pill, time label, and priority pill were being removed on
hover and replaced with action buttons, causing layout reflow and
visible jitter. Now the labels stay rendered (invisible when hovered
for todos/reminders) to hold their space, and action buttons are
absolutely positioned on top. Events show no actions so their labels
stay visible on hover. Zero layout shift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:29:37 +08:00
66e230f740 Make right column cards fill height to align with Upcoming card
Wrap TodoWidget in flex-1 container and add h-full to its Card so
the Upcoming Todos card stretches to fill remaining space in the
right column, keeping both columns visually aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:21:04 +08:00
b8bc097f6f Fix Upcoming card to match grid row height with internal scroll
Replace fixed maxHeight 520px with h-full + overflow-hidden so the
card stretches to match the right column height in the grid row.
The flex chain (Card flex-col → CardContent flex-1 min-h-0 →
ScrollArea flex-1 min-h-0) ensures content scrolls internally
within the row-determined height instead of capping independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:47:00 +08:00
847372643b Fix QA findings: bound queries, error handlers, snooze clamp
C-01: Add 30-day lower bound on overdue todo/reminder queries to
prevent fetching entire history.
C-02: Remove dead include_past query param — past-event filtering
is handled client-side.
W-01: Add onError toast handlers to all three inline mutations.
W-02: Snooze dropdown opens upward (bottom-full) to avoid clipping
inside the ScrollArea overflow container.
S-06: Clamp getMinutesUntilTomorrowMorning() to max 1440 to stay
within ReminderSnooze schema bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:21:10 +08:00
99161f1b47 Fix Upcoming card height constraint with flex column + maxHeight
Root cause: h-full on Card inside a flex-col parent with no explicit
height meant nothing constrained the card — ScrollArea max-h never
triggered overflow. Fix: Card uses maxHeight 520px as the outer cap,
flex-col layout with shrink-0 header, and min-h-0 on CardContent +
ScrollArea so the flex chain allows content to shrink and scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:15:53 +08:00
27a5002c74 Fix Upcoming card height — use natural height with scroll cap
The flex-col h-full layout caused the card to stretch to match the
grid row, pushing content beyond the ScrollArea max-height. Switched
to natural card height with max-h-[400px] on ScrollArea so the card
stays compact and scrolls internally without mismatching the right
column cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:07:59 +08:00
076b2fc3c9 Add scroll cap and fix all-day event time display
Restore max-h-[400px] on ScrollArea so the widget caps and scrolls
instead of growing unbounded and making cards uneven. All-day events
now show "All day" instead of the misleading "12:00 AM" time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:37:04 +08:00
28e1673f05 Fix hover glow using arbitrary Tailwind opacity values
/8 is not in Tailwind's default opacity scale so the classes were
purged. Use /[0.08] arbitrary value syntax instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:27:48 +08:00
5af54de44b Replace left border indicators with subtle type-colored hover glow
Removes the always-visible 2px colored left border from each row.
On hover, the row background now glows with the type color at 8%
opacity (blue for todos, purple for events, orange for reminders).
Cleaner at rest, still provides type recognition on interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:24:08 +08:00
9635401fe8 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>
2026-03-11 21:07:14 +08:00
16 changed files with 775 additions and 158 deletions

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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 (
<Card>
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-purple-500/10">
@ -33,17 +62,45 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No events today
</p>
<div className="flex flex-col items-center justify-center py-6 gap-2">
<div className="rounded-full bg-muted p-4">
<Calendar className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">Enjoy the free time</p>
</div>
) : (
<div className="space-y-0.5">
{events.map((event) => (
{events.map((event) => {
const timeState = getEventTimeState(event, clientNow);
const progress = getProgressPercent(event, clientNow);
const isCurrent = timeState === 'current';
const isPast = timeState === 'past';
return (
<div
key={event.id}
onClick={() => 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"
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 */}
<div
className="absolute left-0 top-1 bottom-1 w-0.5 rounded-full overflow-hidden"
style={{ backgroundColor: event.all_day ? 'transparent' : 'hsl(var(--border))' }}
>
{!event.all_day && (
<div
className={cn('w-full rounded-full transition-all duration-1000', isPast && 'opacity-50')}
style={{
height: `${progress}%`,
backgroundColor: event.color || 'hsl(var(--accent-color))',
}}
/>
)}
</div>
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
@ -55,7 +112,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
</span>
<span className="text-sm font-medium truncate">{event.title}</span>
</div>
))}
);
})}
</div>
)}
</CardContent>

View File

@ -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 (
<div className="rounded-lg bg-amber-500/[0.07] border border-amber-500/10 px-3.5 py-2 space-y-1">
<div className="rounded-lg bg-amber-500/[0.07] border border-amber-500/10 px-3.5 py-2 space-y-1 hover:shadow-lg hover:shadow-amber-500/5 hover:border-amber-500/20 transition-all duration-200">
{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 (
<div
key={event.id}
onClick={() => 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'
)}
>
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate">

View File

@ -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<HTMLDivElement>(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<DashboardData>(`/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<UpcomingResponse>(`/upcoming?days=${days}&client_date=${clientDate}`);
return data;
},
staleTime: 60_000,
refetchInterval: 120_000,
});
const { data: weatherData } = useQuery<WeatherData>({
@ -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 (
<div className="flex flex-col h-full">
@ -107,6 +159,10 @@ export default function DashboardPage() {
);
}
const updatedAgo = dataUpdatedAt
? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true })
: null;
return (
<div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */}
@ -115,9 +171,25 @@ export default function DashboardPage() {
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)}
</h1>
<p className="text-muted-foreground text-sm mt-1">
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground text-sm">
{format(new Date(), 'EEEE, MMMM d, yyyy')}
</p>
{updatedAgo && (
<>
<span className="text-muted-foreground/40 text-xs">·</span>
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
<button
onClick={handleRefresh}
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
title="Refresh dashboard"
aria-label="Refresh dashboard"
>
<RefreshCw className="h-3 w-3" />
</button>
</>
)}
</div>
</div>
<div className="relative" ref={dropdownRef}>
<Button
@ -125,31 +197,40 @@ export default function DashboardPage() {
size="icon"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-9 w-9"
aria-haspopup="menu"
aria-expanded={dropdownOpen}
aria-label="Quick add"
>
<Plus className="h-4 w-4" />
<Plus className={cn('h-4 w-4 transition-transform duration-200', dropdownOpen && 'rotate-45')} />
</Button>
{dropdownOpen && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border bg-popover shadow-xl z-50 py-1 animate-fade-in">
<div role="menu" className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border bg-popover shadow-xl z-50 py-1 animate-fade-in">
<button
role="menuitem"
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('event'); setDropdownOpen(false); }}
>
<CalIcon className="h-4 w-4 text-purple-400" />
Event
<kbd className="ml-auto text-[10px] text-muted-foreground/50 font-mono bg-muted rounded px-1 py-0.5">e</kbd>
</button>
<button
role="menuitem"
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('todo'); setDropdownOpen(false); }}
>
<ListTodo className="h-4 w-4 text-blue-400" />
Todo
<kbd className="ml-auto text-[10px] text-muted-foreground/50 font-mono bg-muted rounded px-1 py-0.5">t</kbd>
</button>
<button
role="menuitem"
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('reminder'); setDropdownOpen(false); }}
>
<Bell className="h-4 w-4 text-orange-400" />
Reminder
<kbd className="ml-auto text-[10px] text-muted-foreground/50 font-mono bg-muted rounded px-1 py-0.5">r</kbd>
</button>
</div>
)}
@ -157,7 +238,7 @@ export default function DashboardPage() {
</div>
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<div className="space-y-3 sm:space-y-5">
<div className="space-y-3 sm:space-y-5 animate-content-reveal">
{/* Week Timeline */}
{upcomingData && (
<div className="animate-slide-up">
@ -179,6 +260,7 @@ export default function DashboardPage() {
<StatsWidget
projectStats={data.project_stats}
totalIncompleteTodos={data.total_incomplete_todos}
totalTodos={data.total_todos}
weatherData={weatherData || null}
/>
</div>
@ -190,20 +272,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' }}>
{/* Left: Upcoming feed (wider) */}
<div className="lg:col-span-3 flex flex-col">
{upcomingData && upcomingData.items.length > 0 ? (
<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>
)}
<UpcomingWidget items={upcomingData?.items ?? []} />
</div>
{/* Right: Countdown + Today's events + todos stacked */}
@ -212,9 +281,11 @@ export default function DashboardPage() {
<CountdownWidget events={data.starred_events} />
)}
<CalendarWidget events={data.todays_events} />
<div className="flex-1">
<TodoWidget todos={data.upcoming_todos} />
</div>
</div>
</div>
{/* Active Reminders — exclude those already shown in alert banner */}
{(() => {
@ -237,7 +308,7 @@ export default function DashboardPage() {
<div
key={reminder.id}
onClick={() => 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"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>

View File

@ -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 (
<p className="text-sm text-muted-foreground italic px-1">
<div className="rounded-lg bg-accent/[0.04] border border-accent/10 px-4 py-3 flex items-start gap-3 animate-fade-in">
<Sparkles className="h-4 w-4 text-accent shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground italic">
{briefing}
</p>
</div>
);
}

View File

@ -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<string, number>;
};
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 (
<svg width={size} height={size} className="shrink-0 -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--border))"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 0.8s ease-out' }}
/>
</svg>
);
}
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}
</p>
</div>
{stat.ring ? (
<ProgressRing {...stat.ring} />
) : stat.icon ? (
<div className={`p-1.5 rounded-md ${stat.glowBg}`}>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
) : null}
</div>
</CardContent>
</Card>

View File

@ -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<string, string> = {
export default function TodoWidget({ todos }: TodoWidgetProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [hoveredId, setHoveredId] = useState<number | null>(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 (
<Card>
<Card className="h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-500/10">
@ -44,21 +60,29 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
</CardHeader>
<CardContent>
{todos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
All caught up.
</p>
<div className="flex flex-col items-center justify-center py-6 gap-2">
<div className="rounded-full bg-muted p-4">
<CheckCircle2 className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">Your slate is clean</p>
</div>
) : (
<div className="space-y-0.5">
{todos.slice(0, 5).map((todo) => {
const isOverdue = isPast(endOfDay(new Date(todo.due_date)));
const isHovered = hoveredId === todo.id;
return (
<div
key={todo.id}
onClick={() => 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"
>
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
<div className="relative flex items-center gap-1.5 shrink-0">
<div className={cn('flex items-center gap-1.5', isHovered && 'invisible')}>
<span className={cn(
'text-xs shrink-0 whitespace-nowrap',
isOverdue ? 'text-red-400' : 'text-muted-foreground'
@ -70,6 +94,22 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
{todo.priority}
</Badge>
</div>
{isHovered && (
<div className="absolute inset-0 flex items-center justify-end">
<button
onClick={(e) => {
e.stopPropagation();
toggleTodo.mutate(todo.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>
)}
</div>
</div>
);
})}
</div>

View File

@ -49,7 +49,7 @@ export default function TrackedProjectsWidget() {
if (!tasks || tasks.length === 0) return null;
return (
<Card>
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">

View File

@ -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<string, { icon: typeof CheckSquare; color: string; label: string }> = {
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<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' },
};
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<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
@ -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 (
<Card className="h-full">
<CardHeader>
<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>
<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'}
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>
{items.length === 0 ? (
<CardContent className="flex-1 min-h-0 flex flex-col">
{filteredCount === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
Nothing upcoming
{focusMode ? 'Nothing for today or tomorrow' : 'Nothing upcoming'}
</p>
) : (
<ScrollArea className="max-h-[400px] -mr-2 pr-2">
<div className="space-y-0.5">
{items.map((item, index) => {
<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 bg-card z-10 w-full flex items-center gap-1.5 pb-1.5 border-b border-border cursor-pointer select-none',
groupIdx === 0 ? 'pt-0' : 'pt-3'
)}
>
<ChevronRight
className={cn(
'h-3 w-3 text-muted-foreground transition-transform duration-150',
!isCollapsed && 'rotate-90'
)}
/>
<span
className={cn(
'text-xs font-semibold uppercase tracking-wider',
isTodayGroup ? 'text-accent' : 'text-muted-foreground'
)}
>
{label}
</span>
{isCollapsed && (
<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 animDelay = Math.min(idx, 8) * 30;
const config = typeConfig[item.type] || typeConfig.todo;
const Icon = config.icon;
const itemKey = `${item.type}-${item.id}-${idx}`;
const isPast = isEventPast(item, clientNow);
const isHovered = hoveredItem === itemKey;
const timeLabel = formatTime(item);
return (
<div
key={`${item.type}-${item.id}-${index}`}
key={itemKey}
onClick={() => 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"
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}
>
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
{/* Title */}
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
<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')
: format(new Date(item.date), 'MMM d')}
{/* 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>
<span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right hidden sm:block', config.color)}>
)}
{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 w-14 text-center hidden sm:block',
'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' :
item.priority === 'low' ? 'bg-green-500/10 text-green-400' :
item.priority === 'none' ? 'bg-gray-500/10 text-gray-400' :
'invisible'
'bg-green-500/10 text-green-400'
)}>
{item.priority || ''}
{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>
);
})}

View File

@ -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'
)}
>
<span
@ -69,7 +69,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
>
{day.dayNum}
</span>
<div className="flex items-center gap-1 mt-0.5 min-h-[8px]">
<div className="flex items-center gap-1 mt-0.5 min-h-[8px] relative">
{day.items.slice(0, 4).map((item) => (
<div
key={`${item.type}-${item.id}`}
@ -78,6 +78,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
!item.color && (typeColors[item.type] || 'bg-muted-foreground')
)}
style={item.color ? { backgroundColor: item.color } : undefined}
title={item.title}
/>
))}
{day.items.length > 4 && (
@ -85,6 +86,9 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
+{day.items.length - 4}
</span>
)}
{day.isToday && (
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-accent animate-pulse-dot" />
)}
</div>
</div>
))}

View File

@ -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<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 ${
direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'
}`}>
{OPTIONS.map((opt) => (
{options.map((opt) => (
<button
key={opt.value}
role="menuitem"

View File

@ -14,7 +14,7 @@ const MAX_TOASTS = 3;
interface AlertsContextValue {
alerts: Reminder[];
dismiss: (id: number) => void;
snooze: (id: number, minutes: 5 | 10 | 15) => void;
snooze: (id: number, minutes: number) => void;
}
const AlertsContext = createContext<AlertsContextValue>({
@ -73,7 +73,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
});
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() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
@ -106,7 +106,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
dismissMutation.mutate(id);
}, [dismissMutation, updateSummaryToast]);
const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => {
const handleSnooze = useCallback((id: number, minutes: number) => {
toast.dismiss(`reminder-${id}`);
firedRef.current.delete(id);
updateSummaryToast();

View File

@ -422,5 +422,44 @@ form[data-submitted] input:invalid + button {
.animate-drift-1 { animation: drift-1 25s ease-in-out infinite; }
.animate-drift-2 { animation: drift-2 30s ease-in-out infinite; }
.animate-drift-3 { animation: drift-3 20s ease-in-out infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes slide-in-row {
from {
opacity: 0;
transform: translateX(-6px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes content-reveal {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-slide-up { animation: slide-up 0.5s ease-out both; }
.animate-fade-in { animation: fade-in 0.3s ease-out both; }
.animate-pulse-dot { animation: pulse-dot 2s ease-in-out infinite; }
.animate-slide-in-row { animation: slide-in-row 250ms ease-out both; }
.animate-content-reveal { animation: content-reveal 400ms ease-out both; }
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -335,6 +335,7 @@ export interface DashboardData {
by_status: Record<string, number>;
};
total_incomplete_todos: number;
total_todos: number;
starred_events: Array<{
id: number;
title: string;
@ -359,11 +360,14 @@ export interface UpcomingItem {
title: string;
date: string;
datetime?: string;
end_datetime?: string;
priority?: string;
category?: string;
all_day?: boolean;
color?: string;
is_starred?: boolean;
snoozed_until?: string;
is_overdue?: boolean;
}
export interface UpcomingResponse {