Compact dashboard: single-line rows, multi-star, weather city

- UpcomingWidget: single-line rows with icon/title/date/type/priority
- CalendarWidget: whitespace-nowrap time ranges, no wrapping
- TodoWidget: compact dot + title + date + badge on one line
- Active Reminders: single-line with dot indicator
- CountdownWidget: supports array of starred events
- StatsWidget: shows city name in weather card
- Dashboard API: returns starred_events array (up to 5) instead of single

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-20 14:28:39 +08:00
parent a638b39d4d
commit bdae07fb7d
8 changed files with 87 additions and 108 deletions

View File

@ -69,22 +69,23 @@ async def get_dashboard(
) )
total_incomplete_todos = total_incomplete_result.scalar() total_incomplete_todos = total_incomplete_result.scalar()
# Next starred event (soonest future starred event) # Starred events (upcoming, ordered by date)
now = datetime.now() now = datetime.now()
starred_query = select(CalendarEvent).where( starred_query = select(CalendarEvent).where(
CalendarEvent.is_starred == True, CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > now CalendarEvent.start_datetime > now
).order_by(CalendarEvent.start_datetime.asc()).limit(1) ).order_by(CalendarEvent.start_datetime.asc()).limit(5)
starred_result = await db.execute(starred_query) starred_result = await db.execute(starred_query)
next_starred = starred_result.scalar_one_or_none() starred_events = starred_result.scalars().all()
next_starred_event_data = None starred_events_data = [
if next_starred: {
next_starred_event_data = { "id": e.id,
"id": next_starred.id, "title": e.title,
"title": next_starred.title, "start_datetime": e.start_datetime
"start_datetime": next_starred.start_datetime
} }
for e in starred_events
]
return { return {
"todays_events": [ "todays_events": [
@ -122,7 +123,7 @@ 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,
"next_starred_event": next_starred_event_data "starred_events": starred_events_data
} }

View File

@ -37,13 +37,13 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.id} key={event.id}
className="flex items-center gap-2.5 p-2 rounded-md hover:bg-card-elevated transition-colors duration-150" className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
> >
<div <div
className="w-[5px] h-[5px] rounded-full shrink-0" className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }} style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/> />
<span className="text-xs text-muted-foreground w-[6.5rem] shrink-0 tabular-nums"> <span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
{event.all_day {event.all_day
? 'All day' ? 'All day'
: `${format(new Date(event.start_datetime), 'h:mm a')} ${format(new Date(event.end_datetime), 'h:mm a')}`} : `${format(new Date(event.start_datetime), 'h:mm a')} ${format(new Date(event.end_datetime), 'h:mm a')}`}

View File

@ -2,31 +2,33 @@ import { differenceInCalendarDays } from 'date-fns';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
interface CountdownWidgetProps { interface CountdownWidgetProps {
event: { events: Array<{
id: number; id: number;
title: string; title: string;
start_datetime: string; start_datetime: string;
}; }>;
} }
export default function CountdownWidget({ event }: CountdownWidgetProps) { export default function CountdownWidget({ events }: CountdownWidgetProps) {
const daysUntil = differenceInCalendarDays(new Date(event.start_datetime), new Date()); const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
if (daysUntil < 0) return null; if (visible.length === 0) return null;
const label = daysUntil === 0
? 'Today'
: daysUntil === 1
? '1 day'
: `${daysUntil} days`;
return ( return (
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg bg-amber-500/[0.07] border border-amber-500/10"> <div className="rounded-lg bg-amber-500/[0.07] border border-amber-500/10 px-3.5 py-2 space-y-1">
<Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400 shrink-0" /> {visible.map((event) => {
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
return (
<div key={event.id} className="flex items-center gap-2">
<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">
<span className="font-semibold tabular-nums">{label}</span> <span className="font-semibold tabular-nums">{label}</span>
{daysUntil > 0 ? ' until ' : ' — '} {days > 0 ? ' until ' : ' — '}
<span className="font-medium text-foreground">{event.title}</span> <span className="font-medium text-foreground">{event.title}</span>
</span> </span>
</div> </div>
); );
})}
</div>
);
} }

