import { useMemo, useState, type FormEvent, type HTMLAttributes } from 'react'; import { cx } from '../utils/cx'; import type { Tone } from '../utils/types'; import { addMonths, dateKey, isSameDay, isSameMonth, monthGrid, startOfDay, startOfMonth, weekdayNames, } from '../utils/date'; import { useControllableState } from '../hooks/useControllableState'; import { Button, IconButton } from './Button'; import { Card } from './Card'; import { Icon } from './Icon'; import { Swatch, SwatchGroup } from './Swatch'; import { TextField } from './TextField'; export type EventTone = Extract; export interface CalendarEvent { id: string; title: string; date: Date; /** Optional clock time, displayed as-is (e.g. `09:30`). */ time?: string; description?: string; tone?: EventTone; } export interface EventCalendarProps extends Omit, 'onChange' | 'defaultValue'> { events?: CalendarEvent[]; defaultEvents?: CalendarEvent[]; onEventsChange?: (events: CalendarEvent[]) => void; onEventAdd?: (event: CalendarEvent) => void; onEventUpdate?: (event: CalendarEvent) => void; onEventRemove?: (id: string) => void; month?: Date; defaultMonth?: Date; onMonthChange?: (month: Date) => void; selectedDate?: Date; defaultSelectedDate?: Date; onSelectedDateChange?: (date: Date) => void; locale?: string; weekStartsOn?: 0 | 1; readOnly?: boolean; } const EMPTY_EVENTS: CalendarEvent[] = []; function createId(): string { return `mors-evt-${Math.random().toString(36).slice(2, 9)}`; } function byDay(events: readonly CalendarEvent[]): Map { const map = new Map(); for (const event of events) { const key = dateKey(event.date); const list = map.get(key); if (list) list.push(event); else map.set(key, [event]); } for (const list of map.values()) { list.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? '') || a.title.localeCompare(b.title)); } return map; } export function EventCalendar({ events, defaultEvents = EMPTY_EVENTS, onEventsChange, onEventAdd, onEventUpdate, onEventRemove, month, defaultMonth, onMonthChange, selectedDate, defaultSelectedDate, onSelectedDateChange, locale, weekStartsOn = 1, readOnly = false, className, ...rest }: EventCalendarProps) { const [items, setItems] = useControllableState(events, defaultEvents, onEventsChange); const [visibleMonth, setVisibleMonth] = useControllableState( month, startOfMonth(defaultMonth ?? selectedDate ?? defaultSelectedDate ?? new Date()), onMonthChange, ); const [selected, setSelected] = useControllableState( selectedDate, startOfDay(defaultSelectedDate ?? new Date()), onSelectedDateChange, ); const [draftTitle, setDraftTitle] = useState(''); const [draftDescription, setDraftDescription] = useState(''); const [draftTime, setDraftTime] = useState(''); const [draftTone, setDraftTone] = useState('accent'); const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editDescription, setEditDescription] = useState(''); const today = startOfDay(new Date()); const days = monthGrid(visibleMonth, weekStartsOn); const weekdays = weekdayNames(locale, weekStartsOn); const grouped = useMemo(() => byDay(items), [items]); const monthLabel = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format( visibleMonth, ); const dayHeading = new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', }).format(selected); const selectedEvents = grouped.get(dateKey(selected)) ?? []; const addEvent = (event: FormEvent) => { event.preventDefault(); const title = draftTitle.trim(); if (!title) return; const next: CalendarEvent = { id: createId(), title, date: startOfDay(selected), time: draftTime.trim() || undefined, description: draftDescription.trim() || undefined, tone: draftTone, }; setItems([...items, next]); onEventAdd?.(next); setDraftTitle(''); setDraftDescription(''); setDraftTime(''); }; const beginEdit = (entry: CalendarEvent) => { setEditingId(entry.id); setEditTitle(entry.title); setEditDescription(entry.description ?? ''); }; const saveEdit = (id: string) => { const title = editTitle.trim(); if (!title) return; let updated: CalendarEvent | undefined; setItems( items.map((entry) => { if (entry.id !== id) return entry; updated = { ...entry, title, description: editDescription.trim() || undefined, }; return updated; }), ); if (updated) onEventUpdate?.(updated); setEditingId(null); }; const removeEvent = (id: string) => { setItems(items.filter((entry) => entry.id !== id)); onEventRemove?.(id); if (editingId === id) setEditingId(null); }; return (
} variant="ghost" size="sm" onClick={() => setVisibleMonth(startOfMonth(addMonths(visibleMonth, -1)))} />

{monthLabel}

} variant="ghost" size="sm" onClick={() => setVisibleMonth(startOfMonth(addMonths(visibleMonth, 1)))} />
{weekdays.map((day) => ( {day.short} ))}
{days.map((date) => { const key = dateKey(date); const dayEvents = grouped.get(key) ?? []; const overflow = Math.max(0, dayEvents.length - 3); const visible = dayEvents.slice(0, overflow > 0 ? 2 : 3); const outside = !isSameMonth(date, visibleMonth); const isSelected = isSameDay(date, selected); const isToday = isSameDay(date, today); return ( ); })}

{dayHeading}

{selectedEvents.length === 0 ? 'No events' : `${selectedEvents.length} event${selectedEvents.length === 1 ? '' : 's'}`}

    {selectedEvents.map((entry) => (
  • {editingId === entry.id ? (
    { event.preventDefault(); saveEdit(entry.id); }} >
    {entry.time && {entry.time}}
    setEditTitle(event.currentTarget.value)} size="sm" required /> setEditDescription(event.currentTarget.value)} size="sm" />
    ) : (
    {entry.time && {entry.time}} {entry.title} {entry.description && ( {entry.description} )}
    {!readOnly && ( } size="sm" variant="ghost" onClick={() => beginEdit(entry)} /> )}
    )}
  • ))}
{!readOnly && (
setDraftTitle(event.currentTarget.value)} size="sm" required /> setDraftDescription(event.currentTarget.value)} size="sm" />
setDraftTime(event.currentTarget.value)} size="sm" /> setDraftTone(tone)} />
)}
); }