/** * DayView - single-day view with hourly time slots. * * Click an empty slot -> fires `onCreateAt(date)`. * Click an entry -> fires `onEditEntry(entry)`. * Drag an entry to another time slot -> calls `onMoveEntry(entry, newStart)`. */ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import type { CalendarEntry } from '@/api/calendar'; export interface DayViewProps { visibleDay: Date; entries: CalendarEntry[]; loading?: boolean; onCreateAt: (date: Date) => void; onEditEntry: (entry: CalendarEntry) => void; onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise; } const HOUR_START = 0; const HOUR_END = 24; const SLOT_HEIGHT = 56; // px per hour - slightly taller for single-day view function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function sameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function fmtTime(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } export function DayView({ visibleDay, entries, loading, onCreateAt, onEditEntry, onMoveEntry, }: DayViewProps) { const { t } = useTranslation(); const today = startOfDay(new Date()); const day = startOfDay(visibleDay); const isToday = sameDay(day, today); const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[]; const months = t('calendar.months', { returnObjects: true }) as string[]; const weekdayIdx = (day.getDay() + 6) % 7; // 0 = Monday const hours = useMemo(() => { return Array.from({ length: HOUR_END - HOUR_START }, (_, i) => HOUR_START + i); }, []); const dayEntries = useMemo(() => { return entries.filter((e) => { if (!e.start_at) return false; return sameDay(new Date(e.start_at), day); }); }, [entries, day]); const handleDragStart = (e: React.DragEvent, entry: CalendarEntry) => { e.dataTransfer.setData('application/x-calendar-entry', entry.id); e.dataTransfer.effectAllowed = 'move'; }; const handleDragOver = (e: React.DragEvent) => { if (e.dataTransfer.types.includes('application/x-calendar-entry')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }; const handleDrop = (e: React.DragEvent, hour: number) => { e.preventDefault(); const id = e.dataTransfer.getData('application/x-calendar-entry'); if (!id) return; const entry = entries.find((x) => x.id === id); if (!entry || !entry.start_at) return; const oldStart = new Date(entry.start_at); const newStart = new Date( day.getFullYear(), day.getMonth(), day.getDate(), hour, oldStart.getMinutes(), oldStart.getSeconds(), ); void onMoveEntry(entry, newStart); }; return (
{/* Header */}
{weekdays[weekdayIdx]}
{day.getDate()}
{months[day.getMonth()]} {day.getFullYear()}
{/* Scrollable time grid */}
{/* Time labels */}
{hours.map((hour) => (
{String(hour).padStart(2, '0')}:00
))}
{/* Day column */}
{/* Hour slots */} {hours.map((hour) => (
!loading && onCreateAt(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, 0)) } onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, hour)} data-testid={`day-slot-${hour}`} /> ))} {/* Entries positioned absolutely */} {dayEntries.map((entry) => { if (!entry.start_at) return null; const start = new Date(entry.start_at); const end = entry.end_at ? new Date(entry.end_at) : new Date(start.getTime() + 60 * 60 * 1000); const startHour = start.getHours() + start.getMinutes() / 60; const endHour = end.getHours() + end.getMinutes() / 60; const durationHours = Math.max(endHour - startHour, 0.5); const top = (startHour - HOUR_START) * SLOT_HEIGHT; const height = durationHours * SLOT_HEIGHT; return (
handleDragStart(e, entry)} onClick={(e) => { e.stopPropagation(); onEditEntry(entry); }} className={clsx( 'absolute left-2 right-2 rounded px-2 py-1 text-sm cursor-grab active:cursor-grabbing', 'bg-primary-100 text-primary-900 hover:bg-primary-200 overflow-hidden', entry.entry_type === 'task' && 'bg-warning-100 text-warning-900 hover:bg-warning-200', )} style={{ top: `${top}px`, height: `${height}px`, minHeight: '24px' }} title={entry.title} data-testid={`day-entry-${entry.id}`} >
{entry.title}
{fmtTime(start)}{entry.end_at ? ` - ${fmtTime(end)}` : ''}
{entry.location && (
@ {entry.location}
)}
); })}
); } export default DayView;