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()
# Next starred event (soonest future starred event)
# Starred events (upcoming, ordered by date)
now = datetime.now()
starred_query = select(CalendarEvent).where(
CalendarEvent.is_starred == True,
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)
next_starred = starred_result.scalar_one_or_none()
starred_events = starred_result.scalars().all()
next_starred_event_data = None
if next_starred:
next_starred_event_data = {
"id": next_starred.id,
"title": next_starred.title,
"start_datetime": next_starred.start_datetime
starred_events_data = [
{
"id": e.id,
"title": e.title,
"start_datetime": e.start_datetime
}
for e in starred_events
]
return {
"todays_events": [
@ -122,7 +123,7 @@ async def get_dashboard(
"by_status": projects_by_status
},
"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) => (
<div
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
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))' }}
/>
<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
? 'All day'
: `${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';
interface CountdownWidgetProps {
event: {
events: Array<{
id: number;
title: string;
start_datetime: string;
};
}>;
}
export default function CountdownWidget({ event }: CountdownWidgetProps) {
const daysUntil = differenceInCalendarDays(new Date(event.start_datetime), new Date());
if (daysUntil < 0) return null;
const label = daysUntil === 0
? 'Today'
: daysUntil === 1
? '1 day'
: `${daysUntil} days`;
export default function CountdownWidget({ events }: CountdownWidgetProps) {
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
if (visible.length === 0) return null;
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">
<Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate">
<span className="font-semibold tabular-nums">{label}</span>
{daysUntil > 0 ? ' until ' : ' — '}
<span className="font-medium text-foreground">{event.title}</span>
</span>
<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) => {
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="font-semibold tabular-nums">{label}</span>
{days > 0 ? ' until ' : ' — '}
<span className="font-medium text-foreground">{event.title}</span>
</span>
</div>
);
})}
</div>
);
}

View File

@ -197,8 +197,8 @@ export default function DashboardPage() {
{/* Right: Countdown + Today's events + todos stacked */}
<div className="lg:col-span-2 flex flex-col gap-5">
{data.next_starred_event && (
<CountdownWidget event={data.next_starred_event} />
{data.starred_events.length > 0 && (
<CountdownWidget events={data.starred_events} />
)}
<CalendarWidget events={data.todays_events} />
<TodoWidget todos={data.upcoming_todos} />
@ -217,19 +217,17 @@ export default function DashboardPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="space-y-0.5">
{data.active_reminders.map((reminder) => (
<div
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="flex-1">
<p className="font-medium text-sm">{reminder.title}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
<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">
{format(new Date(reminder.remind_at), 'MMM d, h:mm a')}
</span>
</div>
))}
</div>

View File

@ -7,7 +7,7 @@ interface StatsWidgetProps {
by_status: Record<string, 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) {
@ -73,6 +73,11 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
<p className="text-[11px] text-muted-foreground capitalize leading-tight">
{weatherData.description}
</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 { Calendar, CheckCircle2 } from 'lucide-react';
import { CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
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',
};
const dotColors: Record<string, string> = {
high: 'bg-red-400',
medium: 'bg-yellow-400',
low: 'bg-green-400',
};
export default function TodoWidget({ todos }: TodoWidgetProps) {
return (
<Card>
@ -39,33 +45,24 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
All caught up.
</p>
) : (
<div className="space-y-2">
<div className="space-y-0.5">
{todos.slice(0, 5).map((todo) => {
const isOverdue = isPast(endOfDay(new Date(todo.due_date)));
return (
<div
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(
'w-1 h-8 rounded-full shrink-0',
todo.priority === 'high' ? 'bg-red-400' :
todo.priority === 'medium' ? 'bg-yellow-400' : 'bg-green-400'
)} />
<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')}
{isOverdue && <span className="font-medium">overdue</span>}
</div>
</div>
</div>
<Badge className={cn('text-[10px] shrink-0', priorityColors[todo.priority] || priorityColors.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={cn(
'text-xs shrink-0 whitespace-nowrap',
isOverdue ? 'text-red-400' : 'text-muted-foreground'
)}>
{format(new Date(todo.due_date), 'MMM d')}
{isOverdue && ' overdue'}
</span>
<Badge className={cn('text-[9px] shrink-0 py-0', priorityColors[todo.priority] || priorityColors.medium)}>
{todo.priority}
</Badge>
</div>

View File

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

View File

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