Compare commits
No commits in common. "c21d7592ae53b8338164056974856854a6f964a5" and "1291807847efeb63db7d737641f3eb161f99f99c" have entirely different histories.
c21d7592ae
...
1291807847
@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_, case
|
from sqlalchemy import select, func, or_
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
@ -84,16 +84,14 @@ async def get_dashboard(
|
|||||||
projects_by_status_result = await db.execute(projects_by_status_query)
|
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}
|
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
|
||||||
|
|
||||||
# Todo counts: total and incomplete in a single query
|
# Total incomplete todos count (scoped to user)
|
||||||
todo_counts_result = await db.execute(
|
total_incomplete_result = await db.execute(
|
||||||
select(
|
select(func.count(Todo.id)).where(
|
||||||
func.count(Todo.id).label("total"),
|
Todo.user_id == current_user.id,
|
||||||
func.count(case((Todo.completed == False, Todo.id))).label("incomplete"),
|
Todo.completed == False,
|
||||||
).where(Todo.user_id == current_user.id)
|
)
|
||||||
)
|
)
|
||||||
todo_row = todo_counts_result.one()
|
total_incomplete_todos = total_incomplete_result.scalar()
|
||||||
total_todos = todo_row.total
|
|
||||||
total_incomplete_todos = todo_row.incomplete
|
|
||||||
|
|
||||||
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
||||||
starred_query = select(CalendarEvent).where(
|
starred_query = select(CalendarEvent).where(
|
||||||
@ -150,7 +148,6 @@ async def get_dashboard(
|
|||||||
"by_status": projects_by_status
|
"by_status": projects_by_status
|
||||||
},
|
},
|
||||||
"total_incomplete_todos": total_incomplete_todos,
|
"total_incomplete_todos": total_incomplete_todos,
|
||||||
"total_todos": total_todos,
|
|
||||||
"starred_events": starred_events_data
|
"starred_events": starred_events_data
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,43 +165,39 @@ async def get_upcoming(
|
|||||||
cutoff_date = today + timedelta(days=days)
|
cutoff_date = today + timedelta(days=days)
|
||||||
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
||||||
today_start = datetime.combine(today, datetime.min.time())
|
today_start = datetime.combine(today, datetime.min.time())
|
||||||
overdue_floor = today - timedelta(days=30)
|
|
||||||
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
|
||||||
|
|
||||||
# Subquery: calendar IDs belonging to this user
|
# Subquery: calendar IDs belonging to this user
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||||
|
|
||||||
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
# Get upcoming todos with due dates (today onward only, scoped to user)
|
||||||
todos_query = select(Todo).where(
|
todos_query = select(Todo).where(
|
||||||
Todo.user_id == current_user.id,
|
Todo.user_id == current_user.id,
|
||||||
Todo.completed == False,
|
Todo.completed == False,
|
||||||
Todo.due_date.isnot(None),
|
Todo.due_date.isnot(None),
|
||||||
Todo.due_date >= overdue_floor,
|
Todo.due_date >= today,
|
||||||
Todo.due_date <= cutoff_date
|
Todo.due_date <= cutoff_date
|
||||||
)
|
)
|
||||||
|
todos_result = await db.execute(todos_query)
|
||||||
|
todos = todos_result.scalars().all()
|
||||||
|
|
||||||
|
# Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars)
|
||||||
events_query = select(CalendarEvent).where(
|
events_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
CalendarEvent.start_datetime >= today_start,
|
CalendarEvent.start_datetime >= today_start,
|
||||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
)
|
)
|
||||||
|
events_result = await db.execute(events_query)
|
||||||
|
events = events_result.scalars().all()
|
||||||
|
|
||||||
|
# Get upcoming reminders (today onward only, scoped to user)
|
||||||
reminders_query = select(Reminder).where(
|
reminders_query = select(Reminder).where(
|
||||||
Reminder.user_id == current_user.id,
|
Reminder.user_id == current_user.id,
|
||||||
Reminder.is_active == True,
|
Reminder.is_active == True,
|
||||||
Reminder.is_dismissed == False,
|
Reminder.is_dismissed == False,
|
||||||
Reminder.remind_at >= overdue_floor_dt,
|
Reminder.remind_at >= today_start,
|
||||||
Reminder.remind_at <= cutoff_datetime
|
Reminder.remind_at <= cutoff_datetime
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute queries sequentially (single session cannot run concurrent queries)
|
|
||||||
todos_result = await db.execute(todos_query)
|
|
||||||
todos = todos_result.scalars().all()
|
|
||||||
|
|
||||||
events_result = await db.execute(events_query)
|
|
||||||
events = events_result.scalars().all()
|
|
||||||
|
|
||||||
reminders_result = await db.execute(reminders_query)
|
reminders_result = await db.execute(reminders_query)
|
||||||
reminders = reminders_result.scalars().all()
|
reminders = reminders_result.scalars().all()
|
||||||
|
|
||||||
@ -219,34 +212,28 @@ async def get_upcoming(
|
|||||||
"date": todo.due_date.isoformat() if todo.due_date else None,
|
"date": todo.due_date.isoformat() if todo.due_date else None,
|
||||||
"datetime": None,
|
"datetime": None,
|
||||||
"priority": todo.priority,
|
"priority": todo.priority,
|
||||||
"category": todo.category,
|
"category": todo.category
|
||||||
"is_overdue": todo.due_date < today if todo.due_date else False,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
end_dt = event.end_datetime
|
|
||||||
upcoming_items.append({
|
upcoming_items.append({
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": event.title,
|
"title": event.title,
|
||||||
"date": event.start_datetime.date().isoformat(),
|
"date": event.start_datetime.date().isoformat(),
|
||||||
"datetime": event.start_datetime.isoformat(),
|
"datetime": event.start_datetime.isoformat(),
|
||||||
"end_datetime": end_dt.isoformat() if end_dt else None,
|
|
||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color,
|
"color": event.color,
|
||||||
"is_starred": event.is_starred,
|
"is_starred": event.is_starred
|
||||||
})
|
})
|
||||||
|
|
||||||
for reminder in reminders:
|
for reminder in reminders:
|
||||||
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
|
|
||||||
upcoming_items.append({
|
upcoming_items.append({
|
||||||
"type": "reminder",
|
"type": "reminder",
|
||||||
"id": reminder.id,
|
"id": reminder.id,
|
||||||
"title": reminder.title,
|
"title": reminder.title,
|
||||||
"date": remind_at_date.isoformat() if remind_at_date else None,
|
"date": reminder.remind_at.date().isoformat(),
|
||||||
"datetime": reminder.remind_at.isoformat() if reminder.remind_at else None,
|
"datetime": reminder.remind_at.isoformat()
|
||||||
"snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None,
|
|
||||||
"is_overdue": remind_at_date < today if remind_at_date else False,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by date/datetime
|
# Sort by date/datetime
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class ReminderUpdate(BaseModel):
|
|||||||
class ReminderSnooze(BaseModel):
|
class ReminderSnooze(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
minutes: int = Field(ge=1, le=1440)
|
minutes: Literal[5, 10, 15]
|
||||||
client_now: Optional[datetime] = None
|
client_now: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { Reminder } from '@/types';
|
|||||||
interface AlertBannerProps {
|
interface AlertBannerProps {
|
||||||
alerts: Reminder[];
|
alerts: Reminder[];
|
||||||
onDismiss: (id: number) => void;
|
onDismiss: (id: number) => void;
|
||||||
onSnooze: (id: number, minutes: number) => void;
|
onSnooze: (id: number, minutes: 5 | 10 | 15) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBannerProps) {
|
export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBannerProps) {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface DashboardEvent {
|
interface DashboardEvent {
|
||||||
id: number;
|
id: number;
|
||||||
@ -19,39 +17,12 @@ interface CalendarWidgetProps {
|
|||||||
events: DashboardEvent[];
|
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) {
|
export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const todayStr = format(new Date(), 'yyyy-MM-dd');
|
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 (
|
return (
|
||||||
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||||
@ -62,58 +33,29 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
<div className="rounded-full bg-muted p-4">
|
No events today
|
||||||
<Calendar className="h-8 w-8 text-muted-foreground" />
|
</p>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Enjoy the free time</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{events.map((event) => {
|
{events.map((event) => (
|
||||||
const timeState = getEventTimeState(event, clientNow);
|
<div
|
||||||
const progress = getProgressPercent(event, clientNow);
|
key={event.id}
|
||||||
const isCurrent = timeState === 'current';
|
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
|
||||||
const isPast = timeState === 'past';
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
|
>
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
|
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
|
||||||
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',
|
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
|
||||||
isCurrent && 'bg-accent/[0.05]',
|
{event.all_day
|
||||||
isPast && 'opacity-50'
|
? 'All day'
|
||||||
)}
|
: `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`}
|
||||||
>
|
</span>
|
||||||
{/* Time progress bar — always rendered for consistent layout */}
|
<span className="text-sm font-medium truncate">{event.title}</span>
|
||||||
<div
|
</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))' }}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
|
|
||||||
{event.all_day
|
|
||||||
? 'All day'
|
|
||||||
: `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium truncate">{event.title}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { differenceInCalendarDays, format } from 'date-fns';
|
import { differenceInCalendarDays, format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface CountdownWidgetProps {
|
interface CountdownWidgetProps {
|
||||||
events: Array<{
|
events: Array<{
|
||||||
@ -17,22 +16,16 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
|
|||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="rounded-lg bg-amber-500/[0.07] border border-amber-500/10 px-3.5 py-2 space-y-1">
|
||||||
{visible.map((event) => {
|
{visible.map((event) => {
|
||||||
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
|
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
|
||||||
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
|
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
|
||||||
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
|
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
|
||||||
const isUrgent = days <= 3;
|
|
||||||
const isFar = days >= 7;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
|
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
|
||||||
className={cn(
|
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
|
||||||
'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" />
|
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||||
<span className="text-sm text-amber-200/90 truncate">
|
<span className="text-sm text-amber-200/90 truncate">
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Bell, Plus, Calendar as CalIcon, ListTodo, RefreshCw } from 'lucide-react';
|
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
|
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
@ -22,7 +22,6 @@ import ReminderForm from '../reminders/ReminderForm';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
function getGreeting(name?: string): string {
|
function getGreeting(name?: string): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
@ -36,14 +35,12 @@ function getGreeting(name?: string): string {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
|
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
|
||||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Click outside to close dropdown
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
@ -54,47 +51,7 @@ export default function DashboardPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [dropdownOpen]);
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
// Keyboard quick-add: Ctrl+N / Cmd+N opens dropdown, e/t/r selects type
|
const { data, isLoading } = useQuery({
|
||||||
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'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -102,8 +59,6 @@ export default function DashboardPage() {
|
|||||||
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
|
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
|
||||||
refetchInterval: 120_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: upcomingData } = useQuery({
|
const { data: upcomingData } = useQuery({
|
||||||
@ -115,8 +70,6 @@ export default function DashboardPage() {
|
|||||||
const { data } = await api.get<UpcomingResponse>(`/upcoming?days=${days}&client_date=${clientDate}`);
|
const { data } = await api.get<UpcomingResponse>(`/upcoming?days=${days}&client_date=${clientDate}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
|
||||||
refetchInterval: 120_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: weatherData } = useQuery<WeatherData>({
|
const { data: weatherData } = useQuery<WeatherData>({
|
||||||
@ -130,11 +83,6 @@ export default function DashboardPage() {
|
|||||||
enabled: !!(settings?.weather_city || (settings?.weather_lat != null && settings?.weather_lon != null)),
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@ -159,10 +107,6 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAgo = dataUpdatedAt
|
|
||||||
? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header — greeting + date + quick add */}
|
{/* Header — greeting + date + quick add */}
|
||||||
@ -171,25 +115,9 @@ export default function DashboardPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||||
{getGreeting(settings?.preferred_name || undefined)}
|
{getGreeting(settings?.preferred_name || undefined)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
<p className="text-muted-foreground text-sm">
|
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
||||||
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
</p>
|
||||||
</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>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
@ -197,40 +125,31 @@ export default function DashboardPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="h-9 w-9"
|
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')} />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{dropdownOpen && (
|
{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">
|
<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">
|
||||||
<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"
|
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); }}
|
onClick={() => { setQuickAddType('event'); setDropdownOpen(false); }}
|
||||||
>
|
>
|
||||||
<CalIcon className="h-4 w-4 text-purple-400" />
|
<CalIcon className="h-4 w-4 text-purple-400" />
|
||||||
Event
|
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>
|
||||||
<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"
|
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); }}
|
onClick={() => { setQuickAddType('todo'); setDropdownOpen(false); }}
|
||||||
>
|
>
|
||||||
<ListTodo className="h-4 w-4 text-blue-400" />
|
<ListTodo className="h-4 w-4 text-blue-400" />
|
||||||
Todo
|
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>
|
||||||
<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"
|
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); }}
|
onClick={() => { setQuickAddType('reminder'); setDropdownOpen(false); }}
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4 text-orange-400" />
|
<Bell className="h-4 w-4 text-orange-400" />
|
||||||
Reminder
|
Reminder
|
||||||
<kbd className="ml-auto text-[10px] text-muted-foreground/50 font-mono bg-muted rounded px-1 py-0.5">r</kbd>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -238,7 +157,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
|
<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">
|
<div className="space-y-3 sm:space-y-5">
|
||||||
{/* Week Timeline */}
|
{/* Week Timeline */}
|
||||||
{upcomingData && (
|
{upcomingData && (
|
||||||
<div className="animate-slide-up">
|
<div className="animate-slide-up">
|
||||||
@ -260,7 +179,6 @@ export default function DashboardPage() {
|
|||||||
<StatsWidget
|
<StatsWidget
|
||||||
projectStats={data.project_stats}
|
projectStats={data.project_stats}
|
||||||
totalIncompleteTodos={data.total_incomplete_todos}
|
totalIncompleteTodos={data.total_incomplete_todos}
|
||||||
totalTodos={data.total_todos}
|
|
||||||
weatherData={weatherData || null}
|
weatherData={weatherData || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -272,7 +190,20 @@ export default function DashboardPage() {
|
|||||||
<div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
<div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
||||||
{/* Left: Upcoming feed (wider) */}
|
{/* Left: Upcoming feed (wider) */}
|
||||||
<div className="lg:col-span-3 flex flex-col">
|
<div className="lg:col-span-3 flex flex-col">
|
||||||
<UpcomingWidget items={upcomingData?.items ?? []} />
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Countdown + Today's events + todos stacked */}
|
{/* Right: Countdown + Today's events + todos stacked */}
|
||||||
@ -281,9 +212,7 @@ export default function DashboardPage() {
|
|||||||
<CountdownWidget events={data.starred_events} />
|
<CountdownWidget events={data.starred_events} />
|
||||||
)}
|
)}
|
||||||
<CalendarWidget events={data.todays_events} />
|
<CalendarWidget events={data.todays_events} />
|
||||||
<div className="flex-1">
|
<TodoWidget todos={data.upcoming_todos} />
|
||||||
<TodoWidget todos={data.upcoming_todos} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -308,7 +237,7 @@ export default function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
key={reminder.id}
|
key={reminder.id}
|
||||||
onClick={() => navigate('/reminders', { state: { reminderId: 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"
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
|
<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="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { format, isSameDay, startOfDay, addDays, isAfter } from 'date-fns';
|
import { format, isSameDay, startOfDay, addDays, isAfter } from 'date-fns';
|
||||||
import { Sparkles } from 'lucide-react';
|
|
||||||
import type { UpcomingItem, DashboardData } from '@/types';
|
import type { UpcomingItem, DashboardData } from '@/types';
|
||||||
|
|
||||||
interface DayBriefingProps {
|
interface DayBriefingProps {
|
||||||
@ -149,11 +148,8 @@ export default function DayBriefing({ upcomingItems, dashboardData, weatherData
|
|||||||
if (!briefing) return null;
|
if (!briefing) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-accent/[0.04] border border-accent/10 px-4 py-3 flex items-start gap-3 animate-fade-in">
|
<p className="text-sm text-muted-foreground italic px-1">
|
||||||
<Sparkles className="h-4 w-4 text-accent shrink-0 mt-0.5" />
|
{briefing}
|
||||||
<p className="text-sm text-muted-foreground italic">
|
</p>
|
||||||
{briefing}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FolderKanban, CloudSun } from 'lucide-react';
|
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
interface StatsWidgetProps {
|
interface StatsWidgetProps {
|
||||||
@ -8,50 +8,12 @@ interface StatsWidgetProps {
|
|||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
};
|
};
|
||||||
totalIncompleteTodos: number;
|
totalIncompleteTodos: number;
|
||||||
totalTodos?: number;
|
|
||||||
weatherData?: { temp: number; description: string; city?: string } | null;
|
weatherData?: { temp: number; description: string; city?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressRing({ value, total, color }: { value: number; total: number; color: string }) {
|
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
|
||||||
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 navigate = useNavigate();
|
||||||
|
|
||||||
const inProgress = projectStats.by_status['in_progress'] || 0;
|
|
||||||
const completedTodos = (totalTodos || 0) - totalIncompleteTodos;
|
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
label: 'PROJECTS',
|
label: 'PROJECTS',
|
||||||
@ -60,25 +22,22 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, totalT
|
|||||||
color: 'text-blue-400',
|
color: 'text-blue-400',
|
||||||
glowBg: 'bg-blue-500/10',
|
glowBg: 'bg-blue-500/10',
|
||||||
onClick: () => navigate('/projects'),
|
onClick: () => navigate('/projects'),
|
||||||
ring: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'IN PROGRESS',
|
label: 'IN PROGRESS',
|
||||||
value: inProgress,
|
value: projectStats.by_status['in_progress'] || 0,
|
||||||
icon: null,
|
icon: TrendingUp,
|
||||||
color: 'text-purple-400',
|
color: 'text-purple-400',
|
||||||
glowBg: 'bg-purple-500/10',
|
glowBg: 'bg-purple-500/10',
|
||||||
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
|
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
|
||||||
ring: { value: inProgress, total: projectStats.total, color: 'hsl(270, 70%, 60%)' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'OPEN TODOS',
|
label: 'OPEN TODOS',
|
||||||
value: totalIncompleteTodos,
|
value: totalIncompleteTodos,
|
||||||
icon: null,
|
icon: CheckSquare,
|
||||||
color: 'text-teal-400',
|
color: 'text-teal-400',
|
||||||
glowBg: 'bg-teal-500/10',
|
glowBg: 'bg-teal-500/10',
|
||||||
onClick: () => navigate('/todos'),
|
onClick: () => navigate('/todos'),
|
||||||
ring: totalTodos ? { value: completedTodos, total: totalTodos, color: 'hsl(170, 70%, 50%)' } : null,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -100,13 +59,9 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, totalT
|
|||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{stat.ring ? (
|
<div className={`p-1.5 rounded-md ${stat.glowBg}`}>
|
||||||
<ProgressRing {...stat.ring} />
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||||
) : stat.icon ? (
|
</div>
|
||||||
<div className={`p-1.5 rounded-md ${stat.glowBg}`}>
|
|
||||||
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { format, isPast, endOfDay } from 'date-fns';
|
import { format, isPast, endOfDay } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { CheckCircle2, Check } from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
|
||||||
|
|
||||||
interface DashboardTodo {
|
interface DashboardTodo {
|
||||||
id: number;
|
id: number;
|
||||||
@ -35,21 +31,9 @@ const dotColors: Record<string, string> = {
|
|||||||
|
|
||||||
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<Card className="h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||||
@ -60,55 +44,31 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{todos.length === 0 ? (
|
{todos.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-6 gap-2">
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
<div className="rounded-full bg-muted p-4">
|
All caught up.
|
||||||
<CheckCircle2 className="h-8 w-8 text-muted-foreground" />
|
</p>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Your slate is clean</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{todos.slice(0, 5).map((todo) => {
|
{todos.slice(0, 5).map((todo) => {
|
||||||
const isOverdue = isPast(endOfDay(new Date(todo.due_date)));
|
const isOverdue = isPast(endOfDay(new Date(todo.due_date)));
|
||||||
const isHovered = hoveredId === todo.id;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
|
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
|
||||||
onMouseEnter={() => setHoveredId(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"
|
||||||
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)} />
|
<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>
|
<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">
|
<span className={cn(
|
||||||
<div className={cn('flex items-center gap-1.5', isHovered && 'invisible')}>
|
'text-xs shrink-0 whitespace-nowrap',
|
||||||
<span className={cn(
|
isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
||||||
'text-xs shrink-0 whitespace-nowrap',
|
)}>
|
||||||
isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
{format(new Date(todo.due_date), 'MMM d')}
|
||||||
)}>
|
{isOverdue && ' overdue'}
|
||||||
{format(new Date(todo.due_date), 'MMM d')}
|
</span>
|
||||||
{isOverdue && ' overdue'}
|
<Badge className={cn('text-[9px] shrink-0 py-0', priorityColors[todo.priority] || priorityColors.medium)}>
|
||||||
</span>
|
{todo.priority}
|
||||||
<Badge className={cn('text-[9px] shrink-0 py-0', priorityColors[todo.priority] || priorityColors.medium)}>
|
</Badge>
|
||||||
{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;
|
if (!tasks || tasks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@ -1,151 +1,26 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
import { format } from 'date-fns';
|
||||||
import { format, isToday, isTomorrow, isThisWeek } from 'date-fns';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Target,
|
|
||||||
ChevronRight,
|
|
||||||
Check,
|
|
||||||
Clock,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { UpcomingItem } from '@/types';
|
import type { UpcomingItem } from '@/types';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils';
|
|
||||||
import api from '@/lib/api';
|
|
||||||
|
|
||||||
interface UpcomingWidgetProps {
|
interface UpcomingWidgetProps {
|
||||||
items: UpcomingItem[];
|
items: UpcomingItem[];
|
||||||
|
days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeConfig: Record<string, { hoverGlow: string; pillBg: string; pillText: string; label: string }> = {
|
const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; label: string }> = {
|
||||||
todo: { hoverGlow: 'hover:bg-blue-500/[0.08]', pillBg: 'bg-blue-500/15', pillText: 'text-blue-400', label: 'TODO' },
|
todo: { icon: CheckSquare, color: 'text-blue-400', label: 'TODO' },
|
||||||
event: { hoverGlow: 'hover:bg-purple-500/[0.08]', pillBg: 'bg-purple-500/15', pillText: 'text-purple-400', label: 'EVENT' },
|
event: { icon: Calendar, color: 'text-purple-400', label: 'EVENT' },
|
||||||
reminder: { hoverGlow: 'hover:bg-orange-500/[0.08]', pillBg: 'bg-orange-500/15', pillText: 'text-orange-400', label: 'REMINDER' },
|
reminder: { icon: Bell, color: 'text-orange-400', label: 'REMINDER' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function getMinutesUntilTomorrowMorning(): number {
|
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
|
||||||
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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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(() => {
|
const handleItemClick = (item: UpcomingItem) => {
|
||||||
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) {
|
switch (item.type) {
|
||||||
case 'event': {
|
case 'event': {
|
||||||
const dateStr = item.datetime
|
const dateStr = item.datetime
|
||||||
@ -161,264 +36,58 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
|||||||
navigate('/reminders', { state: { reminderId: item.id } });
|
navigate('/reminders', { state: { reminderId: item.id } });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
};
|
||||||
|
|
||||||
const toggleDay = useCallback((dateKey: string) => {
|
|
||||||
setCollapsedDays((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(dateKey)) next.delete(dateKey);
|
|
||||||
else next.add(dateKey);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSnooze = useCallback((id: number, minutes: number, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSnoozeOpen(null);
|
|
||||||
snoozeReminder.mutate({ id, minutes });
|
|
||||||
}, [snoozeReminder]);
|
|
||||||
|
|
||||||
const formatTime = useCallback((item: UpcomingItem) => {
|
|
||||||
if (item.type === 'todo') {
|
|
||||||
const d = new Date(item.date + 'T00:00:00');
|
|
||||||
if (isToday(d)) return 'Due today';
|
|
||||||
return null; // date shown in header
|
|
||||||
}
|
|
||||||
if (item.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 (
|
return (
|
||||||
<Card className="flex flex-col h-full overflow-hidden">
|
<Card className="h-full">
|
||||||
<CardHeader className="shrink-0">
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-accent/10">
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
<ArrowRight className="h-4 w-4 text-accent" />
|
<ArrowRight className="h-4 w-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
Upcoming
|
Upcoming
|
||||||
<span className="text-xs text-muted-foreground font-normal ml-1">
|
|
||||||
{filteredCount} {filteredCount === 1 ? 'item' : 'items'}
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-xs text-muted-foreground">{days} days</span>
|
||||||
<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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 min-h-0 flex flex-col">
|
<CardContent>
|
||||||
{filteredCount === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
{focusMode ? 'Nothing for today or tomorrow' : 'Nothing upcoming'}
|
Nothing upcoming
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="flex-1 min-h-0 -mr-2 pr-2">
|
<ScrollArea className="max-h-[400px] -mr-2 pr-2">
|
||||||
<div>
|
<div className="space-y-0.5">
|
||||||
{dayEntries.map(([dateKey, dayItems], groupIdx) => {
|
{items.map((item, index) => {
|
||||||
const isCollapsed = collapsedDays.has(dateKey);
|
const config = typeConfig[item.type] || typeConfig.todo;
|
||||||
const label = getDayLabel(dateKey);
|
const Icon = config.icon;
|
||||||
const isTodayGroup = isToday(new Date(dateKey + 'T00:00:00'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dateKey}>
|
<div
|
||||||
{/* Sticky day header */}
|
key={`${item.type}-${item.id}-${index}`}
|
||||||
<button
|
onClick={() => handleItemClick(item)}
|
||||||
onClick={() => toggleDay(dateKey)}
|
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(
|
>
|
||||||
'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',
|
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
|
||||||
groupIdx === 0 ? 'pt-0' : 'pt-3'
|
<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
|
||||||
<ChevronRight
|
? format(new Date(item.datetime), 'MMM d, h:mm a')
|
||||||
className={cn(
|
: format(new Date(item.date), 'MMM d')}
|
||||||
'h-3 w-3 text-muted-foreground transition-transform duration-150',
|
</span>
|
||||||
!isCollapsed && 'rotate-90'
|
<span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right hidden sm:block', config.color)}>
|
||||||
)}
|
{config.label}
|
||||||
/>
|
</span>
|
||||||
<span
|
<span className={cn(
|
||||||
className={cn(
|
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center hidden sm:block',
|
||||||
'text-xs font-semibold uppercase tracking-wider',
|
item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
|
||||||
isTodayGroup ? 'text-accent' : 'text-muted-foreground'
|
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' :
|
||||||
{label}
|
'invisible'
|
||||||
</span>
|
)}>
|
||||||
{isCollapsed && (
|
{item.priority || ''}
|
||||||
<span className="text-[10px] text-muted-foreground font-normal ml-auto mr-1">
|
</span>
|
||||||
{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 itemKey = `${item.type}-${item.id}-${idx}`;
|
|
||||||
const isPast = isEventPast(item, clientNow);
|
|
||||||
const isHovered = hoveredItem === itemKey;
|
|
||||||
const timeLabel = formatTime(item);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={itemKey}
|
|
||||||
onClick={() => handleItemClick(item)}
|
|
||||||
onMouseEnter={() => setHoveredItem(itemKey)}
|
|
||||||
onMouseLeave={() => { setHoveredItem(null); setSnoozeOpen(null); }}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 py-1.5 px-2 rounded-md 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>
|
|
||||||
|
|
||||||
{/* Status pills */}
|
|
||||||
{item.is_overdue && item.type === 'todo' && (
|
|
||||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-red-500/10 text-red-400 shrink-0">
|
|
||||||
OVERDUE
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.type === 'reminder' && item.snoozed_until && (
|
|
||||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-amber-500/10 text-amber-400 shrink-0">
|
|
||||||
SNOOZED
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.type === 'reminder' && item.is_overdue && !item.snoozed_until && (
|
|
||||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-red-500/10 text-red-400 shrink-0">
|
|
||||||
OVERDUE
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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 hidden sm:inline-block',
|
|
||||||
item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
|
|
||||||
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
|
|
||||||
'bg-green-500/10 text-green-400'
|
|
||||||
)}>
|
|
||||||
{item.priority}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -49,8 +49,8 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
|||||||
day.isToday
|
day.isToday
|
||||||
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
|
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
|
||||||
: day.isPast
|
: day.isPast
|
||||||
? 'border-transparent opacity-50 hover:opacity-75 hover:scale-[1.04] hover:bg-card-elevated'
|
? 'border-transparent opacity-50 hover:opacity-75'
|
||||||
: 'border-transparent hover:border-border/50 hover:scale-[1.04] hover:bg-card-elevated'
|
: 'border-transparent hover:border-border/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -69,7 +69,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
|||||||
>
|
>
|
||||||
{day.dayNum}
|
{day.dayNum}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 mt-0.5 min-h-[8px] relative">
|
<div className="flex items-center gap-1 mt-0.5 min-h-[8px]">
|
||||||
{day.items.slice(0, 4).map((item) => (
|
{day.items.slice(0, 4).map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.type}-${item.id}`}
|
key={`${item.type}-${item.id}`}
|
||||||
@ -78,7 +78,6 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
|||||||
!item.color && (typeColors[item.type] || 'bg-muted-foreground')
|
!item.color && (typeColors[item.type] || 'bg-muted-foreground')
|
||||||
)}
|
)}
|
||||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||||
title={item.title}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{day.items.length > 4 && (
|
{day.items.length > 4 && (
|
||||||
@ -86,9 +85,6 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
|||||||
+{day.items.length - 4}
|
+{day.items.length - 4}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,19 +2,18 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
interface SnoozeDropdownProps {
|
interface SnoozeDropdownProps {
|
||||||
onSnooze: (minutes: number) => void;
|
onSnooze: (minutes: 5 | 10 | 15) => void;
|
||||||
label: string;
|
label: string;
|
||||||
direction?: 'up' | 'down';
|
direction?: 'up' | 'down';
|
||||||
options?: { value: number; label: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: { value: number; label: string }[] = [
|
const OPTIONS: { value: 5 | 10 | 15; label: string }[] = [
|
||||||
{ value: 5, label: '5 minutes' },
|
{ value: 5, label: '5 minutes' },
|
||||||
{ value: 10, label: '10 minutes' },
|
{ value: 10, label: '10 minutes' },
|
||||||
{ value: 15, label: '15 minutes' },
|
{ value: 15, label: '15 minutes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SnoozeDropdown({ onSnooze, label, direction = 'up', options = DEFAULT_OPTIONS }: SnoozeDropdownProps) {
|
export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: SnoozeDropdownProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -52,7 +51,7 @@ export default function SnoozeDropdown({ onSnooze, label, direction = 'up', opti
|
|||||||
<div role="menu" className={`absolute right-0 w-32 rounded-md border bg-popover shadow-lg z-50 py-1 animate-fade-in ${
|
<div role="menu" className={`absolute right-0 w-32 rounded-md border bg-popover shadow-lg z-50 py-1 animate-fade-in ${
|
||||||
direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'
|
direction === 'up' ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
}`}>
|
}`}>
|
||||||
{options.map((opt) => (
|
{OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const MAX_TOASTS = 3;
|
|||||||
interface AlertsContextValue {
|
interface AlertsContextValue {
|
||||||
alerts: Reminder[];
|
alerts: Reminder[];
|
||||||
dismiss: (id: number) => void;
|
dismiss: (id: number) => void;
|
||||||
snooze: (id: number, minutes: number) => void;
|
snooze: (id: number, minutes: 5 | 10 | 15) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlertsContext = createContext<AlertsContextValue>({
|
const AlertsContext = createContext<AlertsContextValue>({
|
||||||
@ -73,7 +73,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snoozeMutation = useMutation({
|
const snoozeMutation = useMutation({
|
||||||
mutationFn: ({ id, minutes }: { id: number; minutes: number }) =>
|
mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) =>
|
||||||
api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
|
api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
@ -106,7 +106,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
|||||||
dismissMutation.mutate(id);
|
dismissMutation.mutate(id);
|
||||||
}, [dismissMutation, updateSummaryToast]);
|
}, [dismissMutation, updateSummaryToast]);
|
||||||
|
|
||||||
const handleSnooze = useCallback((id: number, minutes: number) => {
|
const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => {
|
||||||
toast.dismiss(`reminder-${id}`);
|
toast.dismiss(`reminder-${id}`);
|
||||||
firedRef.current.delete(id);
|
firedRef.current.delete(id);
|
||||||
updateSummaryToast();
|
updateSummaryToast();
|
||||||
|
|||||||
@ -422,44 +422,5 @@ form[data-submitted] input:invalid + button {
|
|||||||
.animate-drift-1 { animation: drift-1 25s ease-in-out infinite; }
|
.animate-drift-1 { animation: drift-1 25s ease-in-out infinite; }
|
||||||
.animate-drift-2 { animation: drift-2 30s 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; }
|
.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-slide-up { animation: slide-up 0.5s ease-out both; }
|
||||||
.animate-fade-in { animation: fade-in 0.3s 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,7 +335,6 @@ export interface DashboardData {
|
|||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
};
|
};
|
||||||
total_incomplete_todos: number;
|
total_incomplete_todos: number;
|
||||||
total_todos: number;
|
|
||||||
starred_events: Array<{
|
starred_events: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@ -360,14 +359,11 @@ export interface UpcomingItem {
|
|||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
datetime?: string;
|
datetime?: string;
|
||||||
end_datetime?: string;
|
|
||||||
priority?: string;
|
priority?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
all_day?: boolean;
|
all_day?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
is_starred?: boolean;
|
is_starred?: boolean;
|
||||||
snoozed_until?: string;
|
|
||||||
is_overdue?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpcomingResponse {
|
export interface UpcomingResponse {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user