UMBRA/frontend/src/components/dashboard/CalendarWidget.tsx
Kyle Pope ac3f746ba3 Fix QA findings: combine todo queries, remove dead prop, add aria-labels
- Merge total_todos and total_incomplete_todos into single DB query (W-04)
- Remove unused `days` prop from UpcomingWidget interface (W-03)
- Add aria-label to focus/show-past toggle buttons (S-08)
- Add zero-duration event guard in CalendarWidget progress calc (S-07)
- Combine duplicate date-utils imports (S-01)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:16:00 +08:00

123 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface DashboardEvent {
id: number;
title: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
color?: string;
is_starred?: boolean;
}
interface CalendarWidgetProps {
events: DashboardEvent[];
}
function getEventTimeState(event: DashboardEvent, now: Date) {
if (event.all_day) return 'all-day' as const;
const start = new Date(event.start_datetime).getTime();
const end = new Date(event.end_datetime).getTime();
const current = now.getTime();
if (current >= end) return 'past' as const;
if (current >= start && current < end) return 'current' as const;
return 'future' as const;
}
function getProgressPercent(event: DashboardEvent, now: Date): number {
if (event.all_day) return 0;
const start = new Date(event.start_datetime).getTime();
const end = new Date(event.end_datetime).getTime();
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) {
const navigate = useNavigate();
const todayStr = format(new Date(), 'yyyy-MM-dd');
const [clientNow, setClientNow] = useState(() => new Date());
useEffect(() => {
const interval = setInterval(() => setClientNow(new Date()), 60_000);
return () => clearInterval(interval);
}, []);
return (
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-purple-500/10">
<Calendar className="h-4 w-4 text-purple-400" />
</div>
Today's Events
</CardTitle>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 gap-2">
<div className="rounded-full bg-muted p-4">
<Calendar className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">Enjoy the free time</p>
</div>
) : (
<div className="space-y-0.5">
{events.map((event) => {
const timeState = getEventTimeState(event, clientNow);
const progress = getProgressPercent(event, clientNow);
const isCurrent = timeState === 'current';
const isPast = timeState === 'past';
return (
<div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
className={cn(
'flex items-center gap-2 py-1.5 rounded-md hover:bg-card-elevated transition-colors duration-150 cursor-pointer relative pl-3.5',
isCurrent && 'bg-accent/[0.05]',
isPast && 'opacity-50'
)}
>
{/* Time progress bar — always rendered for consistent layout */}
<div
className="absolute left-0 top-1 bottom-1 w-0.5 rounded-full overflow-hidden"
style={{ backgroundColor: event.all_day ? 'transparent' : 'hsl(var(--border))' }}
>
{!event.all_day && (
<div
className={cn('w-full rounded-full transition-all duration-1000', isPast && 'opacity-50')}
style={{
height: `${progress}%`,
backgroundColor: event.color || 'hsl(var(--accent-color))',
}}
/>
)}
</div>
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/>
<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>
)}
</CardContent>
</Card>
);
}