import { ChevronLeftIcon, ChevronRightIcon, ClockIcon, MapPinIcon, PlusIcon, } from '@heroicons/react/20/solid' import { useEffect, useRef, useState, type PointerEvent } from 'react' import ButtonGroup from '../ButtonGroup' import { timeToMinutes, workingHoursForDate, type CalendarCoreWorkingHours, } from './calendarSettings' import { toDateKey } from './dateUtils' export type CalendarView = 'month' | 'week' | 'day' | 'year' export type CalendarEventColor = | 'indigo' | 'blue' | 'emerald' | 'amber' | 'rose' | 'slate' export type CalendarEvent = { id: string title: string start: string end: string allDay: boolean location: string description: string color: CalendarEventColor } type CalendarProps = { events: CalendarEvent[] coreWorkingHours: CalendarCoreWorkingHours selectedDate: Date view: CalendarView onSelectedDateChange: (date: Date) => void onViewChange: (view: CalendarView) => void onCreateEvent: (start: Date, end?: Date, allDay?: boolean) => void onEditEvent: (event: CalendarEvent) => void } const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const viewLabels: Record = { day: 'Tag', week: 'Woche', month: 'Monat', year: 'Jahr', } const viewOptions = (Object.keys(viewLabels) as CalendarView[]).map((view) => ({ id: view, label: viewLabels[view], })) const eventColors: Record< CalendarEventColor, { card: string; dot: string; text: string } > = { indigo: { card: 'bg-indigo-50 text-indigo-700 ring-indigo-200 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/20 dark:hover:bg-indigo-500/25', dot: 'bg-indigo-500', text: 'text-indigo-600 dark:text-indigo-300', }, blue: { card: 'bg-blue-50 text-blue-700 ring-blue-200 hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-200 dark:ring-blue-400/20 dark:hover:bg-blue-500/25', dot: 'bg-blue-500', text: 'text-blue-600 dark:text-blue-300', }, emerald: { card: 'bg-emerald-50 text-emerald-700 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/20 dark:hover:bg-emerald-500/25', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-300', }, amber: { card: 'bg-amber-50 text-amber-800 ring-amber-200 hover:bg-amber-100 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/20 dark:hover:bg-amber-500/25', dot: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-300', }, rose: { card: 'bg-rose-50 text-rose-700 ring-rose-200 hover:bg-rose-100 dark:bg-rose-500/15 dark:text-rose-200 dark:ring-rose-400/20 dark:hover:bg-rose-500/25', dot: 'bg-rose-500', text: 'text-rose-600 dark:text-rose-300', }, slate: { card: 'bg-slate-100 text-slate-700 ring-slate-200 hover:bg-slate-200 dark:bg-white/10 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-white/15', dot: 'bg-slate-500', text: 'text-slate-600 dark:text-slate-300', }, } function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } function startOfDay(date: Date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()) } function addDays(date: Date, amount: number) { const next = new Date(date) next.setDate(next.getDate() + amount) return startOfDay(next) } function addMonths(date: Date, amount: number) { const next = new Date(date.getFullYear(), date.getMonth() + amount, 1) return startOfDay(next) } function addYears(date: Date, amount: number) { return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate()) } function startOfWeek(date: Date) { const normalized = startOfDay(date) const mondayOffset = (normalized.getDay() + 6) % 7 return addDays(normalized, -mondayOffset) } function isSameDay(left: Date, right: Date) { return toDateKey(left) === toDateKey(right) } function isToday(date: Date) { return isSameDay(date, new Date()) } function eventsForDay(events: CalendarEvent[], date: Date) { const dayStart = startOfDay(date) const dayEnd = addDays(dayStart, 1) return events .filter((event) => { const eventStart = new Date(event.start) const eventEnd = new Date(event.end) return eventStart < dayEnd && eventEnd > dayStart }) .sort( (left, right) => Number(right.allDay) - Number(left.allDay) || new Date(left.start).getTime() - new Date(right.start).getTime(), ) } function formatTime(value: string | Date) { return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(typeof value === 'string' ? new Date(value) : value) } function dateAtMinutes(date: Date, minutes: number) { const result = startOfDay(date) result.setMinutes(minutes) return result } function addMinutes(date: Date, minutes: number) { return new Date(date.getTime() + minutes * 60_000) } function normalizeTimeRange(anchor: Date, focus: Date) { const start = anchor <= focus ? anchor : focus const lastSlot = anchor <= focus ? focus : anchor return { start, end: addMinutes(lastSlot, 30), } } function formatSlotTime(minutes: number) { return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String( minutes % 60, ).padStart(2, '0')}` } function titleForView(date: Date, view: CalendarView) { if (view === 'day') { return new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', }).format(date) } if (view === 'week') { const first = startOfWeek(date) const last = addDays(first, 6) const firstLabel = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: first.getMonth() === last.getMonth() ? undefined : 'short', }).format(first) const lastLabel = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'short', year: 'numeric', }).format(last) return `${firstLabel} - ${lastLabel}` } return new Intl.DateTimeFormat('de-DE', { month: view === 'month' ? 'long' : undefined, year: 'numeric', }).format(date) } function MonthView({ events, selectedDate, onSelectedDateChange, onCreateEvent, onEditEvent, onOpenDay, }: Omit & { onOpenDay: (date: Date) => void }) { const monthStart = new Date( selectedDate.getFullYear(), selectedDate.getMonth(), 1, ) const gridStart = startOfWeek(monthStart) const days = Array.from({ length: 42 }, (_, index) => addDays(gridStart, index), ) const selectedEvents = eventsForDay(events, selectedDate) const gridRef = useRef(null) const dragRef = useRef<{ pointerId: number anchorIndex: number focusIndex: number } | null>(null) const [daySelection, setDaySelection] = useState<{ startIndex: number endIndex: number } | null>(null) const [hoveredIndex, setHoveredIndex] = useState(null) // Spiegel der aktuellen Props für den nativen Wheel-Listener, der nur einmal // registriert wird und sonst veraltete Werte einfangen würde. const selectedDateRef = useRef(selectedDate) selectedDateRef.current = selectedDate const onSelectedDateChangeRef = useRef(onSelectedDateChange) onSelectedDateChangeRef.current = onSelectedDateChange // Mausrad über dem Raster wechselt den Monat. Die 250-ms-Sperre verhindert, // dass ein einzelner Scroll-Schub (Trackpad-Momentum) mehrere Monate springt. useEffect(() => { const grid = gridRef.current if (!grid) { return } let lastSwitch = 0 function handleWheel(event: WheelEvent) { if ( event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY) ) { return } event.preventDefault() const now = Date.now() if (now - lastSwitch < 250) { return } lastSwitch = now onSelectedDateChangeRef.current( addMonths(selectedDateRef.current, event.deltaY > 0 ? 1 : -1), ) } grid.addEventListener('wheel', handleWheel, { passive: false }) return () => grid.removeEventListener('wheel', handleWheel) }, []) function getDayIndexFromPointer( clientX: number, clientY: number, clampToGrid = false, ) { const grid = gridRef.current if (!grid) { return null } const bounds = grid.getBoundingClientRect() const rawX = clientX - bounds.left const rawY = clientY - bounds.top if ( !clampToGrid && (rawX < 0 || rawX >= bounds.width || rawY < 0 || rawY >= bounds.height) ) { return null } const relativeX = Math.min(bounds.width - 1, Math.max(0, rawX)) const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY)) const column = Math.min(6, Math.floor((relativeX / bounds.width) * 7)) const row = Math.min(5, Math.floor((relativeY / bounds.height) * 6)) return row * 7 + column } function handleGridPointerDown(event: PointerEvent) { if (event.button !== 0) { return } if ( event.target instanceof HTMLElement && event.target.closest('[data-calendar-event], [data-calendar-skip-drag]') ) { return } const index = getDayIndexFromPointer(event.clientX, event.clientY) if (index === null || !gridRef.current) { return } event.preventDefault() dragRef.current = { pointerId: event.pointerId, anchorIndex: index, focusIndex: index, } setHoveredIndex(null) setDaySelection({ startIndex: index, endIndex: index }) gridRef.current.setPointerCapture(event.pointerId) } function handleGridPointerMove(event: PointerEvent) { const drag = dragRef.current if (drag) { const index = getDayIndexFromPointer(event.clientX, event.clientY, true) if (index === null || index === drag.focusIndex) { return } drag.focusIndex = index setDaySelection({ startIndex: Math.min(drag.anchorIndex, index), endIndex: Math.max(drag.anchorIndex, index), }) return } if (event.pointerType !== 'mouse') { return } const index = getDayIndexFromPointer(event.clientX, event.clientY) setHoveredIndex((current) => (current === index ? current : index)) } function finishGridSelection(event: PointerEvent) { const drag = dragRef.current if (!drag) { return } const index = getDayIndexFromPointer(event.clientX, event.clientY, true) ?? drag.focusIndex const startIndex = Math.min(drag.anchorIndex, index) const endIndex = Math.max(drag.anchorIndex, index) if (gridRef.current?.hasPointerCapture(drag.pointerId)) { gridRef.current.releasePointerCapture(drag.pointerId) } dragRef.current = null setDaySelection(null) if (startIndex === endIndex) { onOpenDay(days[startIndex]) return } onSelectedDateChange(days[startIndex]) onCreateEvent(days[startIndex], days[endIndex], true) } function cancelGridSelection() { const drag = dragRef.current if (drag && gridRef.current?.hasPointerCapture(drag.pointerId)) { gridRef.current.releasePointerCapture(drag.pointerId) } dragRef.current = null setDaySelection(null) } return (
{weekdayLabels.map((weekday) => (
{weekday}
))}
{ if (!dragRef.current) { setHoveredIndex(null) } }} className="grid min-h-[32rem] flex-1 grid-cols-7 grid-rows-6 gap-px bg-gray-200 select-none dark:bg-white/10" > {days.map((day, index) => { const dayEvents = eventsForDay(events, day) const currentMonth = day.getMonth() === selectedDate.getMonth() const selected = isSameDay(day, selectedDate) const inSelection = daySelection !== null && index >= daySelection.startIndex && index <= daySelection.endIndex return (
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onOpenDay(day) } }} > {inSelection && (
) })}

{new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: '2-digit', month: 'long', }).format(selectedDate)}

{selectedEvents.length === 0 ? (

Keine Termine an diesem Tag.

) : (
    {selectedEvents.map((event) => (
  1. ))}
)}
) } function EventListButton({ event, onClick, }: { event: CalendarEvent onClick: (event: CalendarEvent) => void }) { return ( ) } function TimeGridView({ events, coreWorkingHours, selectedDate, days, onSelectedDateChange, onCreateEvent, onEditEvent, }: Omit & { days: Date[] }) { const timeGridRef = useRef(null) const interactionRef = useRef<{ pointerId: number anchor: Date focus: Date } | null>(null) const [hoveredSlot, setHoveredSlot] = useState<{ dayKey: string minutes: number } | null>(null) const [timeSelection, setTimeSelection] = useState<{ anchor: Date focus: Date } | null>(null) const hours = Array.from({ length: 24 }, (_, index) => index) const allDayEvents = days.flatMap((day) => eventsForDay(events, day).filter((event) => event.allDay), ) const selectedRange = timeSelection ? normalizeTimeRange(timeSelection.anchor, timeSelection.focus) : null const gridTemplateColumns = `3.5rem repeat(${days.length}, minmax(7rem, 1fr))` const showHoverSlotTopEdge = days.length === 1 function getSlotFromPointer( clientX: number, clientY: number, clampToGrid = false, ) { const grid = timeGridRef.current if (!grid) { return null } const bounds = grid.getBoundingClientRect() const timeGutterWidth = 56 const daysWidth = bounds.width - timeGutterWidth const rawX = clientX - bounds.left - timeGutterWidth const rawY = clientY - bounds.top if ( !clampToGrid && (rawX < 0 || rawX >= daysWidth || rawY < 0 || rawY >= bounds.height) ) { return null } const relativeX = Math.min(daysWidth - 1, Math.max(0, rawX)) const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY)) const dayIndex = Math.min( days.length - 1, Math.floor((relativeX / daysWidth) * days.length), ) const minutes = Math.min(47, Math.floor((relativeY / bounds.height) * 48)) * 30 return dateAtMinutes(days[dayIndex], minutes) } function handleTimeGridPointerDown(event: PointerEvent) { if (event.button !== 0) { return } if ( event.target instanceof HTMLElement && event.target.closest('[data-calendar-event]') ) { return } const slot = getSlotFromPointer(event.clientX, event.clientY) if (!slot || !timeGridRef.current) { return } event.preventDefault() interactionRef.current = { pointerId: event.pointerId, anchor: slot, focus: slot, } setHoveredSlot(null) setTimeSelection({ anchor: slot, focus: slot }) timeGridRef.current.setPointerCapture(event.pointerId) } function handleTimeGridPointerMove(event: PointerEvent) { const interaction = interactionRef.current const slot = getSlotFromPointer( event.clientX, event.clientY, Boolean(interaction), ) if (!slot) { setHoveredSlot(null) return } if (interaction) { interaction.focus = slot setTimeSelection({ anchor: interaction.anchor, focus: slot, }) return } setHoveredSlot({ dayKey: toDateKey(slot), minutes: slot.getHours() * 60 + slot.getMinutes(), }) } function finishTimeGridSelection(event: PointerEvent) { const interaction = interactionRef.current if (!interaction) { return } const focus = getSlotFromPointer(event.clientX, event.clientY, true) ?? interaction.focus const range = normalizeTimeRange(interaction.anchor, focus) if (timeGridRef.current?.hasPointerCapture(interaction.pointerId)) { timeGridRef.current.releasePointerCapture(interaction.pointerId) } interactionRef.current = null setTimeSelection(null) setHoveredSlot(null) onSelectedDateChange(range.start) onCreateEvent(range.start, range.end) } function cancelTimeGridSelection() { const interaction = interactionRef.current if ( interaction && timeGridRef.current?.hasPointerCapture(interaction.pointerId) ) { timeGridRef.current.releasePointerCapture(interaction.pointerId) } interactionRef.current = null setTimeSelection(null) setHoveredSlot(null) } return (
{days.map((day) => ( ))}
{allDayEvents.length > 0 && (
Ganz
{days.map((day) => (
{eventsForDay(events, day) .filter((event) => event.allDay) .map((event) => ( ))}
))}
)}
{ if (!interactionRef.current) { setHoveredSlot(null) } }} className="grid w-full" style={{ gridTemplateColumns }} >
{hours.map((hour) => (
{String(hour).padStart(2, '0')}:00
))}
{days.map((day) => { const timedEvents = eventsForDay(events, day).filter( (event) => !event.allDay, ) const coreHours = workingHoursForDate(coreWorkingHours, day) const coreStartMinutes = coreHours ? timeToMinutes(coreHours.start) : 0 const coreEndMinutes = coreHours ? timeToMinutes(coreHours.end) : 0 const dayStart = startOfDay(day) const dayEnd = addDays(dayStart, 1) const selectionStart = selectedRange && selectedRange.end > dayStart ? new Date( Math.max( selectedRange.start.getTime(), dayStart.getTime(), ), ) : null const selectionEnd = selectedRange && selectedRange.start < dayEnd ? new Date( Math.min(selectedRange.end.getTime(), dayEnd.getTime()), ) : null const hasSelection = selectionStart && selectionEnd && selectionEnd > selectionStart const selectionStartMinutes = selectionStart ? (selectionStart.getTime() - dayStart.getTime()) / 60_000 : 0 const selectionDurationMinutes = selectionStart && selectionEnd ? (selectionEnd.getTime() - selectionStart.getTime()) / 60_000 : 0 return (
{coreHours?.enabled && coreEndMinutes > coreStartMinutes && (
)} {hasSelection && ( )} {!timeSelection && hoveredSlot?.dayKey === toDateKey(day) && ( )} {timedEvents.map((event) => { const eventStart = new Date(event.start) const eventEnd = new Date(event.end) const segmentStart = new Date( Math.max(eventStart.getTime(), dayStart.getTime()), ) const segmentEnd = new Date( Math.min(eventEnd.getTime(), dayEnd.getTime()), ) const startMinutes = (segmentStart.getTime() - dayStart.getTime()) / 60_000 const durationMinutes = Math.max( 30, (segmentEnd.getTime() - segmentStart.getTime()) / 60_000, ) return ( ) })}
) })}
) } function YearView({ events, selectedDate, onOpenDay, }: Pick & { onOpenDay: (date: Date) => void }) { const months = Array.from( { length: 12 }, (_, month) => new Date(selectedDate.getFullYear(), month, 1), ) return (
{months.map((month) => { const gridStart = startOfWeek(month) const days = Array.from({ length: 42 }, (_, index) => addDays(gridStart, index), ) return (

{new Intl.DateTimeFormat('de-DE', { month: 'long' }).format( month, )}

{weekdayLabels.map((weekday) => (
{weekday.slice(0, 1)}
))}
{days.map((day) => { const inMonth = day.getMonth() === month.getMonth() const dayEvents = eventsForDay(events, day) const selected = isSameDay(day, selectedDate) return ( ) })}
) })}
) } export default function Calendar({ events, coreWorkingHours, selectedDate, view, onSelectedDateChange, onViewChange, onCreateEvent, onEditEvent, }: CalendarProps) { function openDay(date: Date) { onSelectedDateChange(date) onViewChange('day') } function move(amount: number) { if (view === 'day') { onSelectedDateChange(addDays(selectedDate, amount)) return } if (view === 'week') { onSelectedDateChange(addDays(selectedDate, amount * 7)) return } if (view === 'month') { onSelectedDateChange(addMonths(selectedDate, amount)) return } onSelectedDateChange(addYears(selectedDate, amount)) } const weekDays = Array.from({ length: 7 }, (_, index) => addDays(startOfWeek(selectedDate), index), ) return (

Kalender

{titleForView(selectedDate, view)}

onViewChange(nextView as CalendarView)} />
{view === 'month' && ( )} {view === 'week' && ( )} {view === 'day' && ( )} {view === 'year' && ( )}
) }