60 lines
2.1 KiB
TypeScript
60 lines
2.1 KiB
TypeScript
|
|
/**
|
||
|
|
* CalendarUpcomingWidget — shows next 3 upcoming events (Task 5.25).
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React from 'react';
|
||
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { listEntries, type CalendarEntry } from '@/api/calendar';
|
||
|
|
import { formatDateTime } from '@/utils/date';
|
||
|
|
import { Calendar, MapPin } from 'lucide-react';
|
||
|
|
|
||
|
|
export function CalendarUpcomingWidget() {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const { data, isLoading, isError } = useQuery({
|
||
|
|
queryKey: ['calendarEntries', 'upcoming'],
|
||
|
|
queryFn: () => listEntries(),
|
||
|
|
staleTime: 60 * 1000,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div className="animate-pulse space-y-2" data-testid="calendar-upcoming-loading">{[...Array(2)].map((_, i) => <div key={i} className="h-4 bg-secondary-100 rounded" />)}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isError) {
|
||
|
|
return <p className="text-sm text-secondary-500" data-testid="calendar-upcoming-error">{t('dashboard.widgetError')}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
const entries: CalendarEntry[] = data ?? [];
|
||
|
|
const now = new Date();
|
||
|
|
const upcoming = entries
|
||
|
|
.filter((e: CalendarEntry) => new Date(e.start_at || e.due_date || '') >= now)
|
||
|
|
.slice(0, 3);
|
||
|
|
|
||
|
|
if (upcoming.length === 0) {
|
||
|
|
return <p className="text-sm text-secondary-500" data-testid="calendar-upcoming-empty">{t('dashboard.noUpcomingEvents')}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-3" data-testid="calendar-upcoming-widget">
|
||
|
|
{upcoming.map((entry: CalendarEntry) => (
|
||
|
|
<div key={entry.id} className="flex items-start gap-2 text-sm">
|
||
|
|
<Calendar className="w-3.5 h-3.5 text-primary-500 mt-0.5" />
|
||
|
|
<div className="flex-1">
|
||
|
|
<div className="font-medium text-secondary-800">{entry.title || '—'}</div>
|
||
|
|
<div className="text-secondary-500 text-xs">
|
||
|
|
{formatDateTime(entry.start_at || entry.due_date) || ''}
|
||
|
|
</div>
|
||
|
|
{entry.location && (
|
||
|
|
<div className="flex items-center gap-1 text-secondary-400 text-xs mt-0.5">
|
||
|
|
<MapPin className="w-3 h-3" />
|
||
|
|
{entry.location}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|