W-02: Renamed layout/AmbientBackground → AppAmbientBackground to avoid naming collision with auth/AmbientBackground (IDE auto-import confusion). S-01: Added visibilitychange listener to re-sync clock after tab sleep/resume. Previously the interval would drift after laptop sleep or long tab backgrounding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { format } 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';
|
|
import { useAlerts } from '@/hooks/useAlerts';
|
|
import StatsWidget from './StatsWidget';
|
|
import TodoWidget from './TodoWidget';
|
|
import CalendarWidget from './CalendarWidget';
|
|
import UpcomingWidget from './UpcomingWidget';
|
|
import WeekTimeline from './WeekTimeline';
|
|
import DayBriefing from './DayBriefing';
|
|
import CountdownWidget from './CountdownWidget';
|
|
import TrackedProjectsWidget from './TrackedProjectsWidget';
|
|
import AlertBanner from './AlertBanner';
|
|
import EventForm from '../calendar/EventForm';
|
|
import TodoForm from '../todos/TodoForm';
|
|
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();
|
|
const suffix = name ? `, ${name}.` : '.';
|
|
if (hour < 5) return `Good night${suffix}`;
|
|
if (hour < 12) return `Good morning${suffix}`;
|
|
if (hour < 17) return `Good afternoon${suffix}`;
|
|
if (hour < 21) return `Good evening${suffix}`;
|
|
return `Good night${suffix}`;
|
|
}
|
|
|
|
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);
|
|
const [clockNow, setClockNow] = useState(() => new Date());
|
|
|
|
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
|
useEffect(() => {
|
|
let intervalId: ReturnType<typeof setInterval>;
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
|
|
function startClock() {
|
|
clearTimeout(timeoutId);
|
|
clearInterval(intervalId);
|
|
setClockNow(new Date());
|
|
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
|
timeoutId = setTimeout(() => {
|
|
setClockNow(new Date());
|
|
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
|
}, msUntilNextMinute);
|
|
}
|
|
|
|
startClock();
|
|
|
|
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
|
function handleVisibility() {
|
|
if (document.visibilityState === 'visible') startClock();
|
|
}
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
clearInterval(intervalId);
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
};
|
|
}, []);
|
|
|
|
// Click outside to close dropdown
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setDropdownOpen(false);
|
|
}
|
|
}
|
|
if (dropdownOpen) document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [dropdownOpen]);
|
|
|
|
// 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();
|
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
|
|
return data;
|
|
},
|
|
staleTime: 60_000,
|
|
refetchInterval: 120_000,
|
|
});
|
|
|
|
const { data: upcomingData } = useQuery({
|
|
queryKey: ['upcoming', settings?.upcoming_days],
|
|
queryFn: async () => {
|
|
const days = settings?.upcoming_days || 7;
|
|
const now = new Date();
|
|
const clientDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
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>({
|
|
queryKey: ['weather'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<WeatherData>('/weather');
|
|
return data;
|
|
},
|
|
staleTime: 30 * 60 * 1000,
|
|
retry: false,
|
|
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">
|
|
<div className="px-4 md:px-6 py-6">
|
|
<div className="animate-pulse space-y-2">
|
|
<div className="h-8 w-48 rounded bg-muted" />
|
|
<div className="h-4 w-32 rounded bg-muted" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
|
<DashboardSkeleton />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-muted-foreground">Failed to load dashboard</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const updatedAgo = dataUpdatedAt
|
|
? (() => {
|
|
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
|
if (mins < 1) return 'just now';
|
|
if (mins === 1) return '1 min ago';
|
|
return `${mins} min ago`;
|
|
})()
|
|
: null;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header — greeting + date + quick add */}
|
|
<div className="px-4 md:px-6 pt-4 sm:pt-6 pb-1 sm:pb-2 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
|
{getGreeting(settings?.preferred_name || undefined)}
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<p className="text-muted-foreground text-sm">
|
|
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
|
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
|
{format(clockNow, '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
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className="h-9 w-9"
|
|
aria-haspopup="menu"
|
|
aria-expanded={dropdownOpen}
|
|
aria-label="Quick add"
|
|
>
|
|
<Plus className={cn('h-4 w-4 transition-transform duration-200', dropdownOpen && 'rotate-45')} />
|
|
</Button>
|
|
{dropdownOpen && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
|
<div className="space-y-3 sm:space-y-5 animate-content-reveal">
|
|
{/* Week Timeline */}
|
|
{upcomingData && (
|
|
<div className="animate-slide-up">
|
|
<WeekTimeline items={upcomingData.items} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Smart Briefing */}
|
|
{upcomingData && (
|
|
<DayBriefing
|
|
upcomingItems={upcomingData.items}
|
|
dashboardData={data}
|
|
weatherData={weatherData || null}
|
|
/>
|
|
)}
|
|
|
|
{/* Stats Row */}
|
|
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
|
|
<StatsWidget
|
|
projectStats={data.project_stats}
|
|
totalIncompleteTodos={data.total_incomplete_todos}
|
|
totalTodos={data.total_todos}
|
|
weatherData={weatherData || null}
|
|
/>
|
|
</div>
|
|
|
|
{/* Alert Banner */}
|
|
<AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} />
|
|
|
|
{/* Main Content — 2 columns */}
|
|
<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">
|
|
<UpcomingWidget items={upcomingData?.items ?? []} />
|
|
</div>
|
|
|
|
{/* Right: Countdown + Today's events + todos stacked */}
|
|
<div className="lg:col-span-2 flex flex-col gap-3 sm:gap-5">
|
|
{data.starred_events.length > 0 && (
|
|
<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 */}
|
|
{(() => {
|
|
const alertIds = new Set(alerts.map((a) => a.id));
|
|
const futureReminders = data.active_reminders.filter((r) => !alertIds.has(r.id));
|
|
if (futureReminders.length === 0) return null;
|
|
return (
|
|
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-md bg-orange-500/10">
|
|
<Bell className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
Active Reminders
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-0.5">
|
|
{futureReminders.map((reminder) => (
|
|
<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-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>
|
|
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
|
|
{reminder.remind_at ? format(new Date(reminder.remind_at), 'MMM d, h:mm a') : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})()}
|
|
|
|
{/* Tracked Projects */}
|
|
<div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}>
|
|
<TrackedProjectsWidget />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Add Forms */}
|
|
{quickAddType === 'event' && <EventForm event={null} onClose={() => setQuickAddType(null)} />}
|
|
{quickAddType === 'todo' && <TodoForm todo={null} onClose={() => setQuickAddType(null)} />}
|
|
{quickAddType === 'reminder' && <ReminderForm reminder={null} onClose={() => setQuickAddType(null)} />}
|
|
</div>
|
|
);
|
|
}
|