View File

@ -197,8 +197,8 @@ export default function DashboardPage() {
{/* Right: Countdown + Today's events + todos stacked */} {/* Right: Countdown + Today's events + todos stacked */}
<div className="lg:col-span-2 flex flex-col gap-5"> <div className="lg:col-span-2 flex flex-col gap-5">
{data.next_starred_event && ( {data.starred_events.length > 0 && (
<CountdownWidget event={data.next_starred_event} /> <CountdownWidget events={data.starred_events} />
)} )}
<CalendarWidget events={data.todays_events} /> <CalendarWidget events={data.todays_events} />
<TodoWidget todos={data.upcoming_todos} /> <TodoWidget todos={data.upcoming_todos} />
@ -217,19 +217,17 @@ export default function DashboardPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-0.5">
{data.active_reminders.map((reminder) => ( {data.active_reminders.map((reminder) => (
<div <div
key={reminder.id} key={reminder.id}
className="flex items-center gap-3 p-3 rounded-lg border border-transparent hover:border-border/50 hover:bg-card-elevated transition-all duration-200" className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
> >
<div className="w-1 h-8 rounded-full bg-orange-400" /> <div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<div className="flex-1"> <span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
<p className="font-medium text-sm">{reminder.title}</p> <span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
<p className="text-xs text-muted-foreground"> {format(new Date(reminder.remind_at), 'MMM d, h:mm a')}
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')} </span>
</p>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@ -7,7 +7,7 @@ interface StatsWidgetProps {
by_status: Record<string, number>; by_status: Record<string, number>;
}; };
totalIncompleteTodos: number; totalIncompleteTodos: number;
weatherData?: { temp: number; description: string } | null; weatherData?: { temp: number; description: string; city?: string } | null;
} }
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) { export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
@ -73,6 +73,11 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
<p className="text-[11px] text-muted-foreground capitalize leading-tight"> <p className="text-[11px] text-muted-foreground capitalize leading-tight">
{weatherData.description} {weatherData.description}
</p> </p>
{weatherData.city && (
<p className="text-[10px] text-muted-foreground/60 leading-tight">
{weatherData.city}
</p>
)}
</> </>
) : ( ) : (
<> <>

View File

@ -1,5 +1,5 @@
import { format, isPast, endOfDay } from 'date-fns'; import { format, isPast, endOfDay } from 'date-fns';
import { Calendar, CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } 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';
@ -22,6 +22,12 @@ const priorityColors: Record<string, string> = {
high: 'bg-red-500/10 text-red-400 border-red-500/20', high: 'bg-red-500/10 text-red-400 border-red-500/20',
}; };
const dotColors: Record<string, string> = {
high: 'bg-red-400',
medium: 'bg-yellow-400',
low: 'bg-green-400',
};
export default function TodoWidget({ todos }: TodoWidgetProps) { export default function TodoWidget({ todos }: TodoWidgetProps) {
return ( return (
<Card> <Card>
@ -39,33 +45,24 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
All caught up. All caught up.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <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)));
return ( return (
<div <div
key={todo.id} key={todo.id}
className="flex items-center gap-3 p-3 rounded-lg border border-transparent hover:border-border/50 hover:bg-card-elevated transition-all duration-200" className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
> >
<div className={cn( <div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
'w-1 h-8 rounded-full shrink-0', <span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>
todo.priority === 'high' ? 'bg-red-400' : <span className={cn(
todo.priority === 'medium' ? 'bg-yellow-400' : 'bg-green-400' 'text-xs shrink-0 whitespace-nowrap',
)} /> isOverdue ? 'text-red-400' : 'text-muted-foreground'
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{todo.title}</p>
<div className="flex items-center gap-2 mt-1">
<div className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-destructive" : "text-muted-foreground"
)}> )}>
<Calendar className="h-3 w-3" />
{format(new Date(todo.due_date), 'MMM d')} {format(new Date(todo.due_date), 'MMM d')}
{isOverdue && <span className="font-medium">overdue</span>} {isOverdue && ' overdue'}
</div> </span>
</div> <Badge className={cn('text-[9px] shrink-0 py-0', priorityColors[todo.priority] || priorityColors.medium)}>
</div>
<Badge className={cn('text-[10px] shrink-0', priorityColors[todo.priority] || priorityColors.medium)}>
{todo.priority} {todo.priority}
</Badge> </Badge>
</div> </div>

View File

@ -10,25 +10,10 @@ interface UpcomingWidgetProps {
days?: number; days?: number;
} }
const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; borderColor: string; label: string }> = { const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; label: string }> = {
todo: { todo: { icon: CheckSquare, color: 'text-blue-400', label: 'TODO' },
icon: CheckSquare, event: { icon: Calendar, color: 'text-purple-400', label: 'EVENT' },
color: 'text-blue-400', reminder: { icon: Bell, color: 'text-orange-400', label: 'REMINDER' },
borderColor: 'border-l-blue-400',
label: 'Todo',
},
event: {
icon: Calendar,
color: 'text-purple-400',
borderColor: 'border-l-purple-400',
label: 'Event',
},
reminder: {
icon: Bell,
color: 'text-orange-400',
borderColor: 'border-l-orange-400',
label: 'Reminder',
},
}; };
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) { export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
@ -52,37 +37,28 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
</p> </p>
) : ( ) : (
<ScrollArea className="max-h-[400px] -mr-2 pr-2"> <ScrollArea className="max-h-[400px] -mr-2 pr-2">
<div className="space-y-1.5"> <div className="space-y-0.5">
{items.map((item, index) => { {items.map((item, index) => {
const config = typeConfig[item.type] || typeConfig.todo; const config = typeConfig[item.type] || typeConfig.todo;
const Icon = config.icon; const Icon = config.icon;
return ( return (
<div <div
key={`${item.type}-${item.id}-${index}`} key={`${item.type}-${item.id}-${index}`}
className={cn( className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
'flex items-start gap-3 p-3 rounded-lg',
'border-l-2 border border-transparent',
config.borderColor,
'hover:bg-card-elevated transition-all duration-200'
)}
> >
<Icon className={cn('h-4 w-4 mt-0.5 shrink-0', config.color)} /> <Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
<div className="flex-1 min-w-0"> <span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>
<p className="font-medium text-sm">{item.title}</p> <span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap tabular-nums">
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{item.datetime {item.datetime
? format(new Date(item.datetime), 'MMM d · h:mm a') ? format(new Date(item.datetime), 'MMM d, h:mm a')
: format(new Date(item.date), 'MMM d')} : format(new Date(item.date), 'MMM d')}
</span> </span>
<span className={cn('text-[10px] font-medium uppercase tracking-wider', config.color)}> <span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right', config.color)}>
{config.label} {config.label}
</span> </span>
</div>
</div>
{item.priority && ( {item.priority && (
<span className={cn( <span className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded', 'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0',
item.priority === 'high' ? 'bg-red-500/10 text-red-400' : item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' : item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
'bg-green-500/10 text-green-400' 'bg-green-500/10 text-green-400'

View File

@ -133,11 +133,11 @@ export interface DashboardData {
by_status: Record<string, number>; by_status: Record<string, number>;
}; };
total_incomplete_todos: number; total_incomplete_todos: number;
next_starred_event: { starred_events: Array<{
id: number; id: number;
title: string; title: string;
start_datetime: string; start_datetime: string;
} | null; }>;
} }
export interface WeatherData { export interface WeatherData {