/** * MonthView — monthly calendar grid with click-to-create and drag-to-move. * * Click an empty cell → fires `onCreateAt(date)`. * Click an entry → fires `onEditEntry(entry)`. * Drag an entry onto another day → calls `onMoveEntry(entry, newDate)`. */ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import type { CalendarEntry } from '@/api/calendar'; export interface MonthViewProps { /** First day of the visible month (UTC). */ visibleMonth: Date; /** Entries to render. Entries with start_at fall on that day. */ entries: CalendarEntry[]; /** Loading state — disables interactions. */ loading?: boolean; /** Clicked an empty cell — create a new entry. */ onCreateAt: (date: Date) => void; /** Clicked an existing entry — open editor. */ onEditEntry: (entry: CalendarEntry) => void; /** Dragged an entry to a new day. */ onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise; } function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function addDays(d: Date, n: number): Date { const out = new Date(d); out.setDate(out.getDate() + n); return out; } 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 MonthView({ visibleMonth, entries, loading, onCreateAt, onEditEntry, onMoveEntry, }: MonthViewProps) { const { t } = useTranslation(); const today = startOfDay(new Date()); // Build a 6-row × 7-day grid (always 42 cells to keep height stable) const gridDays = useMemo(() => { const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1); // Week starts on Monday (DE locale) const dayOfWeek = (first.getDay() + 6) % 7; // 0 = Monday const start = addDays(first, -dayOfWeek); return Array.from({ length: 42 }, (_, i) => addDays(start, i)); }, [visibleMonth]); // Group entries by yyyy-mm-dd const entriesByDay = useMemo(() => { const map = new Map(); for (const entry of entries) { if (!entry.start_at) continue; const d = new Date(entry.start_at); const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; const list = map.get(key) ?? []; list.push(entry); map.set(key, list); } return map; }, [entries]); const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[]; const months = t('calendar.months', { returnObjects: true }) as string[]; const headerLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`; 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, day: Date ) => { 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; // Preserve original time-of-day, change only date const oldStart = new Date(entry.start_at); const newStart = new Date( day.getFullYear(), day.getMonth(), day.getDate(), oldStart.getHours(), oldStart.getMinutes(), oldStart.getSeconds() ); void onMoveEntry(entry, newStart); }; return (
{headerLabel}
{weekdays.map((wd) => (
{wd}
))}
{gridDays.map((day) => { const inMonth = day.getMonth() === visibleMonth.getMonth(); const isToday = sameDay(day, today); const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`; const dayEntries = entriesByDay.get(key) ?? []; return (
!loading && onCreateAt(day)} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !loading) { e.preventDefault(); onCreateAt(day); } }} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, day)} className={clsx( 'border border-secondary-100 min-h-[6rem] p-1 align-top cursor-pointer', 'hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', !inMonth && 'bg-secondary-50 text-secondary-400', loading && 'opacity-60 pointer-events-none' )} >
{day.getDate()}
{dayEntries.slice(0, 3).map((entry) => { const time = entry.start_at ? fmtTime(new Date(entry.start_at)) : ''; return (
handleDragStart(e, entry)} onClick={(e) => { e.stopPropagation(); onEditEntry(entry); }} data-testid={`month-entry-${entry.id}`} className={clsx( 'text-xs truncate rounded px-1 py-0.5 cursor-grab active:cursor-grabbing', 'bg-primary-100 text-primary-900 hover:bg-primary-200' )} title={entry.title} > {time && {time}} {entry.title}
); })} {dayEntries.length > 3 && (
+{dayEntries.length - 3} {t('calendar.noEntries').includes('Keine') ? 'weitere' : 'more'}
)}
); })}
); } export default MonthView;