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>
This commit is contained in:
parent
8b6530c901
commit
b41b0b6635
@ -93,6 +93,12 @@ async def get_dashboard(
|
||||
)
|
||||
total_incomplete_todos = total_incomplete_result.scalar()
|
||||
|
||||
# Total todos count (for progress ring ratio)
|
||||
total_todos_result = await db.execute(
|
||||
select(func.count(Todo.id)).where(Todo.user_id == current_user.id)
|
||||
)
|
||||
total_todos = total_todos_result.scalar()
|
||||
|
||||
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
||||
starred_query = select(CalendarEvent).where(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
@ -148,6 +154,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
|
||||
}
|
||||
|
||||
|
||||
@ -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,38 @@ 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();
|
||||
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 +61,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 +111,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -226,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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 className="h-full">
|
||||
<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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { format, isToday, isTomorrow, isThisWeek } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@ -62,6 +62,11 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
||||
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(() => {
|
||||
@ -69,15 +74,29 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Toggle todo completion
|
||||
// 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: () => toast.error('Failed to complete todo'),
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(['upcoming'], context.previousData);
|
||||
}
|
||||
toast.error('Failed to complete todo');
|
||||
},
|
||||
});
|
||||
|
||||
// Snooze reminder
|
||||
@ -261,6 +280,7 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
||||
{!isCollapsed && (
|
||||
<div className="py-0.5">
|
||||
{dayItems.map((item, idx) => {
|
||||
const animDelay = Math.min(idx, 8) * 30;
|
||||
const config = typeConfig[item.type] || typeConfig.todo;
|
||||
const itemKey = `${item.type}-${item.id}-${idx}`;
|
||||
const isPast = isEventPast(item, clientNow);
|
||||
@ -275,9 +295,11 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
||||
onMouseLeave={() => { setHoveredItem(null); setSnoozeOpen(null); }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-1.5 px-2 rounded-md transition-colors duration-150 cursor-pointer',
|
||||
!hasMounted.current && 'animate-slide-in-row',
|
||||
config.hoverGlow,
|
||||
isPast && 'opacity-50'
|
||||
)}
|
||||
style={!hasMounted.current ? { animationDelay: `${animDelay}ms`, animationFillMode: 'backwards' } : undefined}
|
||||
>
|
||||
{/* Title */}
|
||||
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user